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
34 activeOrganizationId: Schema.NullOr(Schema.String),34 activeOrganizationId: Schema.NullOr(Schema.String),
35});35});
36 36
37const SwitchOrganizationBody = Schema.Struct({
38 organizationId: Schema.String,
39});
40
41const CreateOrganizationBody = Schema.Struct({37const CreateOrganizationBody = Schema.Struct({
42 name: Schema.String,38 name: Schema.String,
43});39});
@@ -143,7 +139,6 @@ export const AUTH_PATHS = { · 99 unmodified lines
143 login: "/api/auth/login",139 login: "/api/auth/login",
144 logout: "/api/auth/logout",140 logout: "/api/auth/logout",
145 callback: "/api/auth/callback",141 callback: "/api/auth/callback",
146 switchOrganization: "/api/auth/switch-organization",
147} as const;142} as const;
148 143
149const AuthErrors = [UserStoreError, WorkOSError] as const;144const AuthErrors = [UserStoreError, WorkOSError] as const;
@@ -178,12 +173,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 28 unmodified lines
178 error: WorkOSError,173 error: WorkOSError,
179 }),174 }),
180 )175 )
181 .add(
182 HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", {
183 payload: SwitchOrganizationBody,
184 error: WorkOSError,
185 }),
186 )
187 .add(176 .add(
188 HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {177 HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
189 payload: CreateOrganizationBody,178 payload: CreateOrganizationBody,
apps/cloud/src/auth/handlers.ts+0−14
@@ -301,20 +301,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 300 unmodified lines
301 };301 };
302 }),302 }),
303 )303 )
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 )
318 .handle("createOrganization", ({ payload }) =>304 .handle("createOrganization", ({ payload }) =>
319 Effect.gen(function* () {305 Effect.gen(function* () {
320 const workos = yield* WorkOSClient;306 const workos = yield* WorkOSClient;
apps/cloud/src/routes/__root.tsx+19−15
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 createRootRoute,7 createRootRoute,
8 useLocation,8 useLocation,
9 useNavigate,9 useNavigate,
10 useParams,
10} from "@tanstack/react-router";11} from "@tanstack/react-router";
11import { AutumnProvider } from "autumn-js/react";12import { AutumnProvider } from "autumn-js/react";
12import posthog from "posthog-js";13import posthog from "posthog-js";
@@ -23,7 +24,6 @@ import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; · 10 unmodified lines
23import { AuthProvider, useAuth } from "../web/auth";24import { AuthProvider, useAuth } from "../web/auth";
24import { loginPath } from "../auth/return-to";25import { loginPath } from "../auth/return-to";
25import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";26import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";
26import { ForeignOrgSlug } from "../web/components/foreign-org-slug";
27import { SupportOptions } from "../web/components/support-options";27import { SupportOptions } from "../web/components/support-options";
28import { Shell } from "../web/shell";28import { Shell } from "../web/shell";
29import appCss from "@executor-js/react/globals.css?url";29import appCss from "@executor-js/react/globals.css?url";
@@ -209,13 +209,20 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 179 unmodified lines
209 const navigate = useNavigate();209 const navigate = useNavigate();
210 const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);210 const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);
211 const isPublicRoute = PUBLIC_PATHS.has(location.pathname);211 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;
212 216
213 // The SSR gate already bounced fresh org-less document requests to217 // The SSR gate already bounced fresh org-less document requests to
214 // /create-org; this catches the MID-SESSION transitions (org deleted,218 // /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.
216 const needsOrgRedirect =222 const needsOrgRedirect =
217 auth.status === "authenticated" &&223 auth.status === "authenticated" &&
218 auth.organization == null &&224 auth.organization == null &&
225 !urlOrgSlug &&
219 !isOnboardingRoute &&226 !isOnboardingRoute &&
220 !isPublicRoute;227 !isPublicRoute;
221 228
@@ -254,7 +261,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { · 32 unmodified lines
254 }261 }
255 262
256 if (auth.organization == null) {263 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 />;
258 }269 }
259 270
260 // Seed the server connection from the SSR origin so origin-derived UI (the271 // 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
275 organizationId={auth.organization.id}286 organizationId={auth.organization.id}
276 organizationSlug={activeSlug}287 organizationSlug={activeSlug}
277 >288 >
278 <OrgSlugGate289 {/* The org header scopes every request to the URL's org, so
279 activeSlug={activeSlug}290 reaching here means the caller is a member of `activeSlug`
280 // Framed by the real shell: a foreign slug resolves (or291 (a foreign slug already 404'd above). The gate only keeps
281 // 404s) inside the app chrome, exactly like the route-level292 the URL canonical — bare → /<slug>. */}
282 // not-found — never a bare full-page state.293 <OrgSlugGate activeSlug={activeSlug}>
283 foreignSlug={(slug) => (
284 <>
285 <Shell content={<ForeignOrgSlug slug={slug} />} />
286 <Toaster />
287 </>
288 )}
289 >
290 <Shell />294 <Shell />
291 <Toaster />295 <Toaster />
292 </OrgSlugGate>296 </OrgSlugGate>
apps/cloud/src/web/auth.tsx+0−1
@@ -33,7 +33,6 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( · 32 unmodified lines
33 }),33 }),
34);34);
35 35
36export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
37export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");36export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
38 37
39export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {38export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
apps/cloud/src/web/components/foreign-org-slug.tsx+0−61
@@ -1,61 +0,0 @@
1import { useEffect, useRef, useState, type ReactNode } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";
5import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import { OrgSlugNotFound } from "@executor-js/react/multiplayer/org-slug-gate";
7
8import { 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
23const 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.
33function 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
50export 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 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";
5import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import { trackEvent } from "@executor-js/react/api/analytics";4import { trackEvent } from "@executor-js/react/api/analytics";
7import { Button } from "@executor-js/react/components/button";5import { Button } from "@executor-js/react/components/button";
8import {6import {
@@ -23,7 +21,7 @@ import { · 14 unmodified lines
23 DropdownMenuSubTrigger,21 DropdownMenuSubTrigger,
24} from "@executor-js/react/components/dropdown-menu";22} from "@executor-js/react/components/dropdown-menu";
25import { useAuth } from "../auth";23import { useAuth } from "../auth";
26import { organizationsAtom, switchOrganization } from "../auth";24import { organizationsAtom } from "../auth";
27import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";25import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";
28 26
29// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
@@ -52,18 +50,15 @@ function CheckIcon() { · 22 unmodified lines
52 50
53function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {51function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {
54 const organizations = useAtomValue(organizationsAtom);52 const organizations = useAtomValue(organizationsAtom);
55 const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
56 53
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 }) => {
58 if (organization.id === props.activeOrganizationId) return;59 if (organization.id === props.activeOrganizationId) return;
59 const exit = await doSwitchOrganization({60 trackEvent("org_switched", { success: true });
60 payload: { organizationId: organization.id },61 window.location.href = `/${organization.slug}`;
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}`;
67 };62 };
68 63
69 return AsyncResult.match(organizations, {64 return AsyncResult.match(organizations, {
@@ -80,7 +75,9 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null · 10 unmodified lines
80 <DropdownMenuItem75 <DropdownMenuItem
81 key={organization.id}76 key={organization.id}
82 disabled={isActive}77 disabled={isActive}
83 onClick={() => handleSwitch(organization)}78 onClick={() => {
79 handleSwitch(organization);
80 }}
84 className="text-xs"81 className="text-xs"
85 >82 >
86 <span className="min-w-0 flex-1 truncate">{organization.name}</span>83 <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
62 const auth = useAuth();62 const auth = useAuth();
63 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;63 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
64 64
65 // Single-org instance: any URL slug other than the instance org's (or none)65 // Single-org instance: a bare URL canonicalizes onto the instance org's
66 // canonicalizes — no foreignSlug handler, there's nothing to switch to.66 // slug. There's only ever one org, so no other slug is reachable.
67 const gated = (67 const gated = (
68 <>68 <>
69 <Shell onSignOut={signOut} navItems={defaultShellNavItems} apiKeysTo={null} />69 <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
70 const auth = useAuth();70 const auth = useAuth();
71 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;71 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
72 72
73 // Single-org instance: any URL slug other than the instance org's (or none)73 // Single-org instance: a bare URL canonicalizes onto the instance org's
74 // canonicalizes — no foreignSlug handler, there's nothing to switch to.74 // slug. There's only ever one org, so no other slug is reachable.
75 const gated = (75 const gated = (
76 <>76 <>
77 <Shell onSignOut={signOut} navItems={selfHostNavItems} />77 <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.
19import { expect } from "@effect/vitest";
20import { Effect } from "effect";
21
22import { scenario } from "../src/scenario";
23import { Browser, Target } from "../src/services";
24
25scenario(
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 switches1// Cloud-only (browser): opening another of YOUR orgs by its slug URL just
2// the session into it. The org-slug gate's foreign-slug branch (ForeignOrgSlug)2// works — no switch, no cookie rewrite. The slug in the path is the request
3// resolves a URL slug that isn't the active org against the caller's3// scope (the `x-executor-organization` header), and the session authenticates
4// memberships — a match switches + reloads, so a bookmark or a teammate's link4// the user to ALL their orgs at once, so a bookmark or a teammate's link into a
5// into a shared org lands you there even when a different org is active.5// shared org lands you there regardless of which org the cookie happens to pin.
6//6//
7// org-switcher.test.ts covers switching via the account menu; this covers the7// org-switcher.test.ts covers the account-menu navigation; this covers the URL
8// URL path. (Unknown/unauthorized slugs → 404 is covered by8// path. (Unknown/unauthorized slugs → 404 is covered by
9// scenarios/org-slug-routing.test.ts.)9// scenarios/org-slug-routing.test.ts; two tabs at once by
10// org-multitab-cookie.test.ts.)
10import { expect } from "@effect/vitest";11import { expect } from "@effect/vitest";
11import { Effect } from "effect";12import { Effect } from "effect";
12 13
@@ -16,14 +17,14 @@ import { Browser, Target } from "../src/services"; · 3 unmodified lines
16const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });17const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
17 18
18scenario(19scenario(
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",
20 {},21 {},
21 Effect.gen(function* () {22 Effect.gen(function* () {
22 const target = yield* Target;23 const target = yield* Target;
23 const browser = yield* Browser;24 const browser = yield* Browser;
24 25
25 // Identity starts in org A. Create org B through the real endpoint, which26 // 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).
27 const identity = yield* target.newIdentity();28 const identity = yield* target.newIdentity();
28 const cookie = identity.headers?.cookie ?? "";29 const cookie = identity.headers?.cookie ?? "";
29 30
@@ -42,23 +43,22 @@ scenario( · 12 unmodified lines
42 const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string };43 const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string };
43 const setCookie = createB.headers.get("set-cookie") ?? "";44 const setCookie = createB.headers.get("set-cookie") ?? "";
44 const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1];45 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();
46 47
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).
48 const orgs = (yield* Effect.promise(() =>49 const orgs = (yield* Effect.promise(() =>
49 fetch(new URL("/api/auth/organizations", target.baseUrl), {50 fetch(new URL("/api/auth/organizations", target.baseUrl), {
50 headers: { cookie: `wos-session=${sessionB}` },51 headers: { cookie: `wos-session=${sessionB}` },
51 }).then((r) => r.json()),52 }).then((r) => r.json()),
52 )) as {53 )) as {
53 organizations: ReadonlyArray<{ name: string; slug: string }>;54 organizations: ReadonlyArray<{ name: string; slug: string }>;
54 activeOrganizationId: string;
55 };55 };
56 const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug;56 const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug;
57 expect(slugA, "org A has a slug").toBeTruthy();57 expect(slugA, "org A has a slug").toBeTruthy();
58 expect(orgB.slug, "org B has a slug").toBeTruthy();58 expect(orgB.slug, "org B has a slug").toBeTruthy();
59 expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug);59 expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug);
60 60
61 // Drive the browser as the session that is ACTIVE IN B.61 // Drive the browser as the session pinned to B.
62 const inB = {62 const inB = {
63 ...identity,63 ...identity,
64 headers: { cookie: `wos-session=${sessionB}` },64 headers: { cookie: `wos-session=${sessionB}` },
@@ -69,15 +69,21 @@ scenario( · 4 unmodified lines
69 await step("Land in org B, then open org A's slug URL directly", async () => {69 await step("Land in org B, then open org A's slug URL directly", async () => {
70 await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });70 await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
71 await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });71 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.
73 await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });73 await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
74 });74 });
75 75
76 await step("The session switches into org A and lands on the slugged URL", async () => {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: a switch that77 // Reaching org A's policies at its URL is the proof the request scoped
78 // failed would render the gate's 404 here, not the Policies page.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).
79 await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 });80 await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 });
80 await page.getByText("Policies").first().waitFor({ timeout: 30_000 });81 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);
81 });87 });
82 });88 });
83 }),89 }),
packages/react/src/api/account-client.tsx+11−1
@@ -4,7 +4,12 @@ import { AccountHttpApi } from "@executor-js/api/client"; · 3 unmodified lines
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5 5
6import { reportApiClientInfrastructureCause } from "./client";6import { reportApiClientInfrastructureCause } from "./client";
7import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection";7import {
8 EXECUTOR_ORG_HEADER,
9 getActiveOrgSlug,
10 getExecutorApiBaseUrl,
11 getExecutorServerAuthorizationHeader,
12} from "./server-connection";
8 13
9// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
10// Shared account client — the provider-neutral `/account/*` surface.15// Shared account client — the provider-neutral `/account/*` surface.
@@ -25,6 +30,11 @@ const AccountApiClient = AtomHttpApi.Service<"AccountApiClient">()("AccountApiCl · 14 unmodified lines
25 if (authorization) {30 if (authorization) {
26 next = HttpClientRequest.setHeader(next, "authorization", authorization);31 next = HttpClientRequest.setHeader(next, "authorization", authorization);
27 }32 }
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 }
28 return next;38 return next;
29 }),39 }),
30 transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),40 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
11import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
12 12
13import { reportHandledFrontendError } from "./error-reporting";13import { reportHandledFrontendError } from "./error-reporting";
14import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection";14import {
15 EXECUTOR_ORG_HEADER,
16 getActiveOrgSlug,
17 getExecutorApiBaseUrl,
18 getExecutorServerAuthorizationHeader,
19} from "./server-connection";
15 20
16const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean =>21const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean =>
17 Option.match(Cause.findErrorOption(cause), {22 Option.match(Cause.findErrorOption(cause), {
@@ -95,6 +100,11 @@ const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorAp · 77 unmodified lines
95 if (authorization) {100 if (authorization) {
96 next = HttpClientRequest.setHeader(next, "authorization", authorization);101 next = HttpClientRequest.setHeader(next, "authorization", authorization);
97 }102 }
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 }
98 return next;108 return next;
99 }),109 }),
100 transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),110 transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
packages/react/src/api/server-connection.tsx+27−0
@@ -1,4 +1,5 @@
1import * as React from "react";1import * as React from "react";
2import { isValidOrgSlug } from "@executor-js/api";
2import {3import {
3 DEFAULT_EXECUTOR_SERVER_ORIGIN,4 DEFAULT_EXECUTOR_SERVER_ORIGIN,
4 DEFAULT_EXECUTOR_SERVER_USERNAME,5 DEFAULT_EXECUTOR_SERVER_USERNAME,
@@ -84,6 +85,32 @@ const resolveInitialExecutorServerConnection = (): ExecutorServerConnection => { · 79 unmodified lines
84 85
85let activeConnection = resolveInitialExecutorServerConnection();86let activeConnection = resolveInitialExecutorServerConnection();
86 87
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.
105export const EXECUTOR_ORG_HEADER = "x-executor-organization";
106
107export 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
87export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection;114export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection;
88 115
89export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => {116export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => {
packages/react/src/multiplayer/org-slug-gate.tsx+14−73
@@ -1,94 +1,39 @@
1import { useEffect, useRef, type ReactNode } from "react";1import { useEffect, type ReactNode } from "react";
2import { useNavigate, useParams } from "@tanstack/react-router";2import { useNavigate, useParams } from "@tanstack/react-router";
3 3
4// ---------------------------------------------------------------------------4// ---------------------------------------------------------------------------
5// Org-slug URL canonicalization for org-scoped hosts (cloud, self-host,5// Org-slug URL canonicalization for org-scoped hosts (cloud, self-host,
6// cloudflare). Console routes live under an optional `{-$orgSlug}` segment, so6// cloudflare). Console routes live under an optional `{-$orgSlug}` segment, so
7// the same tree serves `/policies` and `/acme/policies`; this gate, mounted7// the same tree serves `/policies` and `/acme/policies`; this gate, mounted
8// inside the authenticated shell, pins the URL to the ACTIVE organization's8// inside the authenticated shell, pins a BARE URL to the active org's slug:
9// slug:
10//9//
11// - bare URL → replace with `/<active-slug>/…` (canonicalize)10// - bare URL → replace with `/<active-slug>/…` (canonicalize)
12// - active slug in URL → render11// - slug already 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
18//12//
19// "Foreign" is judged against where the SESSION has been, not just the URL:13// The URL slug is the request SCOPE, not just a label: every API call carries
20// when the active slug changes while mounted (an org create/switch elsewhere14// it (the `x-executor-organization` header), and the server re-checks live
21// in the app re-resolves `/account/me`), the URL is the stale side and15// membership and resolves data for that org — same as the MCP URL-pinned org.
22// canonicalizes forward. Treating that as a foreign URL would switch the16// So a foreign slug never reaches this gate as "active": the server returns no
23// session straight back — racing the in-flight navigation and rotating the17// organization for an org the caller can't see, and the shell 404s upstream.
24// session cookie twice (WorkOS sealed-session refreshes are single-use, so18// That makes two browser tabs on different orgs fully independent — no shared
25// the loser of that race invalidates the winner).19// "active org" to steal.
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.
29// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
30 21
31export interface OrgSlugGateProps {22export interface OrgSlugGateProps {
32 /** The active organization's slug (from `useAuth().organization`). */23 /** The active organization's slug (from `useAuth().organization`). */
33 readonly activeSlug: string;24 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;
43 readonly children: ReactNode;25 readonly children: ReactNode;
44}26}
45 27
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 */
52export 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
72export function OrgSlugGate(props: OrgSlugGateProps) {28export function OrgSlugGate(props: OrgSlugGateProps) {
73 const { activeSlug, foreignSlug } = props;29 const { activeSlug } = props;
74 const params = useParams({ strict: false }) as { orgSlug?: string };30 const params = useParams({ strict: false }) as { orgSlug?: string };
75 const urlSlug = params.orgSlug ?? null;31 const urlSlug = params.orgSlug ?? null;
76 const navigate = useNavigate();32 const navigate = useNavigate();
77 33
78 // The active slug the URL last agreed with. While it differs from34 // Only a BARE URL canonicalizes. A slug in the URL is the scope the whole
79 // `activeSlug`, the session has moved out from under the URL (create/switch35 // request chain already ran with, so it always matches `activeSlug` here.
80 // elsewhere in the app) — that is never a foreign URL.36 const needsCanonicalize = urlSlug === null;
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;
92 37
93 useEffect(() => {38 useEffect(() => {
94 if (!needsCanonicalize) return;39 if (!needsCanonicalize) return;
@@ -104,10 +49,6 @@ export function OrgSlugGate(props: OrgSlugGateProps) { · 9 unmodified lines
104 });49 });
105 }, [needsCanonicalize, activeSlug, navigate]);50 }, [needsCanonicalize, activeSlug, navigate]);
106 51
107 if (isForeign) {
108 return <>{foreignSlug ? foreignSlug(urlSlug) : (props.notFound ?? <OrgSlugNotFound />)}</>;
109 }
110
111 // Render through while canonicalizing — the target is the same route, so52 // Render through while canonicalizing — the target is the same route, so
112 // withholding children would only flash the page.53 // withholding children would only flash the page.
113 return <>{props.children}</>;54 return <>{props.children}</>;