History

Render the real shell from the gate's verified identity — delete the full-page skeleton

The SSR gate already unseals the session per document request, so the loading silhouette was rendering 'we don't know who you are' on requests where the server knew exactly who it was serving. Now: - the gate resolves the auth hint server-side (browser cookie when it matches the verified ids, else minted fresh — org name from the local mirror) and threads it to the SSR render through middleware context; the root loader dehydrates it so the first client render agrees by construction - AuthProvider takes an initialHint: SSR paints the authenticated shell outright; hosts without the seam (self-host) keep the post-mount cookie read - org-less sessions are redirected to /create-org at the edge, so the client AuthGate's org branch only covers mid-session transitions - ShellSkeleton is deleted; the remaining placeholder is a neutral blank screen for moments between redirects The first-ever load on a fresh browser now paints the real shell with the user's identity before /account/me resolves — previously the one case that still sat on a skeleton for the whole round trip.

Rhys Sullivan committed 5 months ago

Commit 0c81edb1 parent 2d865819 files · +295 −151Browse files

Unified · Split

9 files
  1. apps/cloud/src/auth/route-paths.ts +10 −0
  2. apps/cloud/src/auth/ssr-gate.ts +128 −18
  3. apps/cloud/src/routes/__root.tsx +33 −83
  4. apps/cloud/src/web/auth.tsx +13 −2
  5. e2e/cloud/auth-hint.test.ts +36 −35
  6. e2e/cloud/session-gate.test.ts +28 −0
  7. e2e/cloud/unauthenticated-skeleton.test.ts +10 −3
  8. packages/react/src/multiplayer/auth-context.tsx +34 −8
  9. packages/react/src/multiplayer/auth-hint.tsx +3 −2
apps/cloud/src/auth/route-paths.ts+10−0
@@ -0,0 +1,10 @@
1+// Route sets shared between the SSR auth gate (server) and the root
2+// AuthGate (client) — one source of truth so the two layers can't disagree
3+// about which pages a given auth state may see. Pure data, safe in both
4+// bundles.
5+
6+/** Pages that render for SIGNED-OUT visitors (the gate lets them through). */
7+export const PUBLIC_PATHS = new Set(["/login"]);
8+
9+/** Pages an authenticated-but-org-less user is FOR (everything else redirects to onboarding). */
10+export const ONBOARDING_PATHS = new Set(["/create-org", "/setup-mcp"]);
apps/cloud/src/auth/ssr-gate.ts+128−18
@@ -1,13 +1,19 @@
11 // ---------------------------------------------------------------------------
22 // SSR auth gate — the server-side session check for DOCUMENT requests.
33 //
4−// Before this gate, every signed-out visitor was served the SPA, whose root
5−// AuthGate SSRs the AUTHENTICATED app-shell skeleton until a client-side
6−// `/account/me` round trip 401s — the "bad skeleton on unauthed state" flash.
7−// The sealed `wos-session` cookie can be verified right here in the worker
4+// The sealed `wos-session` cookie is verified right here in the worker
85 // (unseal + JWT check against cached JWKS — no per-request WorkOS round trip
9−// except token refresh), so signed-out visitors are 302'd to /login before any
10−// app HTML exists, and signed-in visitors proceed knowing the session is real.
6+// except token refresh), so by the time the SPA is served the server KNOWS
7+// who it's serving:
8+//
9+// - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists
10+// - org-less → 302 /create-org (onboarding owns those sessions)
11+// - signed in → the document is served WITH the verified identity: the
12+// auth-hint travels to the SSR render via request-middleware context (the
13+// root loader picks it up), and is minted as a cookie when the browser
14+// doesn't hold a current one — so the very first paint is the real app
15+// shell, never a skeleton. The hint is display-only; /account/me remains
16+// the authority and the client keeps it fresh from then on.
1117 //
1218 // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /
1319 // accept), excluding app-owned paths (/api, /mcp — they answer for themselves
@@ -15,19 +21,31 @@ · 1 unmodified line
1521 // ---------------------------------------------------------------------------
1622
1723 import { createMiddleware } from "@tanstack/react-start";
18−import { Effect, Exit, ManagedRuntime } from "effect";
24+import { Effect, Exit, Layer, ManagedRuntime } from "effect";
1925
20−import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";
26+import {
27+ AUTH_HINT_COOKIE,
28+ AUTH_HINT_MAX_AGE_SECONDS,
29+ decodeAuthHint,
30+ encodeAuthHint,
31+ type AuthHint,
32+} from "@executor-js/react/multiplayer/auth-hint";
2133
2234 import { isAppOwnedPath } from "../app-paths";
35+import { makeDbLayer } from "../db/db";
36+import { makeUserStoreLayer, UserStoreService } from "./context";
2337 import { parseCookie } from "./cookies";
38+import { sealedSessionDisplayName } from "./middleware";
2439 import { loginPath, safeReturnTo } from "./return-to";
40+import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
2541 import { WorkOSClient } from "./workos";
2642
2743 const SESSION_COOKIE = "wos-session";
2844 /** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */
2945 const SESSION_COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";
3046 const SESSION_MAX_AGE = 60 * 60 * 24 * 7;
47+/** Same attributes the client write uses — minus HttpOnly: the SPA reads it. */
48+const HINT_COOKIE_ATTRIBUTES = "Path=/; Secure; SameSite=Lax";
3149
3250 const isDocumentRequest = (request: Request): boolean => {
3351 if (request.method !== "GET" && request.method !== "HEAD") return false;
@@ -48,7 +66,14 @@ const isDocumentRequest = (request: Request): boolean => { · 14 unmodified lines
4866 let runtime: ManagedRuntime.ManagedRuntime<WorkOSClient, unknown> | undefined;
4967 const getRuntime = () => (runtime ??= ManagedRuntime.make(WorkOSClient.Default));
5068
51−type VerifiedSession = { readonly refreshedSession?: string | undefined };
69+type VerifiedSession = {
70+ readonly userId: string;
71+ readonly email: string;
72+ readonly name: string | null;
73+ readonly avatarUrl: string | null;
74+ readonly organizationId: string | null;
75+ readonly refreshedSession?: string | undefined;
76+};
5277
5378 // EVERY failure collapses to "signed out" — WorkOS errors inside the effect
5479 // and layer-construction errors like a bad cookie password (runPromiseExit
@@ -58,9 +83,75 @@ const verifySession = async (sealed: string): Promise<VerifiedSession | null> => · 3 unmodified lines
5883 const exit = await getRuntime().runPromiseExit(
5984 Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.authenticateSealedSession(sealed)),
6085 );
61− return Exit.isSuccess(exit) ? exit.value : null;
86+ if (!Exit.isSuccess(exit) || exit.value === null) return null;
87+ const result = exit.value;
88+ return {
89+ userId: result.userId,
90+ email: result.email,
91+ name: sealedSessionDisplayName(result),
92+ avatarUrl: result.avatarUrl ?? null,
93+ organizationId: result.organizationId ?? null,
94+ refreshedSession: result.refreshedSession,
95+ };
6296 };
6397
98+// ── Auth hint ────────────────────────────────────────────────────────────────
99+
100+/**
101+ * The hint this request should be served with: the browser's own cookie when
102+ * it already matches the verified identity, else one minted fresh from the
103+ * session. `mint` is set when the cookie must also be (re)written — identity
104+ * data freshness (a renamed user/org) is the CLIENT's job via /account/me,
105+ * so the gate only steps in when the ids are wrong, never to rewrite display
106+ * fields (which would ping-pong with the client's authoritative write).
107+ */
108+const resolveAuthHint = async (
109+ session: VerifiedSession,
110+ cookieHeader: string | null,
111+): Promise<{ hint: AuthHint; mint: boolean }> => {
112+ const existing = decodeAuthHint(parseCookie(cookieHeader, AUTH_HINT_COOKIE));
113+ if (
114+ existing &&
115+ existing.user.id === session.userId &&
116+ (existing.organization?.id ?? null) === session.organizationId
117+ ) {
118+ return { hint: existing, mint: false };
119+ }
120+ return {
121+ hint: {
122+ v: 1,
123+ user: {
124+ id: session.userId,
125+ email: session.email,
126+ name: session.name,
127+ avatarUrl: session.avatarUrl,
128+ },
129+ organization: session.organizationId
130+ ? { id: session.organizationId, name: await organizationName(session.organizationId) }
131+ : null,
132+ },
133+ mint: true,
134+ };
135+};
136+
137+// The sealed session carries the org ID but not its name; the local mirror
138+// has it. Only consulted when minting (absent/mismatched hint) — never on the
139+// steady-state path — and over per-request layers, because a connection cached
140+// in the shared runtime would be reused across requests, which Cloudflare
141+// forbids. A miss or failure reads as "" — display-only, corrected by the
142+// client's /account/me write.
143+const organizationName = async (organizationId: string): Promise<string> => {
144+ const exit = await getRuntime().runPromiseExit(
145+ Effect.flatMap(UserStoreService.asEffect(), (users) =>
146+ users.use((store) => store.getOrganization(organizationId)),
147+ ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),
148+ );
149+ return Exit.isSuccess(exit) ? (exit.value?.name ?? "") : "";
150+};
151+
152+const hintSetCookie = (hint: AuthHint) =>
153+ `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;
154+
64155 const sessionSetCookie = (sealed: string) =>
65156 `${SESSION_COOKIE}=${sealed}; ${SESSION_COOKIE_ATTRIBUTES}; Max-Age=${SESSION_MAX_AGE}`;
66157
@@ -88,12 +179,13 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 21 unmodified lines
88179 async ({ pathname, request, next }) => {
89180 if (isAppOwnedPath(pathname) || !isDocumentRequest(request)) return next();
90181
91− const sealed = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
182+ const cookieHeader = request.headers.get("cookie");
183+ const sealed = parseCookie(cookieHeader, SESSION_COOKIE);
92184 const url = new URL(request.url);
93185
94− // /login is the one page signed-out visitors are FOR; a signed-in visitor
95− // landing here is bounced straight back to where they were headed.
96− if (pathname === "/login") {
186+ // Public pages are what signed-out visitors are FOR; a signed-in visitor
187+ // landing on /login is bounced straight back to where they were headed.
188+ if (PUBLIC_PATHS.has(pathname)) {
97189 const session = sealed ? await verifySession(sealed) : null;
98190 if (!session) return next();
99191 return redirect(safeReturnTo(url.searchParams.get("returnTo")) ?? "/", {
@@ -118,14 +210,32 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 18 unmodified lines
118210 return redirect("/", { refreshedSession: session.refreshedSession });
119211 }
120212
121− const result = await next();
213+ // A session with no organization belongs in onboarding — same decision
214+ // the client AuthGate makes mid-session, made here before the document
215+ // exists so the app shell is never painted for an org-less session.
216+ if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) {
217+ return redirect("/create-org", { refreshedSession: session.refreshedSession });
218+ }
219+
220+ // Serve the document WITH the verified identity: the hint rides to the
221+ // SSR render through middleware context (the root loader reads it), so
222+ // the server paints the real authenticated shell — no loading state, no
223+ // skeleton. Set-cookie writes ride on the rendered response.
224+ const { hint, mint } = await resolveAuthHint(session, cookieHeader);
225+ const result = await next({ context: { authHint: hint } });
226+ if (!mint && !session.refreshedSession) return result;
227+
228+ const response = new Response(result.response.body, result.response);
229+ if (mint) {
230+ // The browser holds no current hint — mint one so the NEXT load (and
231+ // any client-side read) sees the same identity this render used.
232+ response.headers.append("set-cookie", hintSetCookie(hint));
233+ }
122234 if (session.refreshedSession) {
123235 // WorkOS refresh tokens are single-use: the rotated sealed session MUST
124236 // reach the browser or the next expiry logs the user out.
125− const response = new Response(result.response.body, result.response);
126237 response.headers.append("set-cookie", sessionSetCookie(session.refreshedSession));
127− return response;
128238 }
129− return result;
239+ return { ...result, response };
130240 },
131241 );
apps/cloud/src/routes/__root.tsx+33−83
@@ -14,22 +14,17 @@ import { PostHogProvider } from "posthog-js/react"; · 13 unmodified lines
1414 import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
1515 import { ExecutorProvider } from "@executor-js/react/api/provider";
1616 import { OrganizationProvider } from "@executor-js/react/api/organization-context";
17−import { Skeleton } from "@executor-js/react/components/skeleton";
1817 import { Toaster } from "@executor-js/react/components/sonner";
1918 import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
2019 import { plugins as clientPlugins } from "virtual:executor/plugins-client";
20+import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint";
2121 import { AuthProvider, useAuth } from "../web/auth";
2222 import { loginPath } from "../auth/return-to";
23+import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";
2324 import { SupportOptions } from "../web/components/support-options";
2425 import { Shell } from "../web/shell";
2526 import appCss from "@executor-js/react/globals.css?url";
2627
27−const ONBOARDING_PATHS = new Set(["/create-org", "/setup-mcp"]);
28−// Routes that render for SIGNED-OUT visitors — the auth gate stays out of
29−// their way entirely (the SSR gate in auth/ssr-gate.ts bounces signed-in
30−// visitors off /login before the document is even served).
31−const PUBLIC_PATHS = new Set(["/login"]);
32−
3328 if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) {
3429 Sentry.init({
3530 dsn: import.meta.env.VITE_PUBLIC_SENTRY_DSN,
@@ -98,6 +93,16 @@ function NotFoundPage() { · 62 unmodified lines
9893
9994 export const Route = createRootRoute({
10095 notFoundComponent: NotFoundPage,
96+ // The verified identity the SSR gate attached to this document request
97+ // (ssr-gate.ts → middleware context → serverContext). Loader data is
98+ // dehydrated, so the client's first render sees the SAME hint the server
99+ // rendered with — the two can't disagree. Client-side re-runs have no
100+ // serverContext and return null, which is fine: the hint only seeds
101+ // initial state (AuthProvider holds it from there).
102+ loader: (opts) => ({
103+ authHint:
104+ (opts as { serverContext?: { authHint?: AuthHint | null } }).serverContext?.authHint ?? null,
105+ }),
101106 head: () => ({
102107 meta: [
103108 { charSet: "utf-8" },
@@ -137,74 +142,22 @@ function RootDocument({ children }: { children: React.ReactNode }) { · 33 unmodified lines
137142 }
138143
139144 function RootComponent() {
145+ const { authHint } = Route.useLoaderData();
140146 return (
141147 <PostHogProvider client={posthog}>
142− <AuthProvider>
148+ <AuthProvider initialHint={authHint}>
143149 <AuthGate />
144150 </AuthProvider>
145151 </PostHogProvider>
146152 );
147153 }
148154
149−function ShellSkeleton() {
150− return (
151− <div className="flex h-screen overflow-hidden">
152− {/* Desktop sidebar skeleton */}
153− <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
154− <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
155− <Skeleton className="h-4 w-20" />
156− </div>
157− <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
158− <Skeleton className="h-7 w-full rounded-md" />
159− <Skeleton className="h-7 w-full rounded-md" />
160− <Skeleton className="h-7 w-full rounded-md" />
161− <Skeleton className="h-7 w-full rounded-md" />
162− <div className="mt-5 mb-2 px-2.5">
163− <Skeleton className="h-3 w-14" />
164− </div>
165− <div className="flex flex-col gap-1">
166− <Skeleton className="h-7 w-11/12 rounded-md" />
167− <Skeleton className="h-7 w-10/12 rounded-md" />
168− <Skeleton className="h-7 w-9/12 rounded-md" />
169− </div>
170− </nav>
171− <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
172− <div className="flex items-center gap-2.5">
173− <Skeleton className="size-7 rounded-full" />
174− <div className="flex min-w-0 flex-1 flex-col gap-1">
175− <Skeleton className="h-3 w-24" />
176− <Skeleton className="h-3 w-16" />
177− </div>
178− </div>
179− </div>
180− </aside>
181−
182− {/* Main content skeleton */}
183− <main className="flex min-h-0 flex-1 flex-col overflow-hidden">
184− {/* Mobile top bar */}
185− <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
186− <Skeleton className="size-7 rounded-md" />
187− <Skeleton className="h-4 w-20" />
188− <div className="w-7 shrink-0" />
189− </div>
190−
191− <div className="flex min-h-0 flex-1 flex-col gap-6 px-6 py-8">
192− <div className="flex items-center justify-between">
193− <div className="flex flex-col gap-2">
194− <Skeleton className="h-6 w-40" />
195− <Skeleton className="h-4 w-64" />
196− </div>
197− <Skeleton className="h-8 w-28 rounded-md" />
198− </div>
199− <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
200− {Array.from({ length: 6 }).map((_, i) => (
201− <Skeleton key={i} className="h-24 w-full rounded-lg" />
202− ))}
203− </div>
204− </div>
205− </main>
206− </div>
207− );
155+// Neutral, layout-free placeholder for the moments no UI is correct yet: a
156+// redirect in flight, or the (post-gate, near-impossible) hint-less verified
157+// load. Never the app shell's silhouette — that bet is the bug this file's
158+// gate exists to prevent.
159+function BlankScreen() {
160+ return <div className="h-screen bg-background" />;
208161 }
209162
210163 function ShellErrorFallback() {
@@ -236,6 +189,9 @@ function AuthGate() { · 25 unmodified lines
236189 const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);
237190 const isPublicRoute = PUBLIC_PATHS.has(location.pathname);
238191
192+ // The SSR gate already bounced fresh org-less document requests to
193+ // /create-org; this catches the MID-SESSION transitions (org deleted,
194+ // membership revoked → /account/me now reports no org).
239195 const needsOrgRedirect =
240196 auth.status === "authenticated" &&
241197 auth.organization == null &&
@@ -262,20 +218,14 @@ function AuthGate() { · 20 unmodified lines
262218 return <Outlet />;
263219 }
264220
265− // Signed-out visitors never reach this point on a fresh document request —
266− // the SSR gate (auth/ssr-gate.ts) redirected them to /login before the SPA
267− // was served. So "loading" here means a VERIFIED signed-in user whose
268− // /account/me is still in flight (and usually not even that: the auth-hint
269− // cookie resolves them to "authenticated" a frame after mount), making the
270− // app-shell skeleton the right placeholder again.
271− if (auth.status === "loading") {
272− return <ShellSkeleton />;
273− }
274−
275− // Mid-session sign-out (logout elsewhere, expiry): blank for the moment the
276− // redirect effect above needs — never a skeleton for an app they're out of.
277− if (auth.status === "unauthenticated") {
278− return <div className="h-screen bg-background" />;
221+ // Every state that isn't "authenticated with an org, on a page that wants
222+ // the shell" is a moment between redirects or an edge the gates make
223+ // near-impossible (a verified user whose hint hasn't seeded yet). Neutral
224+ // blank — the one placeholder that's correct whatever happens next. The
225+ // app-shell skeleton this file used to render here is exactly the
226+ // wrong-UI flash the SSR gate + hint exist to prevent.
227+ if (auth.status === "loading" || auth.status === "unauthenticated") {
228+ return <BlankScreen />;
279229 }
280230
281231 if (isOnboardingRoute) {
@@ -283,14 +233,14 @@ function AuthGate() { · 1 unmodified line
283233 }
284234
285235 if (auth.organization == null) {
286− return <ShellSkeleton />;
236+ return <BlankScreen />;
287237 }
288238
289239 return (
290240 <AutumnProvider pathPrefix="/api/billing">
291241 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
292242 <ExecutorProvider onHandledError={captureFrontendError}>
293− <React.Suspense fallback={<ShellSkeleton />}>
243+ <React.Suspense fallback={<BlankScreen />}>
294244 <ExecutorPluginsProvider plugins={clientPlugins}>
295245 <OrganizationProvider organizationId={auth.organization.id}>
296246 <Shell />
apps/cloud/src/web/auth.tsx+13−2
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 useAuth,
88 type IdentifyFn,
99 } from "@executor-js/react/multiplayer/auth-context";
10+import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint";
1011
1112 import { CloudApiClient } from "./client";
1213
@@ -44,7 +45,13 @@ export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvi · 31 unmodified lines
4445
4546 // ── Provider ───────────────────────────────────────────────────────────────
4647
47−export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
48+export const AuthProvider = ({
49+ children,
50+ initialHint,
51+}: {
52+ children: React.ReactNode;
53+ initialHint?: AuthHint | null;
54+}) => {
4855 const posthog = usePostHog();
4956
5057 const onIdentify = React.useCallback<IdentifyFn>(
@@ -64,5 +71,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { · 13 unmodified lines
6471 [posthog],
6572 );
6673
67− return <SharedAuthProvider onIdentify={onIdentify}>{children}</SharedAuthProvider>;
74+ return (
75+ <SharedAuthProvider onIdentify={onIdentify} initialHint={initialHint}>
76+ {children}
77+ </SharedAuthProvider>
78+ );
6879 };
e2e/cloud/auth-hint.test.ts+36−35
@@ -1,10 +1,11 @@
1−// Cloud-specific: the auth-hint cookie lifecycle. The sealed session is
2−// HttpOnly, so on a fresh page load the SPA can't know it's signed in until
3−// /account/me resolves — the hint (executor-auth-hint, non-HttpOnly, written
4−// by AuthProvider once /account/me confirms) is what lets the NEXT load paint
5−// the real app shell immediately instead of a skeleton. These scenarios pin
6−// the full loop: confirmed identity writes it, the next load seeds from it
7−// while the probe is still in flight, and logout takes it away.
1+// Cloud-specific: the auth-hint lifecycle that lets the app paint the REAL
2+// authenticated shell with no full-page skeleton, ever. The sealed session is
3+// HttpOnly, so the SPA can't derive identity from it — instead the SSR gate
4+// verifies the session per document request, renders the shell from that
5+// verified identity, and MINTS the non-HttpOnly hint cookie
6+// (executor-auth-hint) when the browser doesn't hold a current one. From
7+// then on AuthProvider keeps the hint fresh on every confirmed /account/me
8+// and logout takes it away with the session.
89 import { expect } from "@effect/vitest";
910 import { Effect } from "effect";
1011
@@ -14,7 +15,7 @@ import { Browser, Target } from "../src/services"; · 3 unmodified lines
1415 const HINT_COOKIE = "executor-auth-hint";
1516
1617 scenario(
17− "Auth hint · a confirmed session writes the hint, and the next load paints the real shell from it",
18+ "Auth hint · the FIRST load on a fresh browser paints the real shell — the gate minted the hint",
1819 {},
1920 Effect.gen(function* () {
2021 const browser = yield* Browser;
@@ -22,25 +23,10 @@ scenario( · 1 unmodified line
2223 const identity = yield* target.newIdentity();
2324
2425 yield* browser.session(identity, async ({ page, step }) => {
25− const hintCookie = async () =>
26− (await page.context().cookies()).find((cookie) => cookie.name === HINT_COOKIE);
27−
28− await step("First signed-in load → /account/me confirms → the hint is written", async () => {
29− await page.goto("/", { waitUntil: "commit" });
30− await page.getByRole("link", { name: "Policies" }).waitFor();
31− // The write happens in an effect after /account/me resolves.
32− await expect.poll(hintCookie, { timeout: 10_000 }).toBeTruthy();
33− });
34−
35− const hint = (await hintCookie())!;
36− expect(hint.httpOnly, "the hint is readable by the SPA — that's its job").toBe(false);
37− expect(
38− decodeURIComponent(hint.value),
39− "it carries the confirmed identity (display data only)",
40− ).toContain(identity.label);
41−
42− // Hold the auth probe open on the SECOND load. Without the hint this
43− // window is a full-page skeleton; with it, the real shell.
26+ // Hold the auth probe open from the very first request. This browser
27+ // has NEVER loaded the app — no hint cookie exists — which used to be
28+ // the one case that still showed a full-page skeleton for the whole
29+ // /account/me round trip.
4430 let probeResolved = false;
4531 await page.route("**/api/account/me", async (route) => {
4632 await new Promise((resolve) => setTimeout(resolve, 2_000));
@@ -48,19 +34,34 @@ scenario( · 1 unmodified line
4834 await route.continue();
4935 });
5036
51− await step("Reload: the real app shell paints while /account/me is in flight", async () => {
37+ await step("First-ever load: the real app shell, immediately", async () => {
5238 await page.goto("/", { waitUntil: "commit" });
53− // Real nav text — the loading skeleton has no text at all.
39+ // Real nav text — a skeleton has no text at all.
5440 await page.getByRole("link", { name: "Policies" }).waitFor();
5541 });
5642
57− expect(probeResolved, "the shell did NOT wait for /account/me — the hint seeded it").toBe(
58− false,
59− );
43+ expect(probeResolved, "the shell did NOT wait for /account/me").toBe(false);
44+ // The full-page skeleton was a text-free silhouette; the real shell has
45+ // the nav AND this identity's own data in the footer — the SSR render
46+ // knew who it was serving. (Per-SECTION skeletons for in-flight data
47+ // are fine; the silhouette of the whole app is what must never paint.)
48+ // newIdentity names the org after the user (`Org user-xxxx`), so the
49+ // footer's org line is identity-specific text no other user would show.
50+ const orgName = `Org ${identity.label.split("@")[0]}`;
6051 expect(
61− await page.getByRole("link", { name: "Billing" }).isVisible(),
62− "it is the full signed-in nav, not a placeholder",
52+ await page.getByText(orgName).first().isVisible(),
53+ "the shell footer shows THIS identity's organization",
6354 ).toBe(true);
55+
56+ // The identity the shell painted from is now pinned for next time: the
57+ // gate minted the hint cookie on the document response itself.
58+ const hint = (await page.context().cookies()).find((c) => c.name === HINT_COOKIE);
59+ expect(hint, "the gate minted the hint on the first response").toBeTruthy();
60+ expect(hint!.httpOnly, "the hint is readable by the SPA — that's its job").toBe(false);
61+ expect(
62+ decodeURIComponent(hint!.value),
63+ "it carries the verified identity (display data only)",
64+ ).toContain(identity.label);
6465 });
6566 }),
6667 );
@@ -74,7 +75,7 @@ scenario( · 7 unmodified lines
7475 const identity = yield* target.newIdentity();
7576
7677 yield* browser.session(identity, async ({ page, step }) => {
77− await step("Load the app signed in (the hint gets written)", async () => {
78+ await step("Load the app signed in (the hint arrives with it)", async () => {
7879 await page.goto("/", { waitUntil: "commit" });
7980 await page.getByRole("link", { name: "Policies" }).waitFor();
8081 await expect
e2e/cloud/session-gate.test.ts+28−0
@@ -81,6 +81,34 @@ scenario( · 80 unmodified lines
8181 }),
8282 );
8383
84+scenario(
85+ "Session gate · an org-less session is sent to onboarding before the app shell exists",
86+ {},
87+ Effect.gen(function* () {
88+ // Gate: the REST API plane is mounted on this target.
89+ yield* Api;
90+ const target = yield* Target;
91+
92+ // A verified user with no organization belongs in onboarding — the gate
93+ // decides that at the edge, so the app shell (whose every query needs an
94+ // org) is never even served.
95+ const orgless = yield* target.newIdentity({ org: false });
96+ const gated = yield* documentRequest(
97+ new URL("/tools", target.baseUrl),
98+ orgless.headers!.cookie!,
99+ );
100+ expect(gated.status, "the app page is not served org-less").toBe(302);
101+ expect(gated.headers.get("location"), "…onboarding owns this session").toBe("/create-org");
102+
103+ // The onboarding page itself is served (no redirect loop).
104+ const onboarding = yield* documentRequest(
105+ new URL("/create-org", target.baseUrl),
106+ orgless.headers!.cookie!,
107+ );
108+ expect(onboarding.status, "/create-org renders for the org-less session").toBe(200);
109+ }),
110+);
111+
84112 // The sealed wos-session is iron-sealed JSON { accessToken, refreshToken, … }.
85113 // Corrupting the access token's signature makes the gate's verify fail the
86114 // same way an EXPIRED token does (invalid JWT → refresh path) — without
e2e/cloud/unauthenticated-skeleton.test.ts+10−3
@@ -162,10 +162,17 @@ scenario( · 161 unmodified lines
162162 await page.goto("/this-page-does-not-exist", { waitUntil: "commit" });
163163 await page.getByText("Page not found").waitFor();
164164 });
165+ // The 404 renders INSIDE the real shell (nav + identity), not as a
166+ // text-free full-page silhouette. Per-section skeletons in the sidebar
167+ // (the integration list mid-fetch) are honest loading states and fine.
165168 expect(
166− await page.locator('[data-slot="skeleton"]').count(),
167− "no app-shell skeleton on the 404 page",
168− ).toBe(0);
169+ await page.getByRole("link", { name: "Policies" }).isVisible(),
170+ "the real shell frames the 404",
171+ ).toBe(true);
172+ expect(
173+ await page.getByText("Go home").isVisible(),
174+ "with the 404 page's action, not a dead end",
175+ ).toBe(true);
169176 });
170177 }),
171178 );
packages/react/src/multiplayer/auth-context.tsx+34−8
@@ -25,6 +25,13 @@ import { · 24 unmodified lines
2525 // as loading — which the host should never let happen for signed-out users
2626 // (cloud redirects them to /login during SSR). The hint is display-only;
2727 // the resolved `/account/me` answer always wins.
28+//
29+// Hosts whose server can see the hint at render time (cloud: the SSR gate
30+// passes it through the root loader) provide `initialHint`, and the
31+// authenticated shell is SSR'd outright — both the server render and the
32+// first client render derive from the same dehydrated value, so they agree
33+// by construction. Hosts without that seam (self-host) omit it and keep the
34+// post-mount cookie read: SSR paints loading, the hint applies a frame later.
2835 // ---------------------------------------------------------------------------
2936
3037 export type AuthUser = {
@@ -63,19 +70,23 @@ const hintState = (hint: AuthHint | null): AuthState | null => · 32 unmodified lines
6370 const AuthProviderClient = ({
6471 children,
6572 onIdentify,
73+ initialHint = null,
6674 }: {
6775 children: React.ReactNode;
6876 onIdentify?: IdentifyFn;
77+ initialHint?: AuthHint | null;
6978 }) => {
7079 const result = useAtomValue(meAtom);
7180
72− // The hint applies one frame AFTER mount: SSR always renders the loading
73− // state and the first client render must match that HTML, so the cookie
74− // read is deferred to an effect (the documented pattern for client-only
75− // values). The flip costs a frame, not a network round trip.
76− const [hint, setHint] = useState<AuthHint | null>(null);
81+ // With a server-provided hint the first client render matches the SSR'd
82+ // authenticated HTML outright (both derive from the same dehydrated value).
83+ // Without one, the cookie read is deferred to an effect: SSR rendered
84+ // loading, and the first client render must match that HTML — the flip
85+ // costs a frame, not a network round trip. Initial-value-only state: later
86+ // loader re-runs (which lose the server context) must not yank the seed.
87+ const [hint, setHint] = useState<AuthHint | null>(initialHint);
7788 useEffect(() => {
78− setHint(readAuthHintCookie());
89+ setHint((current) => current ?? readAuthHintCookie());
7990 }, []);
8091
8192 // What `/account/me` actually said — the authority. The atom value only
@@ -127,12 +138,27 @@ const AuthProviderClient = ({ · 45 unmodified lines
127138 export const AuthProvider = ({
128139 children,
129140 onIdentify,
141+ initialHint = null,
130142 }: {
131143 children: React.ReactNode;
132144 onIdentify?: IdentifyFn;
145+ /**
146+ * The auth hint as the HOST's server saw it on this request (cloud: from
147+ * the SSR gate via the root loader, dehydrated to the client). When set,
148+ * SSR paints the authenticated shell instead of a loading state.
149+ */
150+ initialHint?: AuthHint | null;
133151 }) => {
134152 if (typeof window === "undefined") {
135− return <AuthContext.Provider value={{ status: "loading" }}>{children}</AuthContext.Provider>;
153+ return (
154+ <AuthContext.Provider value={hintState(initialHint) ?? { status: "loading" }}>
155+ {children}
156+ </AuthContext.Provider>
157+ );
136158 }
137− return <AuthProviderClient onIdentify={onIdentify}>{children}</AuthProviderClient>;
159+ return (
160+ <AuthProviderClient onIdentify={onIdentify} initialHint={initialHint}>
161+ {children}
162+ </AuthProviderClient>
163+ );
138164 };
packages/react/src/multiplayer/auth-hint.tsx+3−2
@@ -40,12 +40,13 @@ export type AuthHint = typeof AuthHintSchema.Type; · 39 unmodified lines
4040
4141 const AuthHintFromJson = Schema.fromJsonString(AuthHintSchema);
4242 const decodeAuthHintJson = Schema.decodeUnknownOption(AuthHintFromJson);
43−const encodeAuthHintJson = Schema.encodeSync(AuthHintFromJson);
4443 // The cookie value arrives percent-encoded; a truncated/corrupted one can
4544 // make decodeURIComponent itself throw, which must read as "no hint".
4645 const decodeUriComponentOption = Option.liftThrowable(decodeURIComponent);
4746
48−/** Encode a hint as a cookie VALUE (URI-encoded JSON). */
47+const encodeAuthHintJson = Schema.encodeSync(AuthHintFromJson);
48+
49+/** Encode a hint as a wire-ready cookie VALUE (URI-encoded JSON). */
4950 export const encodeAuthHint = (hint: AuthHint): string =>
5051 encodeURIComponent(encodeAuthHintJson(hint));
5152