Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 17 additions & 22 deletions apps/cloud/src/auth/ssr-gate.ts → apps/cloud/src/auth/doc-gate.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -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://…/<org>/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);
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/auth/last-org-cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
//
Expand Down
51 changes: 11 additions & 40 deletions apps/cloud/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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 (
<PostHogProvider client={posthog}>
<AnalyticsProvider client={analyticsClient}>
<AuthProvider initialHint={authHint}>
<AuthGate ssrOrigin={origin} />
<AuthProvider>
<AuthGate />
</AuthProvider>
</AnalyticsProvider>
</PostHogProvider>
Expand Down Expand Up @@ -213,7 +194,7 @@ function ShellErrorFallback() {
);
}

function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
function AuthGate() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
Expand Down Expand Up @@ -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 <BlankScreen />;
}
Expand All @@ -286,12 +267,6 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
return urlOrgSlug ? <NotFoundPage /> : <BlankScreen />;
}

// 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 `/<slug>/mcp` install URL.
// Prefer the URL's slug over the session's: on first paint `auth.organization`
Expand All @@ -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. */}
<ExecutorProvider
connection={connection}
scopeKey={pathnameOrgSlug}
onHandledError={captureFrontendError}
>
<ExecutorProvider scopeKey={pathnameOrgSlug} onHandledError={captureFrontendError}>
<React.Suspense fallback={<BlankScreen />}>
<ExecutorPluginsProvider plugins={clientPlugins}>
<OrganizationProvider
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/routes/bare/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { safeReturnTo } from "../../auth/return-to";
import { LoginPage } from "../../web/pages/login";

// The signed-out landing page. The SSR auth gate (auth/ssr-gate.ts) sends
// The signed-out landing page. The document auth gate (auth/doc-gate.ts) sends
// signed-out document requests here with ?returnTo=<the path they wanted>,
// and bounces already-signed-in visitors straight back to it.
export const Route = createFileRoute("/login")({
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
10 changes: 10 additions & 0 deletions apps/cloud/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
],
Expand Down
55 changes: 55 additions & 0 deletions e2e/cloud/connect-card-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Cloud-specific: the integrations landing page's connect card prints an
// `npx add-mcp <url>` 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_<id> 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$/,
);
});
}),
);
79 changes: 0 additions & 79 deletions e2e/cloud/connect-card-ssr-origin.test.ts

This file was deleted.

13 changes: 10 additions & 3 deletions e2e/cloud/unauthenticated-skeleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading