diff --git a/apps/cloud/src/auth/ssr-gate.ts b/apps/cloud/src/auth/doc-gate.ts similarity index 89% rename from apps/cloud/src/auth/ssr-gate.ts rename to apps/cloud/src/auth/doc-gate.ts index a85f9ffe1e..16d6145bc9 100644 --- a/apps/cloud/src/auth/ssr-gate.ts +++ b/apps/cloud/src/auth/doc-gate.ts @@ -1,19 +1,23 @@ // --------------------------------------------------------------------------- -// SSR auth gate — the server-side session check for DOCUMENT requests. +// Document auth gate — the server-side session check for DOCUMENT requests. // // The sealed `wos-session` cookie is verified right here in the worker // (unseal + JWT check against cached JWKS — no per-request WorkOS round trip -// except token refresh), so by the time the SPA is served the server KNOWS -// who it's serving: +// except token refresh), so by the time the SPA shell is served the server +// KNOWS who it's serving: // // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists // - org-less → 302 /create-org (onboarding owns those sessions) -// - signed in → the document is served WITH the verified identity: the -// auth-hint travels to the SSR render via request-middleware context (the -// root loader picks it up), and is minted as a cookie when the browser -// doesn't hold a current one — so the very first paint is the real app -// shell, never a skeleton. The hint is display-only; /account/me remains -// the authority and the client keeps it fresh from then on. +// - signed in → the prerendered shell is served, and the auth-hint cookie is +// minted when the browser doesn't hold a current one — the client seeds its +// auth state from that cookie right after mount (there is no per-request +// render to dehydrate it into). The hint is display-only; /account/me +// remains the authority and the client keeps it fresh from then on. +// +// Beyond redirects, this gate is load-bearing for SESSION LIFETIME: WorkOS +// refresh tokens are single-use, and a verify that rotates the sealed session +// must deliver the new cookie to the browser or the next expiry logs the user +// out. Document navigations are where that rotation lands. // // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest / // accept), excluding app-owned paths (/api, /mcp — they answer for themselves @@ -39,7 +43,6 @@ import { parseCookie } from "./cookies"; import { LAST_ORG_COOKIE } from "./last-org-cookie"; import { sealedSessionDisplayName } from "./middleware"; import { authorizeOrganizationSelector } from "./organization"; -import { browserOriginFromRequest } from "./request-origin"; import { loginPath, safeReturnTo } from "./return-to"; import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; import { WorkOSClient } from "./workos"; @@ -279,19 +282,11 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( } } - // Serve the document WITH the verified identity: the hint rides to the - // SSR render through middleware context (the root loader reads it), so - // the server paints the real authenticated shell — no loading state, no - // skeleton. The request origin rides along too: it's what the connect - // card's MCP URL is built from, and the server knows it (the SPA only - // learns `window.location.origin` after mount), so passing it here lets - // SSR render the real `https://…//mcp` instead of the client-side - // `http://127.0.0.1:4000` default — which would otherwise flash until - // hydration corrected it. Set-cookie writes ride on the rendered response. + // Serve the shell, minting the auth-hint cookie when the browser lacks a + // current one so the client's post-mount cookie read seeds the verified + // identity. Set-cookie writes ride on the shell response. const { hint, mint } = await resolveAuthHint(session, cookieHeader); - const result = await next({ - context: { authHint: hint, origin: browserOriginFromRequest(request) }, - }); + const result = await next(); if (!mint && !session.refreshedSession) return result; const response = new Response(result.response.body, result.response); diff --git a/apps/cloud/src/auth/last-org-cookie.ts b/apps/cloud/src/auth/last-org-cookie.ts index dde7c92848..ef6242604f 100644 --- a/apps/cloud/src/auth/last-org-cookie.ts +++ b/apps/cloud/src/auth/last-org-cookie.ts @@ -8,7 +8,7 @@ // cookie fills the gap: the client records the slug of the org it's verifiably // viewing, and the two bare-entry deciders honor it — // -// - the SSR auth gate redirects bare document paths onto it (ssr-gate.ts) +// - the document auth gate redirects bare document paths onto it (doc-gate.ts) // - the login callback prefers it when picking the org for a fresh session // with a bare returnTo (handlers.ts) // diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 474eb8045c..517d53b0b1 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -23,7 +23,6 @@ import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; -import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; import { AuthProvider, useAuth } from "../web/auth"; import { loginPath } from "../auth/return-to"; import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths"; @@ -111,27 +110,6 @@ function NotFoundPage() { export const Route = createRootRoute({ notFoundComponent: NotFoundPage, - // What the SSR gate attached to this document request (ssr-gate.ts → - // middleware context → serverContext). Loader data is dehydrated, so the - // client's first render sees the SAME values the server rendered with — the - // two can't disagree: - // - authHint: the verified identity, seeding AuthProvider's initial state. - // - origin: the request origin, seeding the server connection so the - // connect-card MCP URL SSRs as the real origin instead of the - // 127.0.0.1 client-side default (which would flash to the real - // value at hydration). - // Client-side re-runs have no serverContext and return null; both consumers - // fall back gracefully (the hint is already held, the origin to the - // window-derived global). - loader: (opts) => { - const serverContext = ( - opts as { serverContext?: { authHint?: AuthHint | null; origin?: string } } - ).serverContext; - return { - authHint: serverContext?.authHint ?? null, - origin: serverContext?.origin ?? null, - }; - }, head: () => ({ meta: [ { charSet: "utf-8" }, @@ -171,12 +149,15 @@ function RootDocument({ children }: { children: React.ReactNode }) { } function RootComponent() { - const { authHint, origin } = Route.useLoaderData(); + // SPA mode: no per-request server render, so nothing is dehydrated. Auth + // seeds from the client-readable hint cookie one frame after mount + // (AuthProvider's own fallback), and origin-derived UI reads the + // window-derived global. return ( - - + + @@ -213,7 +194,7 @@ function ShellErrorFallback() { ); } -function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { +function AuthGate() { const auth = useAuth(); const location = useLocation(); const navigate = useNavigate(); @@ -265,11 +246,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { } // Every state that isn't "authenticated with an org, on a page that wants - // the shell" is a moment between redirects or an edge the gates make - // near-impossible (a verified user whose hint hasn't seeded yet). Neutral + // the shell" is a moment between redirects, or the one frame between mount + // and the hint cookie seeding (SPA mode reads it in an effect). Neutral // blank — the one placeholder that's correct whatever happens next. The // app-shell skeleton this file used to render here is exactly the - // wrong-UI flash the SSR gate + hint exist to prevent. + // wrong-UI flash the document gate + hint exist to prevent. if (auth.status === "loading" || auth.status === "unauthenticated") { return ; } @@ -286,12 +267,6 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { return urlOrgSlug ? : ; } - // Seed the server connection from the SSR origin so origin-derived UI (the - // connect card's MCP URL) renders the real host on the first paint instead - // of the 127.0.0.1 default the client-side global falls back to during SSR. - // Null on client loader re-runs → undefined → the window-derived global, - // which is the same origin, so the key never changes and nothing remounts. - const connection = ssrOrigin ? ({ kind: "http", origin: ssrOrigin } as const) : undefined; 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` @@ -315,11 +290,7 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { canonicalization remounts the registry so anything fetched header-less on first paint (rejected server-side) is refetched with the org header. */} - + }> , // and bounces already-signed-in visitors straight back to it. export const Route = createFileRoute("/login")({ diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index ebdf8256c6..aaadc1a521 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -2,7 +2,7 @@ import { createMiddleware, createStart } from "@tanstack/react-start"; import { decodeOAuthCallbackState } from "@executor-js/sdk/shared"; import { isAppOwnedPath } from "./app-paths"; -import { authGateMiddleware } from "./auth/ssr-gate"; +import { authGateMiddleware } from "./auth/doc-gate"; import { parseCookie } from "./auth/cookies"; import { ORG_SELECTOR_HEADER } from "./auth/organization"; import { loginPath } from "./auth/return-to"; diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index ec565da2f2..308d5d60d8 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -142,6 +142,16 @@ export default defineConfig(({ command, mode }) => { router: { virtualRouteConfig: routes, }, + // SPA mode: the console is 100% authenticated UI (marketing is its own + // Astro app, docs are a proxy), so nothing needs per-request React + // SSR. The shell is prerendered once at build; document requests still + // run the request-middleware chain (doc-gate auth redirects + session + // cookie rotation) but serve that static shell, which drops the whole + // React app from the worker bundle and takes per-request render cost + // to zero. + spa: { + enabled: true, + }, }), react(), ], diff --git a/e2e/cloud/connect-card-origin.test.ts b/e2e/cloud/connect-card-origin.test.ts new file mode 100644 index 0000000000..da1e80f57b --- /dev/null +++ b/e2e/cloud/connect-card-origin.test.ts @@ -0,0 +1,55 @@ +// Cloud-specific: the integrations landing page's connect card prints an +// `npx add-mcp ` install command — the first thing a new user is told to +// copy. That URL's origin must be the host actually serving the console, never +// the desktop/CLI fallback `http://127.0.0.1:4000` the server-connection +// module defaults to when no origin is known. +// +// Under SPA serving there is no per-request document render: the shell is a +// static asset and the card renders client-side, deriving its origin from +// `window.location.origin`. This scenario pins the rendered card (the DOM the +// user actually copies from) to the serving host, against the real WorkOS +// emulator session. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +scenario( + "Connect card · the rendered install command uses the real host, never the 127.0.0.1 default", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + const expectedOrigin = new URL(target.baseUrl).origin; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + let commandText = ""; + await step("Open the console and read the connect card's command", async () => { + await page.goto("/", { waitUntil: "commit" }); + const command = page.getByText(/add-mcp\s+https?:\/\//).first(); + await command.waitFor({ timeout: 30_000 }); + commandText = (await command.textContent()) ?? ""; + }); + + const endpoint = /add-mcp\s+(https?:\/\/\S+\/mcp)/.exec(commandText)?.[1] ?? null; + expect(endpoint, "the connect card renders an install command").not.toBeNull(); + + expect(new URL(endpoint!).origin, "the install URL uses the real serving origin").toBe( + expectedOrigin, + ); + expect(endpoint!, "…and not the desktop/CLI default").not.toContain("127.0.0.1:4000"); + // It's still the org-scoped path the user actually needs. Since #974 + // ("Org-slug console URLs across cloud, self-host, and cloudflare + // hosts"), the install card prints the org's URL SLUG (e.g. + // /org-user-xxx/mcp), not the legacy WorkOS org_ form — + // mount.ts's classifyMcpPath still accepts either shape, but the slug + // form is what ships, so accept both rather than pinning on the + // retired id-only shape. + expect(endpoint!, "the install URL stays org-scoped").toMatch( + /\/(?:org_[^/]+|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\/mcp$/, + ); + }); + }), +); diff --git a/e2e/cloud/connect-card-ssr-origin.test.ts b/e2e/cloud/connect-card-ssr-origin.test.ts deleted file mode 100644 index c8ffd4725f..0000000000 --- a/e2e/cloud/connect-card-ssr-origin.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Cloud-specific: now that the gate renders the REAL shell during SSR (no -// skeleton), the integrations landing page's connect card is part of the very -// first painted HTML — including its `npx add-mcp ` install command. That -// URL is built from the server connection's origin, and the SPA only learns -// `window.location.origin` after it mounts; the SSR default is the desktop/CLI -// fallback `http://127.0.0.1:4000`. If SSR rendered with that default, the -// command would paint `127.0.0.1:4000` and then flip to the real host at -// hydration — a visible flash of the wrong URL on the first thing the user is -// told to copy. -// -// The gate now threads the request origin to the render, so the command is -// SSR'd against the REAL host from the first byte. These scenarios pin that on -// the raw document (pre-JS), where a flash would be observable, against the -// real WorkOS emulator session. -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; -import { Api, Target } from "../src/services"; - -/** A document navigation request — what the SSR gate keys on. */ -const documentRequest = (url: URL, cookie: string) => - Effect.promise(() => - fetch(url, { redirect: "manual", headers: { accept: "text/html", cookie } }), - ); - -/** - * The install command is rendered inside a
 (shiki's grammar isn't loaded
- * during SSR, so the code block falls back to plain text). Stripping tags
- * reassembles the literal command even if hydration would later tokenize it
- * into colored spans.
- */
-const installEndpointFromHtml = (html: string): string | null => {
-  const text = html.replace(/<[^>]+>/g, " ");
-  return /add-mcp\s+(https?:\/\/\S+\/mcp)/.exec(text)?.[1] ?? null;
-};
-
-scenario(
-  "Connect card · the install command SSRs against the real host, never the 127.0.0.1 default",
-  {},
-  Effect.gen(function* () {
-    // Gate: the REST API plane is mounted on this target.
-    yield* Api;
-    const target = yield* Target;
-    const expectedOrigin = new URL(target.baseUrl).origin;
-
-    const identity = yield* target.newIdentity();
-    const response = yield* documentRequest(
-      new URL("/", target.baseUrl),
-      identity.headers!.cookie!,
-    );
-    expect(response.status, "the authenticated landing page is served").toBe(200);
-
-    const html = yield* Effect.promise(() => response.text());
-    // The card is part of the first paint (the whole point of deleting the
-    // skeleton) — if it weren't SSR'd, there'd be no flash to fix and this
-    // guard would be vacuous, so assert it's actually there.
-    const endpoint = installEndpointFromHtml(html);
-    expect(endpoint, "the connect card's install command is in the SSR'd HTML").not.toBeNull();
-
-    // The fix: the command's origin is the host that served the document, not
-    // the client-side fallback the SPA would otherwise paint before mounting.
-    expect(new URL(endpoint!).origin, "the install URL uses the real serving origin").toBe(
-      expectedOrigin,
-    );
-    expect(endpoint!, "…and not the desktop/CLI default that used to flash").not.toContain(
-      "127.0.0.1:4000",
-    );
-    // It's still the org-scoped path the user actually needs. Since #974
-    // ("Org-slug console URLs across cloud, self-host, and cloudflare hosts"),
-    // the install card prints the org's URL SLUG (e.g. /org-user-xxx/mcp), not
-    // the legacy WorkOS org_ form — mount.ts's classifyMcpPath still
-    // accepts either shape, but the slug form is what ships, so accept both
-    // rather than pinning on the retired id-only shape.
-    expect(endpoint!, "the install URL stays org-scoped").toMatch(
-      /\/(?:org_[^/]+|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\/mcp$/,
-    );
-  }),
-);
diff --git a/e2e/cloud/unauthenticated-skeleton.test.ts b/e2e/cloud/unauthenticated-skeleton.test.ts
index 5898bb7e70..277729797e 100644
--- a/e2e/cloud/unauthenticated-skeleton.test.ts
+++ b/e2e/cloud/unauthenticated-skeleton.test.ts
@@ -4,8 +4,8 @@
 // AuthGate used to SSR the AUTHENTICATED app-shell skeleton (sidebar + card
 // grid) for every visitor and only swap to a login page after a client-side
 // `/account/me` 401 — signed-out users were shown an app they'd never reach.
-// Now the SSR auth gate (apps/cloud/src/auth/ssr-gate.ts) verifies the sealed
-// session cookie in the worker and 302s signed-out document requests to
+// Now the document auth gate (apps/cloud/src/auth/doc-gate.ts) verifies the
+// sealed session cookie in the worker and 302s signed-out document requests to
 // /login (carrying ?returnTo=), so the app shell never exists for them.
 import { expect } from "@effect/vitest";
 import { Effect } from "effect";
@@ -136,7 +136,14 @@ scenario(
         await page.goto("/", { waitUntil: "commit" });
         await page.locator("aside").first().waitFor({ state: "visible" });
       });
-      expect(new URL(page.url()).pathname, "no login detour for a valid session").toBe("/");
+      // The contract is "no login detour": under SPA serving the shell becomes
+      // visible only after auth resolves, by which point OrgSlugGate may have
+      // already canonicalized the bare root onto the org's slug (that landing
+      // is pinned by auth-routing-flow). Either resting place is signed-in
+      // routing working; /login is the only wrong answer.
+      expect(new URL(page.url()).pathname, "no login detour for a valid session").not.toBe(
+        "/login",
+      );
     });
 
     // A signed-in visitor landing on /login is bounced back into the app.
diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts
index 799c1cfa47..851ab85cc4 100644
--- a/e2e/setup/cloud.boot.ts
+++ b/e2e/setup/cloud.boot.ts
@@ -56,6 +56,18 @@ export const bootCloud = async (options: CloudBootOptions): Promise
   const dbPath = resolve(cloudDir, ".e2e-stub-db");
   if (options.fresh ?? true) rmSync(dbPath, { recursive: true, force: true });
 
+  // Fresh worker state per boot, for the same reason as the DB: miniflare
+  // persists the Workers Cache API under .wrangler/state across dev-server
+  // runs, and the JWKS L2 cache (auth/jwks-cache.ts) lives there keyed by
+  // URL. The WorkOS emulator binds this checkout's stable port block, so run
+  // N+1's dev server serves run N's cached JWKS (stale-while-revalidate)
+  // against a fresh emulator's keys — every session verify then fails
+  // signature, falls into single-use refresh, and concurrent requests race
+  // each other's rotation into 401s. One run poisons the next.
+  if (options.fresh ?? true) {
+    rmSync(resolve(cloudDir, ".wrangler", "state"), { recursive: true, force: true });
+  }
+
   // MCP access tokens minted by the emulator's OAuth server must carry the
   // app's client id as audience (what the resource server verifies).
   process.env.EMULATE_WORKOS_AUDIENCE = options.workosClientId;