History

Web clients scope by the URL org; drop cookie-based org switching (#1000)

The console URL's org slug is now the request scope on the client too: both API clients (core + account) read the slug from window.location at request time and send it as the x-executor-organization header, so every tab's requests are scoped to the org its own URL names — independent of the single shared session cookie. With requests URL-scoped, the cookie-based switch machinery is dead weight: - delete the switchOrganization endpoint/handler/atom; the org switcher and create-org just navigate to /<slug> (the session already authenticates the user to all their orgs, so there is nothing to switch server-side). - delete ForeignOrgSlug; a slug the caller can't access now resolves to a null org via /account/me and the shell 404s, no switch-into-org dance. - simplify OrgSlugGate to canonicalize a bare URL onto the active slug and otherwise render through (a slug in the URL is already the scope). Flips the multi-tab scenario from reproducing the cookie-steal corruption to asserting per-tab independence, and reframes the foreign-slug scenario around URL scoping (no session switch; the cookie is never rewritten).

Rhys Sullivan committed 4 months ago

Commit 1f9bfe01 parent 58e23e814 files · +233 −214Browse files

Unified · Split

14 files
  1. apps/cloud/src/auth/api.ts +0 −11
  2. apps/cloud/src/auth/handlers.ts +0 −14
  3. apps/cloud/src/routes/__root.tsx +19 −15
  4. apps/cloud/src/web/auth.tsx +0 −1
  5. apps/cloud/src/web/components/foreign-org-slug.tsx +0 −61
  6. apps/cloud/src/web/components/org-menu-slot.tsx +12 −15
  7. apps/host-cloudflare/web/routes/__root.tsx +2 −2
  8. apps/host-selfhost/web/routes/__root.tsx +2 −2
  9. e2e/cloud/org-multitab-cookie.test.ts +111 −0
  10. e2e/cloud/org-slug-foreign.test.ts +24 −18
  11. packages/react/src/api/account-client.tsx +11 −1
  12. packages/react/src/api/client.tsx +11 −1
  13. packages/react/src/api/server-connection.tsx +27 −0
  14. packages/react/src/multiplayer/org-slug-gate.tsx +14 −73
apps/cloud/src/auth/api.ts+0−11
@@ -34,10 +34,6 @@ const AuthOrganizationsResponse = Schema.Struct({ · 33 unmodified lines
3434 activeOrganizationId: Schema.NullOr(Schema.String),
3535 });
3636
37−const SwitchOrganizationBody = Schema.Struct({
38− organizationId: Schema.String,
39−});
40−
4137 const CreateOrganizationBody = Schema.Struct({
4238 name: Schema.String,
4339 });
@@ -143,7 +139,6 @@ export const AUTH_PATHS = { · 99 unmodified lines
143139 login: "/api/auth/login",
144140 logout: "/api/auth/logout",
145141 callback: "/api/auth/callback",
146− switchOrganization: "/api/auth/switch-organization",
147142 } as const;
148143
149144 const AuthErrors = [UserStoreError, WorkOSError] as const;
@@ -178,12 +173,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 28 unmodified lines
178173 error: WorkOSError,
179174 }),
180175 )
181− .add(
182− HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", {
183− payload: SwitchOrganizationBody,
184− error: WorkOSError,
185− }),
186− )
187176 .add(
188177 HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
189178 payload: CreateOrganizationBody,
apps/cloud/src/auth/handlers.ts+0−14
@@ -301,20 +301,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 300 unmodified lines
301301 };
302302 }),
303303 )
304− .handle("switchOrganization", ({ payload }) =>
305− Effect.gen(function* () {
306− const workos = yield* WorkOSClient;
307− const session = yield* SessionContext;
308−
309− const refreshed = yield* workos.refreshSession(
310− session.sealedSession,
311− payload.organizationId,
312− );
313− if (refreshed) {
314− (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS);
315− }
316− }),
317− )
318304 .handle("createOrganization", ({ payload }) =>
319305 Effect.gen(function* () {
320306 const workos = yield* WorkOSClient;
apps/cloud/src/routes/__root.tsx+19−15
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 createRootRoute,
88 useLocation,
99 useNavigate,
10+ useParams,
1011 } from "@tanstack/react-router";
1112 import { AutumnProvider } from "autumn-js/react";
1213 import posthog from "posthog-js";
@@ -23,7 +24,6 @@ import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; · 10 unmodified lines
2324 import { AuthProvider, useAuth } from "../web/auth";
2425 import { loginPath } from "../auth/return-to";
2526 import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";
26−import { ForeignOrgSlug } from "../web/components/foreign-org-slug";
2727 import { SupportOptions } from "../web/components/support-options";
2828 import { Shell } from "../web/shell";
2929 import appCss from "@executor-js/react/globals.css?url";
@@ -209,13 +209,20 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 179 unmodified lines
209209 const navigate = useNavigate();
210210 const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);
211211 const isPublicRoute = PUBLIC_PATHS.has(location.pathname);
212+ // The org the URL names (the `{-$orgSlug}` segment), if any. `/account/me`
213+ // is scoped to it, so `auth.organization` IS this org when the caller is a
214+ // member — and `null` when the URL names an org they can't access.
215+ const urlOrgSlug = (useParams({ strict: false }) as { orgSlug?: string }).orgSlug;
212216
213217 // The SSR gate already bounced fresh org-less document requests to
214218 // /create-org; this catches the MID-SESSION transitions (org deleted,
215− // membership revoked → /account/me now reports no org).
219+ // membership revoked → /account/me now reports no org). Only for BARE paths:
220+ // an org-less result on a slugged URL is a wrong address (404 below), not a
221+ // reason to send the user to onboarding.
216222 const needsOrgRedirect =
217223 auth.status === "authenticated" &&
218224 auth.organization == null &&
225+ !urlOrgSlug &&
219226 !isOnboardingRoute &&
220227 !isPublicRoute;
221228
@@ -254,7 +261,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 32 unmodified lines
254261 }
255262
256263 if (auth.organization == null) {
257− return <BlankScreen />;
264+ // A URL naming an org this session can't access (`/account/me` returned no
265+ // org for its slug) is a wrong address → the route 404, framed by nothing
266+ // (the user isn't "in" any org here). A bare path with no org is a new
267+ // user — the redirect effect above is taking them to onboarding.
268+ return urlOrgSlug ? <NotFoundPage /> : <BlankScreen />;
258269 }
259270
260271 // Seed the server connection from the SSR origin so origin-derived UI (the
@@ -275,18 +286,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 14 unmodified lines
275286 organizationId={auth.organization.id}
276287 organizationSlug={activeSlug}
277288 >
278− <OrgSlugGate
279− activeSlug={activeSlug}
280− // Framed by the real shell: a foreign slug resolves (or
281− // 404s) inside the app chrome, exactly like the route-level
282− // not-found — never a bare full-page state.
283− foreignSlug={(slug) => (
284− <>
285− <Shell content={<ForeignOrgSlug slug={slug} />} />
286− <Toaster />
287− </>
288− )}
289− >
289+ {/* The org header scopes every request to the URL's org, so
290+ reaching here means the caller is a member of `activeSlug`
291+ (a foreign slug already 404'd above). The gate only keeps
292+ the URL canonical — bare → /<slug>. */}
293+ <OrgSlugGate activeSlug={activeSlug}>
290294 <Shell />
291295 <Toaster />
292296 </OrgSlugGate>
apps/cloud/src/web/auth.tsx+0−1
@@ -33,7 +33,6 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 32 unmodified lines
3333 }),
3434 );
3535
36−export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
3736 export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
3837
3938 export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
apps/cloud/src/web/components/foreign-org-slug.tsx+0−61
@@ -1,61 +0,0 @@
1−import { useEffect, useRef, useState, type ReactNode } from "react";
2−import { useAtomValue, useAtomSet } from "@effect/atom-react";
3−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4−import * as Exit from "effect/Exit";
5−import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
6−import { OrgSlugNotFound } from "@executor-js/react/multiplayer/org-slug-gate";
7−
8−import { organizationsAtom, switchOrganization } from "../auth";
9−
10−// ---------------------------------------------------------------------------
11−// Foreign-slug resolution for the cloud org-slug gate: the URL names a slug
12−// that isn't the active org's. Resolve it against the caller's memberships:
13−// - a member of that org (their bookmark, a teammate's shared link) → switch
14−// the session into it and reload, so every atom re-scopes
15−// - anything else → a WRONG ADDRESS → not-found. A slug must never silently
16−// resolve to a different workspace than the URL names, and single-segment
17−// typos (/this-page-does-not-exist) match the slugged index route, so this
18−// IS the app's 404 for them.
19−// Membership comes from the already-cached organizations atom; the matched
20−// case alone performs a side effect, isolated in `SwitchIntoOrg`.
21−// ---------------------------------------------------------------------------
22−
23−const Centered = (props: { children?: ReactNode }) => (
24− <div className="flex min-h-full flex-1 items-center justify-center text-sm text-muted-foreground">
25− {props.children}
26− </div>
27−);
28−
29−// Switch the session into `organizationId`, then full-reload so the whole app
30−// re-scopes. One-shot: the ref guards React's double-invoke and the render
31−// between resolution and the reload landing. A failed switch means the slug
32−// named an org we can't actually enter — fall back to not-found.
33−function SwitchIntoOrg(props: { readonly organizationId: string }) {
34− const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
35− const [failed, setFailed] = useState(false);
36− const started = useRef(false);
37−
38− useEffect(() => {
39− if (started.current) return;
40− started.current = true;
41− void doSwitchOrganization({
42− payload: { organizationId: props.organizationId },
43− reactivityKeys: authWriteKeys,
44− }).then((exit) => (Exit.isSuccess(exit) ? window.location.reload() : setFailed(true)));
45− }, [props.organizationId, doSwitchOrganization]);
46−
47− return failed ? <OrgSlugNotFound /> : <Centered>Switching organization…</Centered>;
48−}
49−
50−export function ForeignOrgSlug(props: { readonly slug: string }) {
51− const organizations = useAtomValue(organizationsAtom);
52−
53− return AsyncResult.match(organizations, {
54− onInitial: () => <Centered />,
55− onFailure: () => <OrgSlugNotFound />,
56− onSuccess: ({ value }) => {
57− const match = value.organizations.find((org: { slug: string }) => org.slug === props.slug);
58− return match ? <SwitchIntoOrg organizationId={match.id} /> : <OrgSlugNotFound />;
59− },
60− });
61−}
apps/cloud/src/web/components/org-menu-slot.tsx+12−15
@@ -1,8 +1,6 @@
11 import { useState } from "react";
2−import { useAtomValue, useAtomSet } from "@effect/atom-react";
2+import { useAtomValue } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4−import * as Exit from "effect/Exit";
5−import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
64 import { trackEvent } from "@executor-js/react/api/analytics";
75 import { Button } from "@executor-js/react/components/button";
86 import {
@@ -23,7 +21,7 @@ import { · 14 unmodified lines
2321 DropdownMenuSubTrigger,
2422 } from "@executor-js/react/components/dropdown-menu";
2523 import { useAuth } from "../auth";
26−import { organizationsAtom, switchOrganization } from "../auth";
24+import { organizationsAtom } from "../auth";
2725 import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";
2826
2927 // ---------------------------------------------------------------------------
@@ -52,18 +50,15 @@ function CheckIcon() { · 22 unmodified lines
5250
5351 function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {
5452 const organizations = useAtomValue(organizationsAtom);
55− const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
5653
57− const handleSwitch = async (organization: { id: string; slug: string }) => {
54+ // Switching orgs is now a pure URL navigation: the session authenticates the
55+ // user to ALL their orgs, and the slug in the path scopes every request (the
56+ // `x-executor-organization` header). No cookie to rewrite, no server switch
57+ // call — just land on the other org's URL root and the whole app re-scopes.
58+ const handleSwitch = (organization: { id: string; slug: string }) => {
5859 if (organization.id === props.activeOrganizationId) return;
59− const exit = await doSwitchOrganization({
60− payload: { organizationId: organization.id },
61− reactivityKeys: authWriteKeys,
62− });
63− trackEvent("org_switched", { success: Exit.isSuccess(exit) });
64− // Land on the new org's URL root — a plain reload would keep the OLD
65− // org's slug in the path and the slug gate would switch right back.
66− if (Exit.isSuccess(exit)) window.location.href = `/${organization.slug}`;
60+ trackEvent("org_switched", { success: true });
61+ window.location.href = `/${organization.slug}`;
6762 };
6863
6964 return AsyncResult.match(organizations, {
@@ -80,7 +75,9 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null · 10 unmodified lines
8075 <DropdownMenuItem
8176 key={organization.id}
8277 disabled={isActive}
83− onClick={() => handleSwitch(organization)}
78+ onClick={() => {
79+ handleSwitch(organization);
80+ }}
8481 className="text-xs"
8582 >
8683 <span className="min-w-0 flex-1 truncate">{organization.name}</span>
apps/host-cloudflare/web/routes/__root.tsx+2−2
@@ -62,8 +62,8 @@ function AuthenticatedApp() { · 61 unmodified lines
6262 const auth = useAuth();
6363 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
6464
65− // Single-org instance: any URL slug other than the instance org's (or none)
66− // canonicalizes — no foreignSlug handler, there's nothing to switch to.
65+ // Single-org instance: a bare URL canonicalizes onto the instance org's
66+ // slug. There's only ever one org, so no other slug is reachable.
6767 const gated = (
6868 <>
6969 <Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} />
apps/host-selfhost/web/routes/__root.tsx+2−2
@@ -70,8 +70,8 @@ function AuthenticatedApp() { · 69 unmodified lines
7070 const auth = useAuth();
7171 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
7272
73− // Single-org instance: any URL slug other than the instance org's (or none)
74− // canonicalizes — no foreignSlug handler, there's nothing to switch to.
73+ // Single-org instance: a bare URL canonicalizes onto the instance org's
74+ // slug. There's only ever one org, so no other slug is reachable.
7575 const gated = (
7676 <>
7777 <Shell onSignOut={signOut} navItems={selfHostNavItems} />
e2e/cloud/org-multitab-cookie.test.ts+111−0
@@ -0,0 +1,111 @@
1+// Cloud-only (browser): two tabs, two orgs, at the same time — independent.
2+//
3+// WorkOS still pins ONE org into the sealed `wos-session` cookie, and the whole
4+// browser shares one cookie jar. Under the OLD cookie-based "active org" model
5+// that made "active organization" a browser-global: two tabs could not be in
6+// two orgs at once, and a switch (or the slug gate's switch-to-honor-the-URL)
7+// silently re-scoped the other tab out from under it.
8+//
9+// The stateless URL model removes that hazard. The slug in the path is the
10+// request scope: every API call carries it (the `x-executor-organization`
11+// header), the server re-checks live membership and resolves data for THAT
12+// org, and the session merely authenticates the user to all their orgs at
13+// once. Nothing writes the cookie on a switch. So this scenario — once the
14+// reproduction of the corruption — now asserts the opposite: each tab's
15+// requests stay scoped to its own URL org, no matter what the other tab does.
16+//
17+// Everything runs through the browser (onboarding + the menu create-org), so
18+// the single-use WorkOS refresh-token chain stays browser-owned and valid.
19+import { expect } from "@effect/vitest";
20+import { Effect } from "effect";
21+
22+import { scenario } from "../src/scenario";
23+import { Browser, Target } from "../src/services";
24+
25+scenario(
26+ "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)",
27+ {},
28+ Effect.gen(function* () {
29+ const target = yield* Target;
30+ const browser = yield* Browser;
31+ const identity = yield* target.newIdentity({ org: false });
32+
33+ yield* browser.session(identity, async ({ page: tab1, step }) => {
34+ const slugOf = (page: typeof tab1) => new URL(page.url()).pathname.replace(/^\/|\/.*$/g, "");
35+
36+ // The org slug a page's REAL app requests carry — read straight off the
37+ // outgoing `x-executor-organization` header, the actual request scope.
38+ // This is what makes the two tabs independent; the shared session cookie
39+ // is irrelevant to it.
40+ const requestOrgSlugOf = async (page: typeof tab1): Promise<string> => {
41+ const matching = page.waitForRequest(
42+ (request) =>
43+ request.url().includes("/api/") &&
44+ request.headers()["x-executor-organization"] !== undefined,
45+ { timeout: 15_000 },
46+ );
47+ // Nudge the app to refetch so a fresh scoped request goes out.
48+ void page.reload({ waitUntil: "commit" });
49+ return (await matching).headers()["x-executor-organization"]!;
50+ };
51+
52+ let slugA = "";
53+ let slugB = "";
54+
55+ await step("Onboard org A in tab 1", async () => {
56+ await tab1.goto("/", { waitUntil: "networkidle" });
57+ await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A");
58+ await tab1.getByRole("button", { name: "Create organization" }).click();
59+ await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 });
60+ await tab1.getByRole("button", { name: "Continue to app" }).click();
61+ await tab1.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 });
62+ await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 });
63+ slugA = slugOf(tab1);
64+ });
65+
66+ await step("Create org B from tab 1's account menu — tab 1 is now in B", async () => {
67+ await tab1.getByRole("button", { name: /Test User/ }).click();
68+ await tab1.getByRole("menuitem", { name: "Multitab A" }).click();
69+ await tab1
70+ .locator('[data-slot="dropdown-menu-sub-content"]')
71+ .getByText("Create organization", { exact: true })
72+ .click();
73+ await tab1.getByText("Add another organization").waitFor();
74+ await tab1.getByPlaceholder("Northwind Labs").fill("Multitab B");
75+ await tab1.getByRole("button", { name: "Create organization" }).click();
76+ await tab1.waitForURL((url) => url.pathname !== `/${slugA}`, { timeout: 30_000 });
77+ await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 });
78+ slugB = slugOf(tab1);
79+ });
80+ expect(slugB, "the two orgs have distinct slugs").not.toBe(slugA);
81+
82+ // A second tab in the SAME context — shares tab 1's cookie jar.
83+ const tab2 = await tab1.context().newPage();
84+
85+ await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => {
86+ await tab2.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
87+ await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 });
88+ expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe(
89+ `/${slugA}/policies`,
90+ );
91+ expect(await requestOrgSlugOf(tab2), "tab 2's API requests are scoped to org A").toBe(
92+ slugA,
93+ );
94+ });
95+
96+ await step("Tab 1 is untouched: still org B, and its requests still scope to B", async () => {
97+ expect(new URL(tab1.url()).pathname, "tab 1's URL still says org B").toBe(`/${slugB}`);
98+ expect(
99+ await tab1.getByRole("button", { name: /Multitab B/ }).isVisible(),
100+ "tab 1's sidebar still shows org B",
101+ ).toBe(true);
102+ // The crux: tab 2 opening org A did NOT re-scope tab 1. Tab 1's own
103+ // requests still carry org B's slug — the URL is the scope, not a
104+ // shared cookie a sibling tab can steal.
105+ expect(await requestOrgSlugOf(tab1), "tab 1's API requests stay scoped to org B").toBe(
106+ slugB,
107+ );
108+ });
109+ });
110+ }),
111+);
e2e/cloud/org-slug-foreign.test.ts+24−18
@@ -1,12 +1,13 @@
1−// Cloud-only (browser): opening another of YOUR orgs by its slug URL switches
2−// the session into it. The org-slug gate's foreign-slug branch (ForeignOrgSlug)
3−// resolves a URL slug that isn't the active org against the caller's
4−// memberships — a match switches + reloads, so a bookmark or a teammate's link
5−// into a shared org lands you there even when a different org is active.
1+// Cloud-only (browser): opening another of YOUR orgs by its slug URL just
2+// works — no switch, no cookie rewrite. The slug in the path is the request
3+// scope (the `x-executor-organization` header), and the session authenticates
4+// the user to ALL their orgs at once, so a bookmark or a teammate's link into a
5+// shared org lands you there regardless of which org the cookie happens to pin.
66 //
7−// org-switcher.test.ts covers switching via the account menu; this covers the
8−// URL path. (Unknown/unauthorized slugs → 404 is covered by
9−// scenarios/org-slug-routing.test.ts.)
7+// org-switcher.test.ts covers the account-menu navigation; this covers the URL
8+// path. (Unknown/unauthorized slugs → 404 is covered by
9+// scenarios/org-slug-routing.test.ts; two tabs at once by
10+// org-multitab-cookie.test.ts.)
1011 import { expect } from "@effect/vitest";
1112 import { Effect } from "effect";
1213
@@ -16,14 +17,14 @@ import { Browser, Target } from "../src/services"; · 3 unmodified lines
1617 const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
1718
1819 scenario(
19− "Org URLs · opening another of your orgs by slug switches the session into it",
20+ "Org URLs · opening another of your orgs by slug works without switching the session",
2021 {},
2122 Effect.gen(function* () {
2223 const target = yield* Target;
2324 const browser = yield* Browser;
2425
2526 // Identity starts in org A. Create org B through the real endpoint, which
26− // switches the active session to B and returns its refreshed cookie.
27+ // returns its refreshed cookie (the session is now pinned to B).
2728 const identity = yield* target.newIdentity();
2829 const cookie = identity.headers?.cookie ?? "";
2930
@@ -42,23 +43,22 @@ scenario( · 12 unmodified lines
4243 const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string };
4344 const setCookie = createB.headers.get("set-cookie") ?? "";
4445 const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1];
45− expect(sessionB, "creating org B refreshed the session into it").toBeTruthy();
46+ expect(sessionB, "creating org B pinned the session into it").toBeTruthy();
4647
47− // Both orgs' slugs from the session that is now active in B.
48+ // Both orgs' slugs (the org list is the same whichever org the cookie pins).
4849 const orgs = (yield* Effect.promise(() =>
4950 fetch(new URL("/api/auth/organizations", target.baseUrl), {
5051 headers: { cookie: `wos-session=${sessionB}` },
5152 }).then((r) => r.json()),
5253 )) as {
5354 organizations: ReadonlyArray<{ name: string; slug: string }>;
54− activeOrganizationId: string;
5555 };
5656 const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug;
5757 expect(slugA, "org A has a slug").toBeTruthy();
5858 expect(orgB.slug, "org B has a slug").toBeTruthy();
5959 expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug);
6060
61− // Drive the browser as the session that is ACTIVE IN B.
61+ // Drive the browser as the session pinned to B.
6262 const inB = {
6363 ...identity,
6464 headers: { cookie: `wos-session=${sessionB}` },
@@ -69,15 +69,21 @@ scenario( · 4 unmodified lines
6969 await step("Land in org B, then open org A's slug URL directly", async () => {
7070 await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
7171 await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
72− // Navigate to org A — the gate sees a foreign-but-member slug.
72+ // Open org A by its slug while the cookie is still pinned to B.
7373 await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
7474 });
7575
76− await step("The session switches into org A and lands on the slugged URL", async () => {
77− // Reaching org A's policies at its URL is the proof: a switch that
78− // failed would render the gate's 404 here, not the Policies page.
76+ await step("Org A's page renders at its slugged URL — the URL is the scope", async () => {
77+ // Reaching org A's policies at its URL is the proof the request scoped
78+ // to A off the URL alone: a model that required the cookie to name A
79+ // would 404 here (the cookie still says B).
7980 await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 });
8081 await page.getByText("Policies").first().waitFor({ timeout: 30_000 });
82+ // And no switch happened: the session cookie still names org B.
83+ const sessionCookie = (await page.context().cookies())
84+ .find((c) => c.name === "wos-session")
85+ ?.value.split(";")[0];
86+ expect(sessionCookie, "the session cookie was never rewritten — no switch").toBe(sessionB);
8187 });
8288 });
8389 }),
packages/react/src/api/account-client.tsx+11−1
@@ -4,7 +4,12 @@ import { AccountHttpApi } from "@executor-js/api/client"; · 3 unmodified lines
44 import * as Effect from "effect/Effect";
55
66 import { reportApiClientInfrastructureCause } from "./client";
7−import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection";
7+import {
8+ EXECUTOR_ORG_HEADER,
9+ getActiveOrgSlug,
10+ getExecutorApiBaseUrl,
11+ getExecutorServerAuthorizationHeader,
12+} from "./server-connection";
813
914 // ---------------------------------------------------------------------------
1015 // Shared account client — the provider-neutral `/account/*` surface.
@@ -25,6 +30,11 @@ const AccountApiClient = AtomHttpApi.Service<"AccountApiClient">()("AccountApiCl · 14 unmodified lines
2530 if (authorization) {
2631 next = HttpClientRequest.setHeader(next, "authorization", authorization);
2732 }
33+ // Scope to the org the console URL is on (see server-connection).
34+ const orgSlug = getActiveOrgSlug();
35+ if (orgSlug) {
36+ next = HttpClientRequest.setHeader(next, EXECUTOR_ORG_HEADER, orgSlug);
37+ }
2838 return next;
2939 }),
3040 transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
packages/react/src/api/client.tsx+11−1
@@ -11,7 +11,12 @@ import * as Option from "effect/Option"; · 10 unmodified lines
1111 import * as Schema from "effect/Schema";
1212
1313 import { reportHandledFrontendError } from "./error-reporting";
14−import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection";
14+import {
15+ EXECUTOR_ORG_HEADER,
16+ getActiveOrgSlug,
17+ getExecutorApiBaseUrl,
18+ getExecutorServerAuthorizationHeader,
19+} from "./server-connection";
1520
1621 const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean =>
1722 Option.match(Cause.findErrorOption(cause), {
@@ -95,6 +100,11 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 77 unmodified lines
95100 if (authorization) {
96101 next = HttpClientRequest.setHeader(next, "authorization", authorization);
97102 }
103+ // Scope the request to the org the console URL is on (see server-connection).
104+ const orgSlug = getActiveOrgSlug();
105+ if (orgSlug) {
106+ next = HttpClientRequest.setHeader(next, EXECUTOR_ORG_HEADER, orgSlug);
107+ }
98108 return next;
99109 }),
100110 transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
packages/react/src/api/server-connection.tsx+27−0
@@ -1,4 +1,5 @@
11 import * as React from "react";
2+import { isValidOrgSlug } from "@executor-js/api";
23 import {
34 DEFAULT_EXECUTOR_SERVER_ORIGIN,
45 DEFAULT_EXECUTOR_SERVER_USERNAME,
@@ -84,6 +85,32 @@ const resolveInitialExecutorServerConnection = (): ExecutorServerConnection => { · 79 unmodified lines
8485
8586 let activeConnection = resolveInitialExecutorServerConnection();
8687
88+// ---------------------------------------------------------------------------
89+// Active org selector — the org the console URL is scoped to.
90+// ---------------------------------------------------------------------------
91+//
92+// Org-scoped hosts (cloud) send this on every API request so the server scopes
93+// to the URL's org, not the session's stored one — which is what lets two
94+// browser tabs sit in two different orgs at once (each tab's window.location is
95+// its own).
96+//
97+// Read straight from `window.location` at REQUEST time, not from a
98+// React-synced mirror: the API clients' `transformClient` runs only in the
99+// browser, so this never touches SSR/hydration, and it's always exactly the
100+// current URL with no effect-timing to get wrong. The org is the first path
101+// segment when it's a valid slug; reserved console roots (`/policies`,
102+// `/login`, …) are NOT valid slugs, so a bare path sends no header and the
103+// server falls back to the session org. Hosts without slugs (self-host,
104+// cloudflare) never produce one either.
105+export const EXECUTOR_ORG_HEADER = "x-executor-organization";
106+
107+export const getActiveOrgSlug = (): string | null => {
108+ const pathname = globalThis.window?.location?.pathname;
109+ if (!pathname) return null;
110+ const first = pathname.split("/")[1];
111+ return first && isValidOrgSlug(first) ? first : null;
112+};
113+
87114 export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection;
88115
89116 export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => {
packages/react/src/multiplayer/org-slug-gate.tsx+14−73
@@ -1,94 +1,39 @@
1−import { useEffect, useRef, type ReactNode } from "react";
1+import { useEffect, type ReactNode } from "react";
22 import { useNavigate, useParams } from "@tanstack/react-router";
33
44 // ---------------------------------------------------------------------------
55 // Org-slug URL canonicalization for org-scoped hosts (cloud, self-host,
66 // cloudflare). Console routes live under an optional `{-$orgSlug}` segment, so
77 // the same tree serves `/policies` and `/acme/policies`; this gate, mounted
8−// inside the authenticated shell, pins the URL to the ACTIVE organization's
9−// slug:
8+// inside the authenticated shell, pins a BARE URL to the active org's slug:
109 //
1110 // - bare URL → replace with `/<active-slug>/…` (canonicalize)
12−// - active slug in URL → render
13−// - other slug in URL → `foreignSlug(slug)` when provided (cloud renders a
14−// switch-organization resolver); single-org hosts
15−// omit it and the slug reads as a wrong address —
16−// a NOT-FOUND page, never a silent redirect into a
17−// workspace the URL didn't name
11+// - slug already in URL → render
1812 //
19−// "Foreign" is judged against where the SESSION has been, not just the URL:
20−// when the active slug changes while mounted (an org create/switch elsewhere
21−// in the app re-resolves `/account/me`), the URL is the stale side and
22−// canonicalizes forward. Treating that as a foreign URL would switch the
23−// session straight back — racing the in-flight navigation and rotating the
24−// session cookie twice (WorkOS sealed-session refreshes are single-use, so
25−// the loser of that race invalidates the winner).
26−//
27−// The URL slug is a SELECTOR, not a trust boundary — every API call is scoped
28−// by the session server-side, same as the MCP URL-pinned org.
13+// The URL slug is the request SCOPE, not just a label: every API call carries
14+// it (the `x-executor-organization` header), and the server re-checks live
15+// membership and resolves data for that org — same as the MCP URL-pinned org.
16+// So a foreign slug never reaches this gate as "active": the server returns no
17+// organization for an org the caller can't see, and the shell 404s upstream.
18+// That makes two browser tabs on different orgs fully independent — no shared
19+// "active org" to steal.
2920 // ---------------------------------------------------------------------------
3021
3122 export interface OrgSlugGateProps {
3223 /** The active organization's slug (from `useAuth().organization`). */
3324 readonly activeSlug: string;
34− /**
35− * Rendered INSTEAD of children when the URL carries a different org's slug
36− * at load time. Multi-org hosts resolve it (switch the session when the
37− * caller is a member, not-found otherwise); single-org hosts leave it unset
38− * and get {@link OrgSlugNotFound}.
39− */
40− readonly foreignSlug?: (slug: string) => ReactNode;
41− /** The host's not-found page for foreign slugs; default {@link OrgSlugNotFound}. */
42− readonly notFound?: ReactNode;
4325 readonly children: ReactNode;
4426 }
4527
46−/**
47− * The default not-found page for a URL naming an org this session can't see.
48− * Sized to fill its container (the shell's content area, or the viewport when
49− * rendered bare). The home link is BARE on purpose — it canonicalizes onto
50− * the active org.
51− */
52−export function OrgSlugNotFound() {
53− return (
54− <main className="flex min-h-full flex-1 items-center justify-center bg-background px-6 py-10">
55− <section className="w-full max-w-md text-center">
56− <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">404</p>
57− <h1 className="mt-2 text-xl font-semibold text-foreground">Page not found</h1>
58− <p className="mt-2 text-sm leading-6 text-muted-foreground">
59− There&apos;s nothing at this address.
60− </p>
61− <a
62− href="/"
63− className="mt-6 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 transition-colors"
64− >
65− Go home
66− </a>
67− </section>
68− </main>
69− );
70−}
71−
7228 export function OrgSlugGate(props: OrgSlugGateProps) {
73− const { activeSlug, foreignSlug } = props;
29+ const { activeSlug } = props;
7430 const params = useParams({ strict: false }) as { orgSlug?: string };
7531 const urlSlug = params.orgSlug ?? null;
7632 const navigate = useNavigate();
7733
78− // The active slug the URL last agreed with. While it differs from
79− // `activeSlug`, the session has moved out from under the URL (create/switch
80− // elsewhere in the app) — that is never a foreign URL.
81− const agreedSlug = useRef(activeSlug);
82− useEffect(() => {
83− if (urlSlug === activeSlug) agreedSlug.current = activeSlug;
84− }, [urlSlug, activeSlug]);
85− const sessionMoved = agreedSlug.current !== activeSlug;
86−
87− const isForeign = urlSlug !== null && urlSlug !== activeSlug && !sessionMoved;
88− // Only URLs that don't NAME a different org canonicalize: bare paths, and
89− // stale slugs after the session itself moved. A foreign slug never does —
90− // it either resolves (the host's foreignSlug) or reads as a wrong address.
91− const needsCanonicalize = urlSlug === null || sessionMoved;
34+ // Only a BARE URL canonicalizes. A slug in the URL is the scope the whole
35+ // request chain already ran with, so it always matches `activeSlug` here.
36+ const needsCanonicalize = urlSlug === null;
9237
9338 useEffect(() => {
9439 if (!needsCanonicalize) return;
@@ -104,10 +49,6 @@ export function OrgSlugGate(props: OrgSlugGateProps) { · 9 unmodified lines
10449 });
10550 }, [needsCanonicalize, activeSlug, navigate]);
10651
107− if (isForeign) {
108− return <>{foreignSlug ? foreignSlug(urlSlug) : (props.notFound ?? <OrgSlugNotFound />)}</>;
109− }
110−
11152 // Render through while canonicalizing — the target is the same route, so
11253 // withholding children would only flash the page.
11354 return <>{props.children}</>;