From 0c63e6e9282ef39401dca7cffb2c39a3c70c6992 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:30:35 -0700 Subject: [PATCH] Render not-found for an org slug the session does not belong to --- .changeset/foreign-org-slug-not-found.md | 13 ++++ apps/cloud/src/routes/__root.tsx | 32 +++++++-- e2e/scenarios/org-slug-routing.test.ts | 72 +++++++++++++++---- .../react/src/multiplayer/org-slug-gate.tsx | 6 +- 4 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 .changeset/foreign-org-slug-not-found.md diff --git a/.changeset/foreign-org-slug-not-found.md b/.changeset/foreign-org-slug-not-found.md new file mode 100644 index 0000000000..9afa34fc44 --- /dev/null +++ b/.changeset/foreign-org-slug-not-found.md @@ -0,0 +1,13 @@ +--- +"executor": patch +--- + +**A console URL naming an organization you cannot see is a not-found page, every time** + +Opening `//policies` sometimes rendered the full authenticated console — sidebar, org switcher showing your OWN organization, page chrome — under an address naming an organization you are not a member of. The page body was a failing org-scoped query with a Retry button, so the workspace on screen belonged to nobody and the URL belonged to someone else. + +The shell's not-found only fired once `/account/me` had answered for the URL's slug. Until then the console read its identity from the auth-hint cookie, which always names the organization the session last landed in, never the one in the address bar. So the first paint answered a question about a different organization and built a whole workspace out of it, and whether you ever saw that depended on how fast the server replied. + +The shell is now built only from an answer that names the organization the URL names. A slug the current answer does not cover renders nothing at all until `/account/me` resolves for that slug, and then either the workspace or — for an organization this session cannot see — the not-found page. The URL is never rewritten: a wrong address stays a wrong address. + +The ordinary cold load is untouched. The hint names the slug already in the URL, so it matches on the very first paint and the shell renders with no round trip. Only a slug the hint does not name waits: a foreign one, and the single frame after switching organizations, which now paints the organization the URL asked for instead of briefly showing the previous one. diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index ab0eb54061..7f141e6072 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -262,15 +262,33 @@ function AuthGate() { return urlOrgSlug ? : ; } + // The authenticated answer must NAME the org the URL names before any shell + // is built from it. `auth.organization` is the auth-hint cookie until + // `/account/me` lands, and the hint always names the session's OWN org — so + // on a foreign slug it is an answer about a different organization, and + // rendering the shell from it puts the user in a workspace the URL never + // named. `/account/me` is scoped by the URL's slug (getActiveOrgSlug), so + // once it resolves this can only agree or be null; a disagreement is + // therefore always an unresolved answer, never a verdict. Blank, not + // not-found: the 404 above is the only thing entitled to declare a wrong + // address, and it waits for the server. + // + // The legitimate cold load is untouched: the hint names the slug in the URL, + // so this matches on the very first paint and the shell renders with no + // round trip. Only a slug the hint does not name pays the wait — a foreign + // slug (which then 404s) and the frame after an org switch (which then + // renders the org the URL asked for, instead of flashing the previous one). + if (pathnameOrgSlug != null && auth.organization.slug !== pathnameOrgSlug) { + return ; + } + const activeSlug = auth.organization.slug; // The org context's slug feeds the connect card's `//mcp` install URL. - // Prefer the URL's slug over the session's: on first paint `auth.organization` - // comes from the SSR auth-hint (the COOKIE's org), so a multi-org user viewing - // / while their cookie still points at orgA would briefly render orgA's - // slug in the copyable URL before /account/me (URL-scoped) corrects it. The - // URL slug is the actual request scope and is correct on the very first paint, - // so sourcing it from there removes that flash. VALIDATED (pathnameOrgSlug, - // not the raw route param): the `{-$orgSlug}` param also captures reserved + // Source it from the URL, which is the actual request scope and is correct on + // the very first paint. The gate above has already made the two agree + // whenever the URL names a slug at all, so this is the same value stated in + // the terms the rest of the tree is keyed on. VALIDATED (pathnameOrgSlug, not + // the raw route param): the `{-$orgSlug}` param also captures reserved // console roots ("/integrations" → orgSlug "integrations"), which are not // org scopes. Falls back to the auth org on a bare/reserved URL (which // OrgSlugGate canonicalizes onto it below). diff --git a/e2e/scenarios/org-slug-routing.test.ts b/e2e/scenarios/org-slug-routing.test.ts index 3ba129bee6..ca75bce372 100644 --- a/e2e/scenarios/org-slug-routing.test.ts +++ b/e2e/scenarios/org-slug-routing.test.ts @@ -5,9 +5,10 @@ // // - /account/me advertises the org's URL slug (valid grammar) // - a bare deep link (/policies) canonicalizes to //policies -// - an unknown slug (/zz-no-such-org/policies) is a wrong address — a -// not-found page, never a silent redirect into a workspace the URL -// didn't name +// - an unknown slug (/zz-no-such-org/policies) is a wrong address on a +// multi-tenant host — a not-found page with the console shell never built +// at any point, never a silent redirect into a workspace the URL didn't +// name (a single-tenant host canonicalizes it instead; both are pinned) // - in-shell navigation keeps the slug prefix on every link // // Cloud's switch-into-another-org-by-URL behavior is covered separately by @@ -20,6 +21,14 @@ import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import { visit } from "../src/surfaces/browser"; +declare global { + interface Window { + /** Set by this scenario's init script the first time a console shell + * sidebar is attached to the document. Test-only. */ + __executorShellEverMounted?: boolean; + } +} + scenario( "Org URLs · console paths carry the organization slug", {}, @@ -46,17 +55,54 @@ scenario( await page.getByText("Policies").first().waitFor(); }); - // The "unknown slug is a 404" contract is multi-tenant only. Selfhost is - // single-tenant: /account/me always returns the instance org regardless of - // the URL segment, so the slug is cosmetic and an unknown one canonicalizes - // onto the shell rather than 404ing. Cloud enforces the not-found; selfhost - // legitimately does not. - if (!target.name.startsWith("selfhost")) { - await step("An unknown org slug is a wrong address, not a redirect", async () => { - await visit(page, "/zz-no-such-org/policies"); + // What an unknown slug MEANS depends on tenancy, so both answers are + // pinned rather than one being skipped: + // + // multi-tenant (cloud) — the slug is a real request scope. /account/me + // returns no organization for one this session cannot see, so the + // address is simply wrong: a not-found page, the URL left exactly as + // typed, and no console shell built at any point along the way. + // single-tenant (selfhost) — /account/me always returns the instance + // org regardless of the URL segment, so the slug is cosmetic and + // OrgSlugGate canonicalizes it onto the shell instead. + const singleTenant = target.name.startsWith("selfhost"); + + // Waiting for the not-found text on its own is not enough. The shell used + // to render first from the auth-hint cookie — which names the session's + // OWN org, never the URL's — and correct itself only once /account/me + // answered, so a run that flashed an entire workspace under a foreign + // address still ended on "Page not found" and passed. Record the shell + // from before the page's own scripts run, so a sidebar that exists for a + // single frame is still caught long after it is gone. + await page.addInitScript(() => { + window.__executorShellEverMounted = false; + const look = () => { + if (document.querySelector('aside, button[aria-label="Open navigation"]')) { + window.__executorShellEverMounted = true; + } + }; + new MutationObserver(look).observe(document, { childList: true, subtree: true }); + look(); + }); + + await step("An unknown org slug resolves by tenancy, never by redirect", async () => { + await visit(page, "/zz-no-such-org/policies"); + if (singleTenant) { + await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { timeout: 30_000 }); + } else { await page.getByText("Page not found").waitFor({ timeout: 30_000 }); - }); - } + } + + const landed = { + pathname: new URL(page.url()).pathname, + shellEverMounted: await page.evaluate(() => window.__executorShellEverMounted === true), + }; + expect(landed, "an unknown slug never lands in a workspace the URL did not name").toEqual( + singleTenant + ? { pathname: `/${slug}/policies`, shellEverMounted: true } + : { pathname: "/zz-no-such-org/policies", shellEverMounted: false }, + ); + }); await step("In-shell navigation keeps the slug prefix", async () => { await visit(page, `/${slug}`); diff --git a/packages/react/src/multiplayer/org-slug-gate.tsx b/packages/react/src/multiplayer/org-slug-gate.tsx index 05b04d65cf..50d0707426 100644 --- a/packages/react/src/multiplayer/org-slug-gate.tsx +++ b/packages/react/src/multiplayer/org-slug-gate.tsx @@ -16,7 +16,11 @@ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; // membership and resolves data for that org — same as the MCP URL-pinned org. // So a foreign slug never reaches this gate as "active" on a multi-org host: // the server returns no organization for an org the caller can't see, and the -// shell 404s upstream. That makes two browser tabs on different orgs fully +// shell 404s upstream. The host is what enforces that — it must not build this +// subtree until the authenticated answer NAMES the slug in the URL, or an +// optimistic answer about the session's own org (cloud's auth-hint cookie) +// would mount the shell under a foreign address before the server ever +// replies. That makes two browser tabs on different orgs fully // independent — no shared "active org" to steal. On a single-org host (e.g. // self-host) every slug resolves to the same org server-side, so a bogus slug // (e.g. `/totally-bogus`) would otherwise fuzzy-match a route and render