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
9191 ? Effect.succeed(caller.session)
9292 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
9393
94− // 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. Membership
96− // is re-checked live, so the header is a selector, not a trust boundary —
94+ // The org scope for an org-scoped request: the console URL's org, sent in
95+ // the selector header. FAIL CLOSED when it's missing — the session's own
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 —
97100 // and two browser tabs on different orgs each send their own header, so
98101 // they stay independent (see organization.ts). Yields the session +
99102 // resolved org, or AccountNoOrganization.
100103 const requireOrganization = (headers: AccountHeaders) =>
101104 Effect.gen(function* () {
102105 const session = yield* requireSession();
103− const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
106+ const selector = headers[ORG_SELECTOR_HEADER];
104107 if (!selector) {
105108 return yield* new AccountNoOrganization();
106109 }
@@ -180,9 +183,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines
180183 me: (headers) =>
181184 Effect.gen(function* () {
182185 const session = yield* requireSession();
183− // Same selector precedence as requireOrganization: the URL's org
184− // (header) drives /account/me so the shell reflects the org the tab
185− // is viewing, not a session-global active org.
186+ // The ONE sanctioned fallback to the session org. /account/me is
187+ // identity, not tenant data: on a bare URL (no org slug yet, so no
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.
186195 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
187196 const org = selector
188197 ? 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
77 import { WorkOSClient, type WorkOSClientService } from "./workos";
88
99 // 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 —
11−// with the session org as the fallback for non-console callers, and live
12−// membership re-checked either way. This is what makes two browser tabs on
10+// `x-executor-organization` selector header) — NEVER the session's stored org.
11+// The sealed cookie's org is a browser-global pinned to whichever org WorkOS
12+// 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
1315 // different orgs independent.
1416
1517 const createdAt = new Date("2026-01-01T00:00:00.000Z");
@@ -95,10 +97,15 @@ const run = (headers: Record<string, string>) => · 79 unmodified lines
9597 );
9698
9799 describe("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", () =>
99101 Effect.gen(function* () {
100− const principal = yield* run({ cookie: "wos-session=x" });
101− expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);
102+ // No fallback to the session org: the cookie's org is browser-global
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+ });
102109 }),
103110 );
104111
apps/cloud/src/auth/workos-auth-provider.ts+20−6
@@ -16,6 +16,7 @@ · 15 unmodified lines
1616 // - invalid api key -> Unauthorized 401 invalid_api_key
1717 // - api-key org not authorized -> NoOrganization 403 no_organization
1818 // - no/invalid session -> NoOrganization 403 no_organization
19+// - session without org header -> NoOrganization 403 no_organization (fail closed)
1920 // - session org not authorized -> NoOrganization 403 no_organization
2021 // - no auth header -> falls through to the sealed-session path
2122 // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are
@@ -86,6 +87,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 64 unmodified lines
8687 code: "no_organization",
8788 message: "No organization in session",
8889 };
90+const NO_ORGANIZATION_SELECTOR = {
91+ code: "no_organization",
92+ message: "No organization selector in request",
93+};
8994 const INVALID_ACCESS_TOKEN = {
9095 code: "invalid_access_token",
9196 message: "Invalid or expired access token",
@@ -199,13 +204,22 @@ export const resolveSessionPrincipal = (request: Request) => · 107 unmodified lines
199204 if (!session) {
200205 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
201206 }
202− // The console URL's org is the scope authority (sent as a header); the
203− // session's own org is the fallback for non-console callers. Membership is
204− // re-checked live either way — the header is a selector, not a trust
205− // boundary (see organization.ts).
206− const selector = orgSelectorFromRequest(request) ?? session.organizationId;
207+ // The console URL's org is the scope authority, sent as a header on every
208+ // request. FAIL CLOSED when it's missing: the sealed cookie's org is
209+ // browser-global and pinned to whichever org WorkOS last touched, so
210+ // falling back to it silently serves ANOTHER org's data to a multi-org
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);
207221 if (!selector) {
208− return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
222+ return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR);
209223 }
210224 const org = yield* authorizeOrganizationSelector(session.userId, selector);
211225 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
6161 ),
6262 });
6363
64−const run = (headers: Record<string, string>, organizationId: string | null = SESSION_ORG) =>
64+const run = (headers: Record<string, string>) =>
6565 resolveBillingOrganization(
6666 new Request("https://executor.test/api/billing/customer", { headers }),
67− { userId: MEMBER, organizationId },
67+ { userId: MEMBER },
6868 ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers)));
6969
7070 describe("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", () =>
7272 Effect.gen(function* () {
73− const org = yield* run({});
74− expect(org.id).toBe(SESSION_ORG);
73+ // No fallback to the session org: the cookie's org is browser-global
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 });
7577 }),
7678 );
7779
78− it.effect("scopes billing to the URL org selector over the session org", () =>
80+ it.effect("scopes billing to the URL org selector", () =>
7981 Effect.gen(function* () {
8082 const org = yield* run({ "x-executor-organization": URL_SLUG });
8183 expect(org.id).toBe(URL_ORG);
@@ -88,11 +90,4 @@ describe("billing route org selector", () => { · 6 unmodified lines
8890 expect(error).toMatchObject({ _tag: "HttpResponseError", status: 403 });
8991 }),
9092 );
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− );
9893 });
apps/cloud/src/extensions/billing/route.ts+5−2
@@ -13,12 +13,15 @@ import { · 12 unmodified lines
1313
1414 type BillingSession = {
1515 readonly userId: string;
16− readonly organizationId?: string | null;
1716 };
1817
1918 export const resolveBillingOrganization = (request: Request, session: BillingSession) =>
2019 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);
2225 if (!selector) {
2326 return yield* new HttpResponseError({
2427 status: 401,
apps/cloud/src/org/auth-middleware.ts+5−1
@@ -40,7 +40,11 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()( · 39 unmodified lines
4040 .pipe(Effect.orElseSucceed(() => null));
4141 if (!result) return unauthorized();
4242
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];
4448 if (!selector) return noOrganization();
4549
4650 const org = yield* authorizeOrganizationSelector(result.userId, selector).pipe(
apps/cloud/src/routes/__root.tsx+34−9
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 useParams,
1111 } from "@tanstack/react-router";
1212 import { AutumnProvider } from "autumn-js/react";
13+import { isValidOrgSlug } from "@executor-js/api";
1314 import posthog from "posthog-js";
1415 import { PostHogProvider } from "posthog-js/react";
1516 import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
@@ -214,6 +215,14 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 198 unmodified lines
214215 // is scoped to it, so `auth.organization` IS this org when the caller is a
215216 // member — and `null` when the URL names an org they can't access.
216217 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;
217226
218227 // The SSR gate already bounced fresh org-less document requests to
219228 // /create-org; this catches the MID-SESSION transitions (org deleted,
@@ -282,26 +291,42 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 62 unmodified lines
282291 // /<orgB> while their cookie still points at orgA would briefly render orgA's
283292 // slug in the copyable URL before /account/me (URL-scoped) corrects it. The
284293 // 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 slug
286− // on a bare URL (which OrgSlugGate is about to canonicalize onto it anyway).
287− const scopeSlug = urlOrgSlug ?? activeSlug;
294+ // so sourcing it from there removes that flash. VALIDATED (pathnameOrgSlug,
295+ // not the raw route param): the `{-$orgSlug}` param also captures reserved
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;
288300 const billingHeaders = scopeSlug ? { [EXECUTOR_ORG_HEADER]: scopeSlug } : undefined;
289301
290302 return (
291303 <AutumnProvider pathPrefix="/api/billing" headers={billingHeaders}>
292304 <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+ >
294315 <React.Suspense fallback={<BlankScreen />}>
295316 <ExecutorPluginsProvider plugins={clientPlugins}>
296317 <OrganizationProvider
297318 organizationId={auth.organization.id}
298319 organizationSlug={scopeSlug}
299320 >
300− {/* The org header scopes every request to the URL's org, so
301− reaching here means the caller is a member of `activeSlug`
302− (a foreign slug already 404'd above). The gate only keeps
303− the URL canonical — bare → /<slug>. */}
304− <OrgSlugGate activeSlug={activeSlug}>
321+ {/* Canonicalize onto the URL's org, not the auth org: on first
322+ paint `auth.organization` is the SSR hint (the COOKIE's
323+ org), and canonicalizing onto that would rewrite a
324+ multi-org user's /<orgB> URL to /<orgA> during the hint
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}>
305330 <Shell />
306331 <Toaster />
307332 </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
6565 // These mirror what the product web app does: cookie-authenticated calls whose
6666 // responses re-seal the session when the active org changes.
6767
68−const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
69−
7068 const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
7169 Effect.promise(async () => {
7270 const response = await fetch(new URL(path, target.baseUrl), {
7371 method: "POST",
7472 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 ?? {}),
7577 "content-type": "application/json",
7678 origin: new URL(target.baseUrl).origin,
77− cookie: cookieOf(identity),
7879 },
7980 body: JSON.stringify(body),
8081 });
@@ -84,17 +85,34 @@ const postJson = (target: TargetShape, path: string, identity: Identity, body: u · 3 unmodified lines
8485 return response;
8586 });
8687
87−/** The identity re-bound to the refreshed session cookie a response set. */
88−const withRefreshedSession = (identity: Identity, response: Response): Identity => {
88+/** The identity re-bound to the refreshed session cookie a response set,
89+ * scoped to `orgSelector` via the selector header (see switchOrg). */
90+const withRefreshedSession = (
91+ identity: Identity,
92+ response: Response,
93+ orgSelector: string,
94+): Identity => {
8995 const refreshed = (response.headers.getSetCookie?.() ?? [])
9096 .find((header) => header.startsWith("wos-session="))
9197 ?.split(";")[0];
9298 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). */
108+const 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;
94112 };
95113
96114 /** 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. */
98116 const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
99117 Effect.gen(function* () {
100118 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
104122 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
105123 invitationId: invitation.id,
106124 });
107− return withRefreshedSession(member, acceptResponse);
125+ return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin));
108126 });
109127
110128 /** Create another org for this account; returns the identity bound to it. */
111129 const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) =>
112130 Effect.gen(function* () {
113131 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);
115134 });
116135
117136 // `/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
119138 // the session. A request picks its active org via the `x-executor-organization`
120139 // header (apps/cloud/src/auth/organization.ts's `ORG_SELECTOR_HEADER`,
121140 // `EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"` in
122−// packages/core/sdk/src/server-connection.ts), falling back to the session's
123−// own org when absent. The header is a SELECTOR, not a trust boundary — the
124−// server re-checks live membership — so attaching it directly to the identity
125−// here is exactly what the real web client does from the console URL's slug.
141+// packages/core/sdk/src/server-connection.ts). Org-scoped API reads FAIL
142+// CLOSED without it — there is deliberately no fallback to the session's
143+// cookie-pinned org (that fallback served wrong-tenant data to multi-org
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.
126147 const ORG_SELECTOR_HEADER = "x-executor-organization";
127148
128149 /** Switch this account's active org; returns the identity scoped to it via
@@ -137,11 +158,12 @@ const switchOrg = ( · 8 unmodified lines
137158 headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId },
138159 });
139160
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). */
141163 const activeOrganizationId = (target: TargetShape, identity: Identity) =>
142164 Effect.promise(async () => {
143165 const response = await fetch(new URL("/api/auth/me", target.baseUrl), {
144− headers: { cookie: cookieOf(identity) },
166+ headers: identity.headers ?? {},
145167 });
146168 if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
147169 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
106106 );
107107
108108 // ── Session plumbing over the real auth endpoints (mirrors the web app) ──────
109−const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
109+const ORG_SELECTOR_HEADER = "x-executor-organization";
110110
111111 const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
112112 Effect.promise(async () => {
113113 const response = await fetch(new URL(path, target.baseUrl), {
114114 method: "POST",
115115 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 ?? {}),
116120 "content-type": "application/json",
117121 origin: new URL(target.baseUrl).origin,
118− cookie: cookieOf(identity),
119122 },
120123 body: JSON.stringify(body),
121124 });
@@ -125,17 +128,28 @@ const postJson = (target: TargetShape, path: string, identity: Identity, body: u · 3 unmodified lines
125128 return response;
126129 });
127130
128−const withRefreshedSession = (identity: Identity, response: Response): Identity => {
131+const withRefreshedSession = (
132+ identity: Identity,
133+ response: Response,
134+ orgSelector: string,
135+): Identity => {
129136 const refreshed = (response.headers.getSetCookie?.() ?? [])
130137 .find((header) => header.startsWith("wos-session="))
131138 ?.split(";")[0];
132139 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+ };
134144 };
135145
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). */
137149 const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
138150 Effect.gen(function* () {
151+ const adminSelector = admin.headers?.[ORG_SELECTOR_HEADER];
152+ if (!adminSelector) throw new Error("admin identity carries no org selector header");
139153 const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
140154 email: member.credentials?.email,
141155 });
@@ -143,7 +157,7 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => · 1 unmodified line
143157 const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
144158 invitationId: invitation.id,
145159 });
146− return withRefreshedSession(member, acceptResponse);
160+ return withRefreshedSession(member, acceptResponse, adminSelector);
147161 });
148162
149163 const apiKeyTemplate = [
e2e/targets/cloud.ts+9−1
@@ -76,6 +76,7 @@ export const cloudTarget = (): Target => ({ · 75 unmodified lines
7676 const label = `user-${randomUUID().slice(0, 8)}`;
7777 const email = `${label}@e2e.test`;
7878 let session = await signIn(email);
79+ let orgSlug: string | null = null;
7980 if (org) {
8081 // The real create-organization flow; the refreshed sealed session in
8182 // the response carries the new org.
@@ -92,11 +93,18 @@ export const cloudTarget = (): Target => ({ · 10 unmodified lines
9293 throw new Error(`cloud newIdentity: create-organization failed (${response.status})`);
9394 }
9495 session = cookiePair(response, "wos-session") ?? session;
96+ orgSlug = ((await response.json()) as { slug?: string }).slug ?? null;
9597 }
9698 const [name, value] = session.split(/=(.*)/s);
9799 return {
98100 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+ },
100108 cookies: [{ name: name!, value: value! }],
101109 credentials: { email, password: "emulated" },
102110 };
packages/react/src/api/provider.tsx+31−3
@@ -4,24 +4,52 @@ import * as React from "react"; · 3 unmodified lines
44 import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting";
55 import {
66 ExecutorServerConnectionProvider,
7+ setActiveOrgSlugOverride,
78 useExecutorServerConnection,
89 type ExecutorServerConnectionInput,
910 } from "./server-connection";
1011
11−function ExecutorRegistryProvider(props: React.PropsWithChildren) {
12+function ExecutorRegistryProvider(props: React.PropsWithChildren<{ scopeKey?: string | null }>) {
1213 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>;
1434 }
1535
1636 export const ExecutorProvider = (
1737 props: React.PropsWithChildren<{
1838 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;
1945 onHandledError?: FrontendErrorReporter;
2046 }>,
2147 ) => (
2248 <FrontendErrorReporterProvider reporter={props.onHandledError}>
2349 <ExecutorServerConnectionProvider connection={props.connection}>
24− <ExecutorRegistryProvider>{props.children}</ExecutorRegistryProvider>
50+ <ExecutorRegistryProvider scopeKey={props.scopeKey}>
51+ {props.children}
52+ </ExecutorRegistryProvider>
2553 </ExecutorServerConnectionProvider>
2654 </FrontendErrorReporterProvider>
2755 );
packages/react/src/api/server-connection.tsx+14−0
@@ -91,7 +91,21 @@ let activeConnection = resolveInitialExecutorServerConnection(); · 90 unmodified lines
9191 // cloudflare) never produce one either.
9292 export const EXECUTOR_ORG_HEADER = EXECUTOR_ORG_SELECTOR_HEADER;
9393
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.
101+let activeOrgSlugOverride: string | null = null;
102+
103+export const setActiveOrgSlugOverride = (slug: string | null): void => {
104+ activeOrgSlugOverride = slug;
105+};
106+
94107 export const getActiveOrgSlug = (): string | null => {
108+ if (activeOrgSlugOverride) return activeOrgSlugOverride;
95109 const pathname = globalThis.window?.location?.pathname;
96110 if (!pathname) return null;
97111 const first = pathname.split("/")[1];