History

Fail closed on the org selector for org-scoped reads (#1488)

Rhys Sullivan committed 3 months ago

Commit 5fa14631 parent b6f68bb12 files · +212 −69Browse files

Unified · Split

12 files
  1. apps/cloud/src/account/workos-account-service.ts +16 −7
  2. apps/cloud/src/auth/org-selector-auth.node.test.ts +13 −6
  3. apps/cloud/src/auth/workos-auth-provider.ts +20 −6
  4. apps/cloud/src/extensions/billing/route.node.test.ts +8 −13
  5. apps/cloud/src/extensions/billing/route.ts +5 −2
  6. apps/cloud/src/org/auth-middleware.ts +5 −1
  7. apps/cloud/src/routes/__root.tsx +34 −9
  8. e2e/cloud/connection-owner-isolation.test.ts +37 −15
  9. e2e/cloud/spec-update-convergence.test.ts +20 −6
  10. e2e/targets/cloud.ts +9 −1
  11. packages/react/src/api/provider.tsx +31 −3
  12. packages/react/src/api/server-connection.tsx +14 −0
apps/cloud/src/account/workos-account-service.ts+16−7
@@ -91,16 +91,19 @@ export const workosAccountProvider: Layer.Layer< · 90 unmodified lines
91 ? Effect.succeed(caller.session)91 ? Effect.succeed(caller.session)
92 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());92 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
93 93
94 // The org scope for an org-scoped request: the console URL's org (sent in94 // The org scope for an org-scoped request: the console URL's org, sent in
95 // the selector header) when present, else the session's own org. Membership95 // the selector header. FAIL CLOSED when it's missing — the session's own
96 // is re-checked live, so the header is a selector, not a trust boundary —96 // org is a browser-global pinned to whichever org WorkOS last touched, so
97 // falling back to it scopes a multi-org user's request to the WRONG org
98 // (see workos-auth-provider.resolveSessionPrincipal). Membership is
99 // re-checked live, so the header is a selector, not a trust boundary —
97 // and two browser tabs on different orgs each send their own header, so100 // and two browser tabs on different orgs each send their own header, so
98 // they stay independent (see organization.ts). Yields the session +101 // they stay independent (see organization.ts). Yields the session +
99 // resolved org, or AccountNoOrganization.102 // resolved org, or AccountNoOrganization.
100 const requireOrganization = (headers: AccountHeaders) =>103 const requireOrganization = (headers: AccountHeaders) =>
101 Effect.gen(function* () {104 Effect.gen(function* () {
102 const session = yield* requireSession();105 const session = yield* requireSession();
103 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;106 const selector = headers[ORG_SELECTOR_HEADER];
104 if (!selector) {107 if (!selector) {
105 return yield* new AccountNoOrganization();108 return yield* new AccountNoOrganization();
106 }109 }
@@ -180,9 +183,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines
180 me: (headers) =>183 me: (headers) =>
181 Effect.gen(function* () {184 Effect.gen(function* () {
182 const session = yield* requireSession();185 const session = yield* requireSession();
183 // Same selector precedence as requireOrganization: the URL's org186 // The ONE sanctioned fallback to the session org. /account/me is
184 // (header) drives /account/me so the shell reflects the org the tab187 // identity, not tenant data: on a bare URL (no org slug yet, so no
185 // is viewing, not a session-global active org.188 // header) it answers "which org should this browser land in", and
189 // the session's pinned org is the only candidate. Every org-scoped
190 // DATA read fails closed instead (requireOrganization above,
191 // resolveSessionPrincipal) — serving tenant data from this fallback
192 // is exactly the wrong-org connection-list bug (2026-07). With a
193 // header (any slugged URL), the URL's org drives the answer so the
194 // shell reflects the org the tab is viewing.
186 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;195 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
187 const org = selector196 const org = selector
188 ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(197 ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
apps/cloud/src/auth/org-selector-auth.node.test.ts+13−6
@@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; · 6 unmodified lines
7import { WorkOSClient, type WorkOSClientService } from "./workos";7import { WorkOSClient, type WorkOSClientService } from "./workos";
8 8
9// The org a console request resolves to is the URL's org (sent in the9// The org a console request resolves to is the URL's org (sent in the
10// `x-executor-organization` selector header), not the session's stored org —10// `x-executor-organization` selector header) — NEVER the session's stored org.
11// with the session org as the fallback for non-console callers, and live11// The sealed cookie's org is a browser-global pinned to whichever org WorkOS
12// membership re-checked either way. This is what makes two browser tabs on12// last touched, so a fallback to it silently scopes a multi-org user's request
13// to the wrong org; a header-less request fails closed instead. Live
14// membership is re-checked either way. This is what makes two browser tabs on
13// different orgs independent.15// different orgs independent.
14 16
15const createdAt = new Date("2026-01-01T00:00:00.000Z");17const createdAt = new Date("2026-01-01T00:00:00.000Z");
@@ -95,10 +97,15 @@ const run = (headers: Record<string, string>) => · 79 unmodified lines
95 );97 );
96 98
97describe("resolveSessionPrincipal · URL org selector", () => {99describe("resolveSessionPrincipal · URL org selector", () => {
98 it.effect("falls back to the session org when no selector header is sent", () =>100 it.effect("fails closed when no selector header is sent", () =>
99 Effect.gen(function* () {101 Effect.gen(function* () {
100 const principal = yield* run({ cookie: "wos-session=x" });102 // No fallback to the session org: the cookie's org is browser-global
101 expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);103 // and can name a different org than the tab's URL for a multi-org user.
104 const error = yield* Effect.flip(run({ cookie: "wos-session=x" }));
105 expect(error, "rejects instead of scoping to the session org").toMatchObject({
106 _tag: "NoOrganization",
107 code: "no_organization",
108 });
102 }),109 }),
103 );110 );
104 111
apps/cloud/src/auth/workos-auth-provider.ts+20−6
@@ -16,6 +16,7 @@ · 15 unmodified lines
16// - invalid api key -> Unauthorized 401 invalid_api_key16// - invalid api key -> Unauthorized 401 invalid_api_key
17// - api-key org not authorized -> NoOrganization 403 no_organization17// - api-key org not authorized -> NoOrganization 403 no_organization
18// - no/invalid session -> NoOrganization 403 no_organization18// - no/invalid session -> NoOrganization 403 no_organization
19// - session without org header -> NoOrganization 403 no_organization (fail closed)
19// - session org not authorized -> NoOrganization 403 no_organization20// - session org not authorized -> NoOrganization 403 no_organization
20// - no auth header -> falls through to the sealed-session path21// - no auth header -> falls through to the sealed-session path
21// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are22// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are
@@ -86,6 +87,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 64 unmodified lines
86 code: "no_organization",87 code: "no_organization",
87 message: "No organization in session",88 message: "No organization in session",
88};89};
90const NO_ORGANIZATION_SELECTOR = {
91 code: "no_organization",
92 message: "No organization selector in request",
93};
89const INVALID_ACCESS_TOKEN = {94const INVALID_ACCESS_TOKEN = {
90 code: "invalid_access_token",95 code: "invalid_access_token",
91 message: "Invalid or expired access token",96 message: "Invalid or expired access token",
@@ -199,13 +204,22 @@ export const resolveSessionPrincipal = (request: Request) => · 107 unmodified lines
199 if (!session) {204 if (!session) {
200 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);205 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
201 }206 }
202 // The console URL's org is the scope authority (sent as a header); the207 // The console URL's org is the scope authority, sent as a header on every
203 // session's own org is the fallback for non-console callers. Membership is208 // request. FAIL CLOSED when it's missing: the sealed cookie's org is
204 // re-checked live either way — the header is a selector, not a trust209 // browser-global and pinned to whichever org WorkOS last touched, so
205 // boundary (see organization.ts).210 // falling back to it silently serves ANOTHER org's data to a multi-org
206 const selector = orgSelectorFromRequest(request) ?? session.organizationId;211 // user (the wrong-tenant connection-list bug, 2026-07). A header-less
212 // session call gets a clear 403 instead. Membership is re-checked live —
213 // the header is a selector, not a trust boundary (see organization.ts).
214 // A bare-URL first paint (no org in the path yet) may 403 here; that's
215 // the safe outcome — OrgSlugGate immediately canonicalizes the URL onto
216 // an org slug, the org-keyed atom registry remounts, and everything
217 // refetches with the header. `/account/me` — which deliberately DOES fall
218 // back to the session org to pick that landing org — lives on the account
219 // plane, not here.
220 const selector = orgSelectorFromRequest(request);
207 if (!selector) {221 if (!selector) {
208 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);222 return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR);
209 }223 }
210 const org = yield* authorizeOrganizationSelector(session.userId, selector);224 const org = yield* authorizeOrganizationSelector(session.userId, selector);
211 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);225 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
apps/cloud/src/extensions/billing/route.node.test.ts+8−13
@@ -61,21 +61,23 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 60 unmodified lines
61 ),61 ),
62});62});
63 63
64const run = (headers: Record<string, string>, organizationId: string | null = SESSION_ORG) =>64const run = (headers: Record<string, string>) =>
65 resolveBillingOrganization(65 resolveBillingOrganization(
66 new Request("https://executor.test/api/billing/customer", { headers }),66 new Request("https://executor.test/api/billing/customer", { headers }),
67 { userId: MEMBER, organizationId },67 { userId: MEMBER },
68 ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers)));68 ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers)));
69 69
70describe("billing route org selector", () => {70describe("billing route org selector", () => {
71 it.effect("falls back to the session org when no selector header is sent", () =>71 it.effect("fails closed when no selector header is sent", () =>
72 Effect.gen(function* () {72 Effect.gen(function* () {
73 const org = yield* run({});73 // No fallback to the session org: the cookie's org is browser-global
74 expect(org.id).toBe(SESSION_ORG);74 // and can name a different org than the tab's URL for a multi-org user.
75 const error = yield* Effect.flip(run({}));
76 expect(error).toMatchObject({ _tag: "HttpResponseError", status: 401 });
75 }),77 }),
76 );78 );
77 79
78 it.effect("scopes billing to the URL org selector over the session org", () =>80 it.effect("scopes billing to the URL org selector", () =>
79 Effect.gen(function* () {81 Effect.gen(function* () {
80 const org = yield* run({ "x-executor-organization": URL_SLUG });82 const org = yield* run({ "x-executor-organization": URL_SLUG });
81 expect(org.id).toBe(URL_ORG);83 expect(org.id).toBe(URL_ORG);
@@ -88,11 +90,4 @@ describe("billing route org selector", () => { · 6 unmodified lines
88 expect(error).toMatchObject({ _tag: "HttpResponseError", status: 403 });90 expect(error).toMatchObject({ _tag: "HttpResponseError", status: 403 });
89 }),91 }),
90 );92 );
91
92 it.effect("requires either a selector header or a session org", () =>
93 Effect.gen(function* () {
94 const error = yield* Effect.flip(run({}, null));
95 expect(error).toMatchObject({ _tag: "HttpResponseError", status: 401 });
96 }),
97 );
98});93});
apps/cloud/src/extensions/billing/route.ts+5−2
@@ -13,12 +13,15 @@ import { · 12 unmodified lines
13 13
14type BillingSession = {14type BillingSession = {
15 readonly userId: string;15 readonly userId: string;
16 readonly organizationId?: string | null;
17};16};
18 17
19export const resolveBillingOrganization = (request: Request, session: BillingSession) =>18export const resolveBillingOrganization = (request: Request, session: BillingSession) =>
20 Effect.gen(function* () {19 Effect.gen(function* () {
21 const selector = request.headers.get(ORG_SELECTOR_HEADER) ?? session.organizationId;20 // FAIL CLOSED: no header, no org. The AutumnProvider always sends the
21 // URL-scoped header (see __root.tsx billingHeaders); the sealed cookie's
22 // org is a browser-global that can name a DIFFERENT org for a multi-org
23 // user (see workos-auth-provider.resolveSessionPrincipal).
24 const selector = request.headers.get(ORG_SELECTOR_HEADER);
22 if (!selector) {25 if (!selector) {
23 return yield* new HttpResponseError({26 return yield* new HttpResponseError({
24 status: 401,27 status: 401,
apps/cloud/src/org/auth-middleware.ts+5−1
@@ -40,7 +40,11 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()( · 39 unmodified lines
40 .pipe(Effect.orElseSucceed(() => null));40 .pipe(Effect.orElseSucceed(() => null));
41 if (!result) return unauthorized();41 if (!result) return unauthorized();
42 42
43 const selector = request.headers[ORG_SELECTOR_HEADER] ?? result.organizationId;43 // FAIL CLOSED: no header, no org — the sealed cookie's org is a
44 // browser-global pinned to whichever org WorkOS last touched, so
45 // falling back to it scopes a multi-org user's request to the WRONG
46 // org (see workos-auth-provider.resolveSessionPrincipal).
47 const selector = request.headers[ORG_SELECTOR_HEADER];
44 if (!selector) return noOrganization();48 if (!selector) return noOrganization();
45 49
46 const org = yield* authorizeOrganizationSelector(result.userId, selector).pipe(50 const org = yield* authorizeOrganizationSelector(result.userId, selector).pipe(
apps/cloud/src/routes/__root.tsx+34−9
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10 useParams,10 useParams,
11} from "@tanstack/react-router";11} from "@tanstack/react-router";
12import { AutumnProvider } from "autumn-js/react";12import { AutumnProvider } from "autumn-js/react";
13import { isValidOrgSlug } from "@executor-js/api";
13import posthog from "posthog-js";14import posthog from "posthog-js";
14import { PostHogProvider } from "posthog-js/react";15import { PostHogProvider } from "posthog-js/react";
15import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";16import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
@@ -214,6 +215,14 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 198 unmodified lines
214 // is scoped to it, so `auth.organization` IS this org when the caller is a215 // is scoped to it, so `auth.organization` IS this org when the caller is a
215 // member — and `null` when the URL names an org they can't access.216 // member — and `null` when the URL names an org they can't access.
216 const urlOrgSlug = (useParams({ strict: false }) as { orgSlug?: string }).orgSlug;217 const urlOrgSlug = (useParams({ strict: false }) as { orgSlug?: string }).orgSlug;
218 // The same slug derived from the PATHNAME instead of the route params: the
219 // params resolve asynchronously (a fresh load renders once with no orgSlug,
220 // then again with it), and anything keyed on them remounts on that flap.
221 // The pathname is synchronously correct on the very first render, and it is
222 // exactly what the request header derives from (getActiveOrgSlug), so the
223 // registry scope below can never disagree with the header scope.
224 const firstSegment = location.pathname.split("/")[1] ?? "";
225 const pathnameOrgSlug = isValidOrgSlug(firstSegment) ? firstSegment : null;
217 226
218 // The SSR gate already bounced fresh org-less document requests to227 // The SSR gate already bounced fresh org-less document requests to
219 // /create-org; this catches the MID-SESSION transitions (org deleted,228 // /create-org; this catches the MID-SESSION transitions (org deleted,
@@ -282,26 +291,42 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 62 unmodified lines
282 // /<orgB> while their cookie still points at orgA would briefly render orgA's291 // /<orgB> while their cookie still points at orgA would briefly render orgA's
283 // slug in the copyable URL before /account/me (URL-scoped) corrects it. The292 // slug in the copyable URL before /account/me (URL-scoped) corrects it. The
284 // URL slug is the actual request scope and is correct on the very first paint,293 // URL slug is the actual request scope and is correct on the very first paint,
285 // so sourcing it from there removes that flash. Falls back to the session slug294 // so sourcing it from there removes that flash. VALIDATED (pathnameOrgSlug,
286 // on a bare URL (which OrgSlugGate is about to canonicalize onto it anyway).295 // not the raw route param): the `{-$orgSlug}` param also captures reserved
287 const scopeSlug = urlOrgSlug ?? activeSlug;296 // console roots ("/integrations" → orgSlug "integrations"), which are not
297 // org scopes. Falls back to the auth org on a bare/reserved URL (which
298 // OrgSlugGate canonicalizes onto it below).
299 const scopeSlug = pathnameOrgSlug ?? activeSlug;
288 const billingHeaders = scopeSlug ? { [EXECUTOR_ORG_HEADER]: scopeSlug } : undefined;300 const billingHeaders = scopeSlug ? { [EXECUTOR_ORG_HEADER]: scopeSlug } : undefined;
289 301
290 return (302 return (
291 <AutumnProvider pathPrefix="/api/billing" headers={billingHeaders}>303 <AutumnProvider pathPrefix="/api/billing" headers={billingHeaders}>
292 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>304 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
293 <ExecutorProvider connection={connection} onHandledError={captureFrontendError}>305 {/* scopeKey ties the atom registry to the URL's org: cached query
306 results can never survive an org change, and the bare → slugged
307 canonicalization remounts the registry so anything fetched
308 header-less on first paint (rejected server-side) is refetched
309 with the org header. */}
310 <ExecutorProvider
311 connection={connection}
312 scopeKey={pathnameOrgSlug}
313 onHandledError={captureFrontendError}
314 >
294 <React.Suspense fallback={<BlankScreen />}>315 <React.Suspense fallback={<BlankScreen />}>
295 <ExecutorPluginsProvider plugins={clientPlugins}>316 <ExecutorPluginsProvider plugins={clientPlugins}>
296 <OrganizationProvider317 <OrganizationProvider
297 organizationId={auth.organization.id}318 organizationId={auth.organization.id}
298 organizationSlug={scopeSlug}319 organizationSlug={scopeSlug}
299 >320 >
300 {/* The org header scopes every request to the URL's org, so321 {/* Canonicalize onto the URL's org, not the auth org: on first
301 reaching here means the caller is a member of `activeSlug`322 paint `auth.organization` is the SSR hint (the COOKIE's
302 (a foreign slug already 404'd above). The gate only keeps323 org), and canonicalizing onto that would rewrite a
303 the URL canonical — bare → /<slug>. */}324 multi-org user's /<orgB> URL to /<orgA> during the hint
304 <OrgSlugGate activeSlug={activeSlug}>325 window. `scopeSlug` prefers the URL slug, so a slugged URL
326 is already canonical (a foreign slug 404'd above) and only
327 a bare URL gets rewritten — onto the auth org, the one
328 thing it can mean. */}
329 <OrgSlugGate activeSlug={scopeSlug}>
305 <Shell />330 <Shell />
306 <Toaster />331 <Toaster />
307 </OrgSlugGate>332 </OrgSlugGate>
e2e/cloud/connection-owner-isolation.test.ts+37−15
@@ -65,16 +65,17 @@ const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toSt · 64 unmodified lines
65// These mirror what the product web app does: cookie-authenticated calls whose65// These mirror what the product web app does: cookie-authenticated calls whose
66// responses re-seal the session when the active org changes.66// responses re-seal the session when the active org changes.
67 67
68const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
69
70const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>68const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
71 Effect.promise(async () => {69 Effect.promise(async () => {
72 const response = await fetch(new URL(path, target.baseUrl), {70 const response = await fetch(new URL(path, target.baseUrl), {
73 method: "POST",71 method: "POST",
74 headers: {72 headers: {
73 // The identity's own headers (session cookie + org selector) exactly
74 // as its API client would send them — org-scoped endpoints fail
75 // closed without the selector.
76 ...(identity.headers ?? {}),
75 "content-type": "application/json",77 "content-type": "application/json",
76 origin: new URL(target.baseUrl).origin,78 origin: new URL(target.baseUrl).origin,
77 cookie: cookieOf(identity),
78 },79 },
79 body: JSON.stringify(body),80 body: JSON.stringify(body),
80 });81 });
@@ -84,17 +85,34 @@ const postJson = (target: TargetShape, path: string, identity: Identity, body: u · 3 unmodified lines
84 return response;85 return response;
85 });86 });
86 87
87/** The identity re-bound to the refreshed session cookie a response set. */88/** The identity re-bound to the refreshed session cookie a response set,
88const withRefreshedSession = (identity: Identity, response: Response): Identity => {89 * scoped to `orgSelector` via the selector header (see switchOrg). */
90const withRefreshedSession = (
91 identity: Identity,
92 response: Response,
93 orgSelector: string,
94): Identity => {
89 const refreshed = (response.headers.getSetCookie?.() ?? [])95 const refreshed = (response.headers.getSetCookie?.() ?? [])
90 .find((header) => header.startsWith("wos-session="))96 .find((header) => header.startsWith("wos-session="))
91 ?.split(";")[0];97 ?.split(";")[0];
92 if (!refreshed) throw new Error("response did not refresh the session cookie");98 if (!refreshed) throw new Error("response did not refresh the session cookie");
93 return { ...identity, headers: { cookie: refreshed } };99 return {
100 ...identity,
101 headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector },
102 };
103};
104
105/** The org selector this identity's requests carry — the same header the web
106 * client derives from the console URL (identities minted with an org carry
107 * it; org-scoped reads fail closed without one). */
108const orgSelectorOf = (identity: Identity): string => {
109 const selector = identity.headers?.[ORG_SELECTOR_HEADER];
110 if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`);
111 return selector;
94};112};
95 113
96/** Invite `member` into `admin`'s org and accept — the real invite flow.114/** Invite `member` into `admin`'s org and accept — the real invite flow.
97 * Returns the member identity with its session re-bound to that org. */115 * Returns the member identity with its requests scoped to that org. */
98const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>116const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
99 Effect.gen(function* () {117 Effect.gen(function* () {
100 const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {118 const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
@@ -104,14 +122,15 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => · 3 unmodified lines
104 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {122 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
105 invitationId: invitation.id,123 invitationId: invitation.id,
106 });124 });
107 return withRefreshedSession(member, acceptResponse);125 return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin));
108 });126 });
109 127
110/** Create another org for this account; returns the identity bound to it. */128/** Create another org for this account; returns the identity bound to it. */
111const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) =>129const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) =>
112 Effect.gen(function* () {130 Effect.gen(function* () {
113 const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });131 const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });
114 return withRefreshedSession(identity, response);132 const created = (yield* Effect.promise(() => response.clone().json())) as { id: string };
133 return withRefreshedSession(identity, response, created.id);
115 });134 });
116 135
117// `/api/auth/switch-organization` (session-cookie-based org switching) was136// `/api/auth/switch-organization` (session-cookie-based org switching) was
@@ -119,10 +138,12 @@ const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) · 1 unmodified line
119// the session. A request picks its active org via the `x-executor-organization`138// the session. A request picks its active org via the `x-executor-organization`
120// header (apps/cloud/src/auth/organization.ts's `ORG_SELECTOR_HEADER`,139// header (apps/cloud/src/auth/organization.ts's `ORG_SELECTOR_HEADER`,
121// `EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"` in140// `EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"` in
122// packages/core/sdk/src/server-connection.ts), falling back to the session's141// packages/core/sdk/src/server-connection.ts). Org-scoped API reads FAIL
123// own org when absent. The header is a SELECTOR, not a trust boundary — the142// CLOSED without it — there is deliberately no fallback to the session's
124// server re-checks live membership — so attaching it directly to the identity143// cookie-pinned org (that fallback served wrong-tenant data to multi-org
125// here is exactly what the real web client does from the console URL's slug.144// users). The header is a SELECTOR, not a trust boundary — the server
145// re-checks live membership — so attaching it directly to the identity here
146// is exactly what the real web client does from the console URL's slug.
126const ORG_SELECTOR_HEADER = "x-executor-organization";147const ORG_SELECTOR_HEADER = "x-executor-organization";
127 148
128/** Switch this account's active org; returns the identity scoped to it via149/** Switch this account's active org; returns the identity scoped to it via
@@ -137,11 +158,12 @@ const switchOrg = ( · 8 unmodified lines
137 headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId },158 headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId },
138 });159 });
139 160
140/** The org this identity's session is currently bound to. */161/** The org this identity's requests are scoped to (selector header included,
162 * mirroring the web client). */
141const activeOrganizationId = (target: TargetShape, identity: Identity) =>163const activeOrganizationId = (target: TargetShape, identity: Identity) =>
142 Effect.promise(async () => {164 Effect.promise(async () => {
143 const response = await fetch(new URL("/api/auth/me", target.baseUrl), {165 const response = await fetch(new URL("/api/auth/me", target.baseUrl), {
144 headers: { cookie: cookieOf(identity) },166 headers: identity.headers ?? {},
145 });167 });
146 if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);168 if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
147 const body = (await response.json()) as { organization: { id: string } | null };169 const body = (await response.json()) as { organization: { id: string } | null };
e2e/cloud/spec-update-convergence.test.ts+20−6
@@ -106,16 +106,19 @@ const serveMutableSpec = (initial: string) => · 105 unmodified lines
106 );106 );
107 107
108// ── Session plumbing over the real auth endpoints (mirrors the web app) ──────108// ── Session plumbing over the real auth endpoints (mirrors the web app) ──────
109const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";109const ORG_SELECTOR_HEADER = "x-executor-organization";
110 110
111const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>111const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
112 Effect.promise(async () => {112 Effect.promise(async () => {
113 const response = await fetch(new URL(path, target.baseUrl), {113 const response = await fetch(new URL(path, target.baseUrl), {
114 method: "POST",114 method: "POST",
115 headers: {115 headers: {
116 // The identity's own headers (session cookie + org selector) exactly
117 // as its API client would send them — org-scoped endpoints fail
118 // closed without the selector.
119 ...(identity.headers ?? {}),
116 "content-type": "application/json",120 "content-type": "application/json",
117 origin: new URL(target.baseUrl).origin,121 origin: new URL(target.baseUrl).origin,
118 cookie: cookieOf(identity),
119 },122 },
120 body: JSON.stringify(body),123 body: JSON.stringify(body),
121 });124 });
@@ -125,17 +128,28 @@ const postJson = (target: TargetShape, path: string, identity: Identity, body: u · 3 unmodified lines
125 return response;128 return response;
126 });129 });
127 130
128const withRefreshedSession = (identity: Identity, response: Response): Identity => {131const withRefreshedSession = (
132 identity: Identity,
133 response: Response,
134 orgSelector: string,
135): Identity => {
129 const refreshed = (response.headers.getSetCookie?.() ?? [])136 const refreshed = (response.headers.getSetCookie?.() ?? [])
130 .find((header) => header.startsWith("wos-session="))137 .find((header) => header.startsWith("wos-session="))
131 ?.split(";")[0];138 ?.split(";")[0];
132 if (!refreshed) throw new Error("response did not refresh the session cookie");139 if (!refreshed) throw new Error("response did not refresh the session cookie");
133 return { ...identity, headers: { cookie: refreshed } };140 return {
141 ...identity,
142 headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector },
143 };
134};144};
135 145
136/** Invite `member` into `admin`'s org and accept — the real invite flow. */146/** Invite `member` into `admin`'s org and accept — the real invite flow.
147 * The member's requests inherit the admin's org selector (org-scoped reads
148 * fail closed without the header). */
137const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>149const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
138 Effect.gen(function* () {150 Effect.gen(function* () {
151 const adminSelector = admin.headers?.[ORG_SELECTOR_HEADER];
152 if (!adminSelector) throw new Error("admin identity carries no org selector header");
139 const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {153 const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
140 email: member.credentials?.email,154 email: member.credentials?.email,
141 });155 });
@@ -143,7 +157,7 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => · 1 unmodified line
143 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {157 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
144 invitationId: invitation.id,158 invitationId: invitation.id,
145 });159 });
146 return withRefreshedSession(member, acceptResponse);160 return withRefreshedSession(member, acceptResponse, adminSelector);
147 });161 });
148 162
149const apiKeyTemplate = [163const apiKeyTemplate = [
e2e/targets/cloud.ts+9−1
@@ -76,6 +76,7 @@ export const cloudTarget = (): Target => ({ · 75 unmodified lines
76 const label = `user-${randomUUID().slice(0, 8)}`;76 const label = `user-${randomUUID().slice(0, 8)}`;
77 const email = `${label}@e2e.test`;77 const email = `${label}@e2e.test`;
78 let session = await signIn(email);78 let session = await signIn(email);
79 let orgSlug: string | null = null;
79 if (org) {80 if (org) {
80 // The real create-organization flow; the refreshed sealed session in81 // The real create-organization flow; the refreshed sealed session in
81 // the response carries the new org.82 // the response carries the new org.
@@ -92,11 +93,18 @@ export const cloudTarget = (): Target => ({ · 10 unmodified lines
92 throw new Error(`cloud newIdentity: create-organization failed (${response.status})`);93 throw new Error(`cloud newIdentity: create-organization failed (${response.status})`);
93 }94 }
94 session = cookiePair(response, "wos-session") ?? session;95 session = cookiePair(response, "wos-session") ?? session;
96 orgSlug = ((await response.json()) as { slug?: string }).slug ?? null;
95 }97 }
96 const [name, value] = session.split(/=(.*)/s);98 const [name, value] = session.split(/=(.*)/s);
97 return {99 return {
98 label: email,100 label: email,
99 headers: { cookie: session },101 // The org selector header rides along exactly as the web client sends
102 // it from the console URL's slug: org-scoped API reads fail closed
103 // without it (no session-org fallback).
104 headers: {
105 cookie: session,
106 ...(orgSlug ? { "x-executor-organization": orgSlug } : {}),
107 },
100 cookies: [{ name: name!, value: value! }],108 cookies: [{ name: name!, value: value! }],
101 credentials: { email, password: "emulated" },109 credentials: { email, password: "emulated" },
102 };110 };
packages/react/src/api/provider.tsx+31−3
@@ -4,24 +4,52 @@ import * as React from "react"; · 3 unmodified lines
4import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting";4import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting";
5import {5import {
6 ExecutorServerConnectionProvider,6 ExecutorServerConnectionProvider,
7 setActiveOrgSlugOverride,
7 useExecutorServerConnection,8 useExecutorServerConnection,
8 type ExecutorServerConnectionInput,9 type ExecutorServerConnectionInput,
9} from "./server-connection";10} from "./server-connection";
10 11
11function ExecutorRegistryProvider(props: React.PropsWithChildren) {12function ExecutorRegistryProvider(props: React.PropsWithChildren<{ scopeKey?: string | null }>) {
12 const connection = useExecutorServerConnection();13 const connection = useExecutorServerConnection();
13 return <RegistryProvider key={connection.key}>{props.children}</RegistryProvider>;14 // The org scope MUST be part of the registry key on org-scoped hosts: query
15 // atoms key their cache entries on the request shape alone (the org selector
16 // header is injected in transformClient, invisible to the atom family key),
17 // so without it a cached result fetched under one org would serve under
18 // another for its TTL. Keying the registry tears the whole cache down
19 // whenever the scope changes — including bare-URL → slugged canonicalization,
20 // which discards any header-less (server-rejected) fetches from first paint.
21 //
22 // The scope also becomes the header authority (setActiveOrgSlugOverride) —
23 // synchronously, during render, BEFORE the keyed registry below mounts and
24 // its atoms fire their first fetches. window.history lags the router during
25 // client-side navigations, so deriving the header from window.location alone
26 // would scope those first fetches to the OLD URL. Browser only: the header
27 // injection never runs during SSR, and the worker's module state is shared
28 // across concurrent requests.
29 if (props.scopeKey !== undefined && globalThis.window) {
30 setActiveOrgSlugOverride(props.scopeKey);
31 }
32 const key = props.scopeKey ? `${connection.key}#${props.scopeKey}` : connection.key;
33 return <RegistryProvider key={key}>{props.children}</RegistryProvider>;
14}34}
15 35
16export const ExecutorProvider = (36export const ExecutorProvider = (
17 props: React.PropsWithChildren<{37 props: React.PropsWithChildren<{
18 connection?: ExecutorServerConnectionInput;38 connection?: ExecutorServerConnectionInput;
39 /**
40 * The org scope the atom registry is keyed to — pass the console URL's org
41 * slug on org-scoped hosts (cloud) so cached query results can never
42 * survive an org change. Hosts without org scoping leave it unset.
43 */
44 scopeKey?: string | null;
19 onHandledError?: FrontendErrorReporter;45 onHandledError?: FrontendErrorReporter;
20 }>,46 }>,
21) => (47) => (
22 <FrontendErrorReporterProvider reporter={props.onHandledError}>48 <FrontendErrorReporterProvider reporter={props.onHandledError}>
23 <ExecutorServerConnectionProvider connection={props.connection}>49 <ExecutorServerConnectionProvider connection={props.connection}>
24 <ExecutorRegistryProvider>{props.children}</ExecutorRegistryProvider>50 <ExecutorRegistryProvider scopeKey={props.scopeKey}>
51 {props.children}
52 </ExecutorRegistryProvider>
25 </ExecutorServerConnectionProvider>53 </ExecutorServerConnectionProvider>
26 </FrontendErrorReporterProvider>54 </FrontendErrorReporterProvider>
27);55);
packages/react/src/api/server-connection.tsx+14−0
@@ -91,7 +91,21 @@ let activeConnection = resolveInitialExecutorServerConnection(); · 90 unmodified lines
91// cloudflare) never produce one either.91// cloudflare) never produce one either.
92export const EXECUTOR_ORG_HEADER = EXECUTOR_ORG_SELECTOR_HEADER;92export const EXECUTOR_ORG_HEADER = EXECUTOR_ORG_SELECTOR_HEADER;
93 93
94// The router-authoritative org scope, set by the host (ExecutorProvider's
95// scopeKey) during render. Preferred over the window.location fallback
96// because the router's pathname can be a tick AHEAD of history during a
97// client-side navigation — a fetch fired in that window would otherwise
98// derive the OLD URL's scope (e.g. none, on the bare → slugged
99// canonicalization) while the atom registry is already keyed to the new one.
100// One value per tab (module state), so tabs stay independent.
101let activeOrgSlugOverride: string | null = null;
102
103export const setActiveOrgSlugOverride = (slug: string | null): void => {
104 activeOrgSlugOverride = slug;
105};
106
94export const getActiveOrgSlug = (): string | null => {107export const getActiveOrgSlug = (): string | null => {
108 if (activeOrgSlugOverride) return activeOrgSlugOverride;
95 const pathname = globalThis.window?.location?.pathname;109 const pathname = globalThis.window?.location?.pathname;
96 if (!pathname) return null;110 if (!pathname) return null;
97 const first = pathname.split("/")[1];111 const first = pathname.split("/")[1];