Unified · Split
| @@ -91,16 +91,19 @@ export const workosAccountProvider: Layer.Layer< · 90 unmodified lines | ||
| 91 | 91 | ? Effect.succeed(caller.session) |
| 92 | 92 | : Effect.fail<AccountUnauthorized>(new AccountUnauthorized()); |
| 93 | 93 | |
| 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 — | |
| 97 | 100 | // and two browser tabs on different orgs each send their own header, so |
| 98 | 101 | // they stay independent (see organization.ts). Yields the session + |
| 99 | 102 | // resolved org, or AccountNoOrganization. |
| 100 | 103 | const requireOrganization = (headers: AccountHeaders) => |
| 101 | 104 | Effect.gen(function* () { |
| 102 | 105 | const session = yield* requireSession(); |
| 103 | − const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; | |
| 106 | + const selector = headers[ORG_SELECTOR_HEADER]; | |
| 104 | 107 | if (!selector) { |
| 105 | 108 | return yield* new AccountNoOrganization(); |
| 106 | 109 | } |
| @@ -180,9 +183,15 @@ export const workosAccountProvider: Layer.Layer< · 73 unmodified lines | ||
| 180 | 183 | me: (headers) => |
| 181 | 184 | Effect.gen(function* () { |
| 182 | 185 | 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. | |
| 186 | 195 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; |
| 187 | 196 | const org = selector |
| 188 | 197 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( |
| @@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; · 6 unmodified lines | ||
| 7 | 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| 8 | 8 | |
| 9 | 9 | // 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 | |
| 13 | 15 | // different orgs independent. |
| 14 | 16 | |
| 15 | 17 | const 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 | |
| 97 | 99 | 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", () => | |
| 99 | 101 | 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 | + }); | |
| 102 | 109 | }), |
| 103 | 110 | ); |
| 104 | 111 | |
| @@ -16,6 +16,7 @@ · 15 unmodified lines | ||
| 16 | 16 | // - invalid api key -> Unauthorized 401 invalid_api_key |
| 17 | 17 | // - api-key org not authorized -> NoOrganization 403 no_organization |
| 18 | 18 | // - no/invalid session -> NoOrganization 403 no_organization |
| 19 | +// - session without org header -> NoOrganization 403 no_organization (fail closed) | |
| 19 | 20 | // - session org not authorized -> NoOrganization 403 no_organization |
| 20 | 21 | // - no auth header -> falls through to the sealed-session path |
| 21 | 22 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are |
| @@ -86,6 +87,10 @@ const NO_ORGANIZATION_IN_SESSION = { · 64 unmodified lines | ||
| 86 | 87 | code: "no_organization", |
| 87 | 88 | message: "No organization in session", |
| 88 | 89 | }; |
| 90 | +const NO_ORGANIZATION_SELECTOR = { | |
| 91 | + code: "no_organization", | |
| 92 | + message: "No organization selector in request", | |
| 93 | +}; | |
| 89 | 94 | const INVALID_ACCESS_TOKEN = { |
| 90 | 95 | code: "invalid_access_token", |
| 91 | 96 | message: "Invalid or expired access token", |
| @@ -199,13 +204,22 @@ export const resolveSessionPrincipal = (request: Request) => · 107 unmodified lines | ||
| 199 | 204 | if (!session) { |
| 200 | 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); 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); | |
| 207 | 221 | if (!selector) { |
| 208 | − return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | |
| 222 | + return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR); | |
| 209 | 223 | } |
| 210 | 224 | const org = yield* authorizeOrganizationSelector(session.userId, selector); |
| 211 | 225 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| @@ -61,21 +61,23 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 60 unmodified lines | ||
| 61 | 61 | ), |
| 62 | 62 | }); |
| 63 | 63 | |
| 64 | −const run = (headers: Record<string, string>, organizationId: string | null = SESSION_ORG) => | |
| 64 | +const run = (headers: Record<string, string>) => | |
| 65 | 65 | resolveBillingOrganization( |
| 66 | 66 | new Request("https://executor.test/api/billing/customer", { headers }), |
| 67 | − { userId: MEMBER, organizationId }, | |
| 67 | + { userId: MEMBER }, | |
| 68 | 68 | ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers))); |
| 69 | 69 | |
| 70 | 70 | 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", () => | |
| 72 | 72 | 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 }); | |
| 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 | 81 | Effect.gen(function* () { |
| 80 | 82 | const org = yield* run({ "x-executor-organization": URL_SLUG }); |
| 81 | 83 | expect(org.id).toBe(URL_ORG); |
| @@ -88,11 +90,4 @@ describe("billing route org selector", () => { · 6 unmodified lines | ||
| 88 | 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 | }); |
| @@ -13,12 +13,15 @@ import { · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | type BillingSession = { |
| 15 | 15 | readonly userId: string; |
| 16 | − readonly organizationId?: string | null; | |
| 17 | 16 | }; |
| 18 | 17 | |
| 19 | 18 | export const resolveBillingOrganization = (request: Request, session: BillingSession) => |
| 20 | 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 | 25 | if (!selector) { |
| 23 | 26 | return yield* new HttpResponseError({ |
| 24 | 27 | status: 401, |
| @@ -40,7 +40,11 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()( · 39 unmodified lines | ||
| 40 | 40 | .pipe(Effect.orElseSucceed(() => null)); |
| 41 | 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 | 48 | if (!selector) return noOrganization(); |
| 45 | 49 | |
| 46 | 50 | const org = yield* authorizeOrganizationSelector(result.userId, selector).pipe( |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | useParams, |
| 11 | 11 | } from "@tanstack/react-router"; |
| 12 | 12 | import { AutumnProvider } from "autumn-js/react"; |
| 13 | +import { isValidOrgSlug } from "@executor-js/api"; | |
| 13 | 14 | import posthog from "posthog-js"; |
| 14 | 15 | import { PostHogProvider } from "posthog-js/react"; |
| 15 | 16 | import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; |
| @@ -214,6 +215,14 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 198 unmodified lines | ||
| 214 | 215 | // is scoped to it, so `auth.organization` IS this org when the caller is a |
| 215 | 216 | // member — and `null` when the URL names an org they can't access. |
| 216 | 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 | 227 | // The SSR gate already bounced fresh org-less document requests to |
| 219 | 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 | 291 | // /<orgB> while their cookie still points at orgA would briefly render orgA's |
| 283 | 292 | // slug in the copyable URL before /account/me (URL-scoped) corrects it. The |
| 284 | 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 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; | |
| 288 | 300 | const billingHeaders = scopeSlug ? { [EXECUTOR_ORG_HEADER]: scopeSlug } : undefined; |
| 289 | 301 | |
| 290 | 302 | return ( |
| 291 | 303 | <AutumnProvider pathPrefix="/api/billing" headers={billingHeaders}> |
| 292 | 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 | 315 | <React.Suspense fallback={<BlankScreen />}> |
| 295 | 316 | <ExecutorPluginsProvider plugins={clientPlugins}> |
| 296 | 317 | <OrganizationProvider |
| 297 | 318 | organizationId={auth.organization.id} |
| 298 | 319 | organizationSlug={scopeSlug} |
| 299 | 320 | > |
| 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}> | |
| 305 | 330 | <Shell /> |
| 306 | 331 | <Toaster /> |
| 307 | 332 | </OrgSlugGate> |
| @@ -65,16 +65,17 @@ const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toSt · 64 unmodified lines | ||
| 65 | 65 | // These mirror what the product web app does: cookie-authenticated calls whose |
| 66 | 66 | // responses re-seal the session when the active org changes. |
| 67 | 67 | |
| 68 | −const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; | |
| 69 | − | |
| 70 | 68 | const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) => |
| 71 | 69 | Effect.promise(async () => { |
| 72 | 70 | const response = await fetch(new URL(path, target.baseUrl), { |
| 73 | 71 | method: "POST", |
| 74 | 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 | 77 | "content-type": "application/json", |
| 76 | 78 | origin: new URL(target.baseUrl).origin, |
| 77 | − cookie: cookieOf(identity), | |
| 78 | 79 | }, |
| 79 | 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 | 85 | return response; |
| 85 | 86 | }); |
| 86 | 87 | |
| 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 => { | |
| 89 | 95 | const refreshed = (response.headers.getSetCookie?.() ?? []) |
| 90 | 96 | .find((header) => header.startsWith("wos-session=")) |
| 91 | 97 | ?.split(";")[0]; |
| 92 | 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). */ | |
| 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; | |
| 94 | 112 | }; |
| 95 | 113 | |
| 96 | 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. */ | |
| 98 | 116 | const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => |
| 99 | 117 | Effect.gen(function* () { |
| 100 | 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 | 122 | const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { |
| 105 | 123 | invitationId: invitation.id, |
| 106 | 124 | }); |
| 107 | − return withRefreshedSession(member, acceptResponse); | |
| 125 | + return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin)); | |
| 108 | 126 | }); |
| 109 | 127 | |
| 110 | 128 | /** Create another org for this account; returns the identity bound to it. */ |
| 111 | 129 | const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) => |
| 112 | 130 | Effect.gen(function* () { |
| 113 | 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 | 136 | // `/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 | 138 | // the session. A request picks its active org via the `x-executor-organization` |
| 120 | 139 | // header (apps/cloud/src/auth/organization.ts's `ORG_SELECTOR_HEADER`, |
| 121 | 140 | // `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. | |
| 126 | 147 | const ORG_SELECTOR_HEADER = "x-executor-organization"; |
| 127 | 148 | |
| 128 | 149 | /** Switch this account's active org; returns the identity scoped to it via |
| @@ -137,11 +158,12 @@ const switchOrg = ( · 8 unmodified lines | ||
| 137 | 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). */ | |
| 141 | 163 | const activeOrganizationId = (target: TargetShape, identity: Identity) => |
| 142 | 164 | Effect.promise(async () => { |
| 143 | 165 | const response = await fetch(new URL("/api/auth/me", target.baseUrl), { |
| 144 | − headers: { cookie: cookieOf(identity) }, | |
| 166 | + headers: identity.headers ?? {}, | |
| 145 | 167 | }); |
| 146 | 168 | if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`); |
| 147 | 169 | const body = (await response.json()) as { organization: { id: string } | null }; |
| @@ -106,16 +106,19 @@ const serveMutableSpec = (initial: string) => · 105 unmodified lines | ||
| 106 | 106 | ); |
| 107 | 107 | |
| 108 | 108 | // ── 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"; | |
| 110 | 110 | |
| 111 | 111 | const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) => |
| 112 | 112 | Effect.promise(async () => { |
| 113 | 113 | const response = await fetch(new URL(path, target.baseUrl), { |
| 114 | 114 | method: "POST", |
| 115 | 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 | 120 | "content-type": "application/json", |
| 117 | 121 | origin: new URL(target.baseUrl).origin, |
| 118 | − cookie: cookieOf(identity), | |
| 119 | 122 | }, |
| 120 | 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 | 128 | return response; |
| 126 | 129 | }); |
| 127 | 130 | |
| 128 | −const withRefreshedSession = (identity: Identity, response: Response): Identity => { | |
| 131 | +const withRefreshedSession = ( | |
| 132 | + identity: Identity, | |
| 133 | + response: Response, | |
| 134 | + orgSelector: string, | |
| 135 | +): Identity => { | |
| 129 | 136 | const refreshed = (response.headers.getSetCookie?.() ?? []) |
| 130 | 137 | .find((header) => header.startsWith("wos-session=")) |
| 131 | 138 | ?.split(";")[0]; |
| 132 | 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). */ | |
| 137 | 149 | const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => |
| 138 | 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 | 153 | const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { |
| 140 | 154 | email: member.credentials?.email, |
| 141 | 155 | }); |
| @@ -143,7 +157,7 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => · 1 unmodified line | ||
| 143 | 157 | const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { |
| 144 | 158 | invitationId: invitation.id, |
| 145 | 159 | }); |
| 146 | − return withRefreshedSession(member, acceptResponse); | |
| 160 | + return withRefreshedSession(member, acceptResponse, adminSelector); | |
| 147 | 161 | }); |
| 148 | 162 | |
| 149 | 163 | const apiKeyTemplate = [ |
| @@ -76,6 +76,7 @@ export const cloudTarget = (): Target => ({ · 75 unmodified lines | ||
| 76 | 76 | const label = `user-${randomUUID().slice(0, 8)}`; |
| 77 | 77 | const email = `${label}@e2e.test`; |
| 78 | 78 | let session = await signIn(email); |
| 79 | + let orgSlug: string | null = null; | |
| 79 | 80 | if (org) { |
| 80 | 81 | // The real create-organization flow; the refreshed sealed session in |
| 81 | 82 | // the response carries the new org. |
| @@ -92,11 +93,18 @@ export const cloudTarget = (): Target => ({ · 10 unmodified lines | ||
| 92 | 93 | throw new Error(`cloud newIdentity: create-organization failed (${response.status})`); |
| 93 | 94 | } |
| 94 | 95 | session = cookiePair(response, "wos-session") ?? session; |
| 96 | + orgSlug = ((await response.json()) as { slug?: string }).slug ?? null; | |
| 95 | 97 | } |
| 96 | 98 | const [name, value] = session.split(/=(.*)/s); |
| 97 | 99 | return { |
| 98 | 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 | 108 | cookies: [{ name: name!, value: value! }], |
| 101 | 109 | credentials: { email, password: "emulated" }, |
| 102 | 110 | }; |
| @@ -4,24 +4,52 @@ import * as React from "react"; · 3 unmodified lines | ||
| 4 | 4 | import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting"; |
| 5 | 5 | import { |
| 6 | 6 | ExecutorServerConnectionProvider, |
| 7 | + setActiveOrgSlugOverride, | |
| 7 | 8 | useExecutorServerConnection, |
| 8 | 9 | type ExecutorServerConnectionInput, |
| 9 | 10 | } from "./server-connection"; |
| 10 | 11 | |
| 11 | −function ExecutorRegistryProvider(props: React.PropsWithChildren) { | |
| 12 | +function ExecutorRegistryProvider(props: React.PropsWithChildren<{ scopeKey?: string | null }>) { | |
| 12 | 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 | |
| 16 | 36 | export const ExecutorProvider = ( |
| 17 | 37 | props: React.PropsWithChildren<{ |
| 18 | 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 | 45 | onHandledError?: FrontendErrorReporter; |
| 20 | 46 | }>, |
| 21 | 47 | ) => ( |
| 22 | 48 | <FrontendErrorReporterProvider reporter={props.onHandledError}> |
| 23 | 49 | <ExecutorServerConnectionProvider connection={props.connection}> |
| 24 | − <ExecutorRegistryProvider>{props.children}</ExecutorRegistryProvider> | |
| 50 | + <ExecutorRegistryProvider scopeKey={props.scopeKey}> | |
| 51 | + {props.children} | |
| 52 | + </ExecutorRegistryProvider> | |
| 25 | 53 | </ExecutorServerConnectionProvider> |
| 26 | 54 | </FrontendErrorReporterProvider> |
| 27 | 55 | ); |
| @@ -91,7 +91,21 @@ let activeConnection = resolveInitialExecutorServerConnection(); · 90 unmodified lines | ||
| 91 | 91 | // cloudflare) never produce one either. |
| 92 | 92 | export 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. | |
| 101 | +let activeOrgSlugOverride: string | null = null; | |
| 102 | + | |
| 103 | +export const setActiveOrgSlugOverride = (slug: string | null): void => { | |
| 104 | + activeOrgSlugOverride = slug; | |
| 105 | +}; | |
| 106 | + | |
| 94 | 107 | export const getActiveOrgSlug = (): string | null => { |
| 108 | + if (activeOrgSlugOverride) return activeOrgSlugOverride; | |
| 95 | 109 | const pathname = globalThis.window?.location?.pathname; |
| 96 | 110 | if (!pathname) return null; |
| 97 | 111 | const first = pathname.split("/")[1]; |