Unified · Split
| @@ -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 in | 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 | 95 | // 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, so | 100 | // 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 org | 186 | // The ONE sanctioned fallback to the session org. /account/me is |
| 184 | // (header) drives /account/me so the shell reflects the org the tab | 187 | // 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 = selector | 196 | const org = selector |
| 188 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( | 197 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( |
| @@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; · 6 unmodified lines | |||
| 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| 8 | 8 | ||
| 9 | // The org a console request resolves to is the URL's org (sent in the | 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 — | 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 live | 11 | // 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 on | 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 | // different orgs independent. | 15 | // different orgs independent. |
| 14 | 16 | ||
| 15 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); | 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 | describe("resolveSessionPrincipal · URL org selector", () => { | 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 | 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 | ||
| @@ -16,6 +16,7 @@ · 15 unmodified lines | |||
| 16 | // - invalid api key -> Unauthorized 401 invalid_api_key | 16 | // - invalid api key -> Unauthorized 401 invalid_api_key |
| 17 | // - api-key org not authorized -> NoOrganization 403 no_organization | 17 | // - api-key org not authorized -> NoOrganization 403 no_organization |
| 18 | // - no/invalid session -> NoOrganization 403 no_organization | 18 | // - 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_organization | 20 | // - session org not authorized -> NoOrganization 403 no_organization |
| 20 | // - no auth header -> falls through to the sealed-session path | 21 | // - no auth header -> falls through to the sealed-session path |
| 21 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are | 22 | // 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 | }; |
| 90 | const NO_ORGANIZATION_SELECTOR = { | ||
| 91 | code: "no_organization", | ||
| 92 | message: "No organization selector in request", | ||
| 93 | }; | ||
| 89 | const INVALID_ACCESS_TOKEN = { | 94 | const 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); the | 207 | // 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 is | 208 | // 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 trust | 209 | // 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); |
| @@ -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 | 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 | ||
| 70 | describe("billing route org selector", () => { | 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 | 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 | }); |
| @@ -13,12 +13,15 @@ import { · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | type BillingSession = { | 14 | type BillingSession = { |
| 15 | readonly userId: string; | 15 | readonly userId: string; |
| 16 | readonly organizationId?: string | null; | ||
| 17 | }; | 16 | }; |
| 18 | 17 | ||
| 19 | export const resolveBillingOrganization = (request: Request, session: BillingSession) => | 18 | export 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, |
| @@ -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( |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | useParams, | 10 | useParams, |
| 11 | } from "@tanstack/react-router"; | 11 | } from "@tanstack/react-router"; |
| 12 | import { AutumnProvider } from "autumn-js/react"; | 12 | import { AutumnProvider } from "autumn-js/react"; |
| 13 | import { isValidOrgSlug } from "@executor-js/api"; | ||
| 13 | import posthog from "posthog-js"; | 14 | import posthog from "posthog-js"; |
| 14 | import { PostHogProvider } from "posthog-js/react"; | 15 | import { PostHogProvider } from "posthog-js/react"; |
| 15 | import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; | 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 | // is scoped to it, so `auth.organization` IS this org when the caller is a | 215 | // 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 to | 227 | // 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's | 291 | // /<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. The | 292 | // 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 slug | 294 | // 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 | <OrganizationProvider | 317 | <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, so | 321 | {/* 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 keeps | 323 | 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> |
| @@ -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 whose | 65 | // 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 | ||
| 68 | const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; | ||
| 69 | |||
| 70 | const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) => | 68 | const 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, |
| 88 | const withRefreshedSession = (identity: Identity, response: Response): Identity => { | 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 | 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). */ | ||
| 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 | /** 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. */ |
| 98 | const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => | 116 | const 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. */ |
| 111 | const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) => | 129 | const 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) was | 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 | // 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"` in | 140 | // `EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"` in |
| 122 | // packages/core/sdk/src/server-connection.ts), falling back to the session's | 141 | // 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 — the | 142 | // CLOSED without it — there is deliberately no fallback to the session's |
| 124 | // server re-checks live membership — so attaching it directly to the identity | 143 | // 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. | ||
| 126 | const ORG_SELECTOR_HEADER = "x-executor-organization"; | 147 | const ORG_SELECTOR_HEADER = "x-executor-organization"; |
| 127 | 148 | ||
| 128 | /** Switch this account's active org; returns the identity scoped to it via | 149 | /** 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). */ | ||
| 141 | const activeOrganizationId = (target: TargetShape, identity: Identity) => | 163 | const 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 }; |
| @@ -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) ────── |
| 109 | const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; | 109 | const ORG_SELECTOR_HEADER = "x-executor-organization"; |
| 110 | 110 | ||
| 111 | const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) => | 111 | const 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 | ||
| 128 | const withRefreshedSession = (identity: Identity, response: Response): Identity => { | 131 | const 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). */ | ||
| 137 | const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => | 149 | const 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 | ||
| 149 | const apiKeyTemplate = [ | 163 | const apiKeyTemplate = [ |
| @@ -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 in | 81 | // 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 | }; |
| @@ -4,24 +4,52 @@ import * as React from "react"; · 3 unmodified lines | |||
| 4 | import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting"; | 4 | import { FrontendErrorReporterProvider, type FrontendErrorReporter } from "./error-reporting"; |
| 5 | import { | 5 | import { |
| 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 | ||
| 11 | function ExecutorRegistryProvider(props: React.PropsWithChildren) { | 12 | function 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 | ||
| 16 | export const ExecutorProvider = ( | 36 | export 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 | ); |
| @@ -91,7 +91,21 @@ let activeConnection = resolveInitialExecutorServerConnection(); · 90 unmodified lines | |||
| 91 | // cloudflare) never produce one either. | 91 | // cloudflare) never produce one either. |
| 92 | export const EXECUTOR_ORG_HEADER = EXECUTOR_ORG_SELECTOR_HEADER; | 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 | export const getActiveOrgSlug = (): string | null => { | 107 | export 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]; |