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). */
7export const PUBLIC_PATHS = new Set(["/login"]);
8
9/** Pages an authenticated-but-org-less user is FOR (everything else redirects to onboarding). */
10export const ONBOARDING_PATHS = new Set(["/create-org", "/setup-mcp"]);
apps/cloud/src/auth/ssr-gate.ts+128−18
@@ -1,13 +1,19 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// SSR auth gate — the server-side session check for DOCUMENT requests.2// SSR auth gate — the server-side session check for DOCUMENT requests.
3//3//
4// Before this gate, every signed-out visitor was served the SPA, whose root4// The sealed `wos-session` cookie is verified right here in the worker
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
8// (unseal + JWT check against cached JWKS — no per-request WorkOS round trip5// (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 any6// except token refresh), so by the time the SPA is served the server KNOWS
10// app HTML exists, and signed-in visitors proceed knowing the session is real.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.
11//17//
12// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /18// Scope: GET/HEAD requests that are document navigations (sec-fetch-dest /
13// accept), excluding app-owned paths (/api, /mcp — they answer for themselves19// accept), excluding app-owned paths (/api, /mcp — they answer for themselves
@@ -15,19 +21,31 @@ · 1 unmodified line
15// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
16 22
17import { createMiddleware } from "@tanstack/react-start";23import { createMiddleware } from "@tanstack/react-start";
18import { Effect, Exit, ManagedRuntime } from "effect";24import { Effect, Exit, Layer, ManagedRuntime } from "effect";
19 25
20import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";26import {
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";
21 33
22import { isAppOwnedPath } from "../app-paths";34import { isAppOwnedPath } from "../app-paths";
35import { makeDbLayer } from "../db/db";
36import { makeUserStoreLayer, UserStoreService } from "./context";
23import { parseCookie } from "./cookies";37import { parseCookie } from "./cookies";
38import { sealedSessionDisplayName } from "./middleware";
24import { loginPath, safeReturnTo } from "./return-to";39import { loginPath, safeReturnTo } from "./return-to";
40import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
25import { WorkOSClient } from "./workos";41import { WorkOSClient } from "./workos";
26 42
27const SESSION_COOKIE = "wos-session";43const SESSION_COOKIE = "wos-session";
28/** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */44/** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */
29const SESSION_COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";45const SESSION_COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax";
30const SESSION_MAX_AGE = 60 * 60 * 24 * 7;46const SESSION_MAX_AGE = 60 * 60 * 24 * 7;
47/** Same attributes the client write uses — minus HttpOnly: the SPA reads it. */
48const HINT_COOKIE_ATTRIBUTES = "Path=/; Secure; SameSite=Lax";
31 49
32const isDocumentRequest = (request: Request): boolean => {50const isDocumentRequest = (request: Request): boolean => {
33 if (request.method !== "GET" && request.method !== "HEAD") return false;51 if (request.method !== "GET" && request.method !== "HEAD") return false;
@@ -48,7 +66,14 @@ const isDocumentRequest = (request: Request): boolean => { · 14 unmodified lines
48let runtime: ManagedRuntime.ManagedRuntime<WorkOSClient, unknown> | undefined;66let runtime: ManagedRuntime.ManagedRuntime<WorkOSClient, unknown> | undefined;
49const getRuntime = () => (runtime ??= ManagedRuntime.make(WorkOSClient.Default));67const getRuntime = () => (runtime ??= ManagedRuntime.make(WorkOSClient.Default));
50 68
51type VerifiedSession = { readonly refreshedSession?: string | undefined };69type 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};
52 77
53// EVERY failure collapses to "signed out" — WorkOS errors inside the effect78// EVERY failure collapses to "signed out" — WorkOS errors inside the effect
54// and layer-construction errors like a bad cookie password (runPromiseExit79// 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
58 const exit = await getRuntime().runPromiseExit(83 const exit = await getRuntime().runPromiseExit(
59 Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.authenticateSealedSession(sealed)),84 Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.authenticateSealedSession(sealed)),
60 );85 );
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 };
62};96};
63 97
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 */
108const 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.
143const 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
152const hintSetCookie = (hint: AuthHint) =>
153 `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`;
154
64const sessionSetCookie = (sealed: string) =>155const sessionSetCookie = (sealed: string) =>
65 `${SESSION_COOKIE}=${sealed}; ${SESSION_COOKIE_ATTRIBUTES}; Max-Age=${SESSION_MAX_AGE}`;156 `${SESSION_COOKIE}=${sealed}; ${SESSION_COOKIE_ATTRIBUTES}; Max-Age=${SESSION_MAX_AGE}`;
66 157
@@ -88,12 +179,13 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 21 unmodified lines
88 async ({ pathname, request, next }) => {179 async ({ pathname, request, next }) => {
89 if (isAppOwnedPath(pathname) || !isDocumentRequest(request)) return next();180 if (isAppOwnedPath(pathname) || !isDocumentRequest(request)) return next();
90 181
91 const sealed = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);182 const cookieHeader = request.headers.get("cookie");
183 const sealed = parseCookie(cookieHeader, SESSION_COOKIE);
92 const url = new URL(request.url);184 const url = new URL(request.url);
93 185
94 // /login is the one page signed-out visitors are FOR; a signed-in visitor186 // Public pages are what signed-out visitors are FOR; a signed-in visitor
95 // landing here is bounced straight back to where they were headed.187 // landing on /login is bounced straight back to where they were headed.
96 if (pathname === "/login") {188 if (PUBLIC_PATHS.has(pathname)) {
97 const session = sealed ? await verifySession(sealed) : null;189 const session = sealed ? await verifySession(sealed) : null;
98 if (!session) return next();190 if (!session) return next();
99 return redirect(safeReturnTo(url.searchParams.get("returnTo")) ?? "/", {191 return redirect(safeReturnTo(url.searchParams.get("returnTo")) ?? "/", {
@@ -118,14 +210,32 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 18 unmodified lines
118 return redirect("/", { refreshedSession: session.refreshedSession });210 return redirect("/", { refreshedSession: session.refreshedSession });
119 }211 }
120 212
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 }
122 if (session.refreshedSession) {234 if (session.refreshedSession) {
123 // WorkOS refresh tokens are single-use: the rotated sealed session MUST235 // WorkOS refresh tokens are single-use: the rotated sealed session MUST
124 // reach the browser or the next expiry logs the user out.236 // reach the browser or the next expiry logs the user out.
125 const response = new Response(result.response.body, result.response);
126 response.headers.append("set-cookie", sessionSetCookie(session.refreshedSession));237 response.headers.append("set-cookie", sessionSetCookie(session.refreshedSession));
127 return response;
128 }238 }
129 return result;239 return { ...result, response };
130 },240 },
131);241);
apps/cloud/src/routes/__root.tsx+33−83
@@ -14,22 +14,17 @@ import { PostHogProvider } from "posthog-js/react"; · 13 unmodified lines
14import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";14import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
15import { ExecutorProvider } from "@executor-js/react/api/provider";15import { ExecutorProvider } from "@executor-js/react/api/provider";
16import { OrganizationProvider } from "@executor-js/react/api/organization-context";16import { OrganizationProvider } from "@executor-js/react/api/organization-context";
17import { Skeleton } from "@executor-js/react/components/skeleton";
18import { Toaster } from "@executor-js/react/components/sonner";17import { Toaster } from "@executor-js/react/components/sonner";
19import { ExecutorPluginsProvider } from "@executor-js/sdk/client";18import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
20import { plugins as clientPlugins } from "virtual:executor/plugins-client";19import { plugins as clientPlugins } from "virtual:executor/plugins-client";
20import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint";
21import { AuthProvider, useAuth } from "../web/auth";21import { AuthProvider, useAuth } from "../web/auth";
22import { loginPath } from "../auth/return-to";22import { loginPath } from "../auth/return-to";
23import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths";
23import { SupportOptions } from "../web/components/support-options";24import { SupportOptions } from "../web/components/support-options";
24import { Shell } from "../web/shell";25import { Shell } from "../web/shell";
25import appCss from "@executor-js/react/globals.css?url";26import appCss from "@executor-js/react/globals.css?url";
26 27
27const 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).
31const PUBLIC_PATHS = new Set(["/login"]);
32
33if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) {28if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) {
34 Sentry.init({29 Sentry.init({
35 dsn: import.meta.env.VITE_PUBLIC_SENTRY_DSN,30 dsn: import.meta.env.VITE_PUBLIC_SENTRY_DSN,
@@ -98,6 +93,16 @@ function NotFoundPage() { · 62 unmodified lines
98 93
99export const Route = createRootRoute({94export const Route = createRootRoute({
100 notFoundComponent: NotFoundPage,95 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 }),
101 head: () => ({106 head: () => ({
102 meta: [107 meta: [
103 { charSet: "utf-8" },108 { charSet: "utf-8" },
@@ -137,74 +142,22 @@ function RootDocument({ children }: { children: React.ReactNode }) { · 33 unmodified lines
137}142}
138 143
139function RootComponent() {144function RootComponent() {
145 const { authHint } = Route.useLoaderData();
140 return (146 return (
141 <PostHogProvider client={posthog}>147 <PostHogProvider client={posthog}>
142 <AuthProvider>148 <AuthProvider initialHint={authHint}>
143 <AuthGate />149 <AuthGate />
144 </AuthProvider>150 </AuthProvider>
145 </PostHogProvider>151 </PostHogProvider>
146 );152 );
147}153}
148 154
149function ShellSkeleton() {155// Neutral, layout-free placeholder for the moments no UI is correct yet: a
150 return (156// redirect in flight, or the (post-gate, near-impossible) hint-less verified
151 <div className="flex h-screen overflow-hidden">157// load. Never the app shell's silhouette — that bet is the bug this file's
152 {/* Desktop sidebar skeleton */}158// gate exists to prevent.
153 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">159function BlankScreen() {
154 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">160 return <div className="h-screen bg-background" />;
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 );
208}161}
209 162
210function ShellErrorFallback() {163function ShellErrorFallback() {
@@ -236,6 +189,9 @@ function AuthGate() { · 25 unmodified lines
236 const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);189 const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname);
237 const isPublicRoute = PUBLIC_PATHS.has(location.pathname);190 const isPublicRoute = PUBLIC_PATHS.has(location.pathname);
238 191
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).
239 const needsOrgRedirect =195 const needsOrgRedirect =
240 auth.status === "authenticated" &&196 auth.status === "authenticated" &&
241 auth.organization == null &&197 auth.organization == null &&
@@ -262,20 +218,14 @@ function AuthGate() { · 20 unmodified lines
262 return <Outlet />;218 return <Outlet />;
263 }219 }
264 220
265 // Signed-out visitors never reach this point on a fresh document request —221 // Every state that isn't "authenticated with an org, on a page that wants
266 // the SSR gate (auth/ssr-gate.ts) redirected them to /login before the SPA222 // the shell" is a moment between redirects or an edge the gates make
267 // was served. So "loading" here means a VERIFIED signed-in user whose223 // near-impossible (a verified user whose hint hasn't seeded yet). Neutral
268 // /account/me is still in flight (and usually not even that: the auth-hint224 // blank — the one placeholder that's correct whatever happens next. The
269 // cookie resolves them to "authenticated" a frame after mount), making the225 // app-shell skeleton this file used to render here is exactly the
270 // app-shell skeleton the right placeholder again.226 // wrong-UI flash the SSR gate + hint exist to prevent.
271 if (auth.status === "loading") {227 if (auth.status === "loading" || auth.status === "unauthenticated") {
272 return <ShellSkeleton />;228 return <BlankScreen />;
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" />;
279 }229 }
280 230
281 if (isOnboardingRoute) {231 if (isOnboardingRoute) {
@@ -283,14 +233,14 @@ function AuthGate() { · 1 unmodified line
283 }233 }
284 234
285 if (auth.organization == null) {235 if (auth.organization == null) {
286 return <ShellSkeleton />;236 return <BlankScreen />;
287 }237 }
288 238
289 return (239 return (
290 <AutumnProvider pathPrefix="/api/billing">240 <AutumnProvider pathPrefix="/api/billing">
291 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>241 <Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
292 <ExecutorProvider onHandledError={captureFrontendError}>242 <ExecutorProvider onHandledError={captureFrontendError}>
293 <React.Suspense fallback={<ShellSkeleton />}>243 <React.Suspense fallback={<BlankScreen />}>
294 <ExecutorPluginsProvider plugins={clientPlugins}>244 <ExecutorPluginsProvider plugins={clientPlugins}>
295 <OrganizationProvider organizationId={auth.organization.id}>245 <OrganizationProvider organizationId={auth.organization.id}>
296 <Shell />246 <Shell />
apps/cloud/src/web/auth.tsx+13−2
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7 useAuth,7 useAuth,
8 type IdentifyFn,8 type IdentifyFn,
9} from "@executor-js/react/multiplayer/auth-context";9} from "@executor-js/react/multiplayer/auth-context";
10import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint";
10 11
11import { CloudApiClient } from "./client";12import { CloudApiClient } from "./client";
12 13
@@ -44,7 +45,13 @@ export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvi · 31 unmodified lines
44 45
45// ── Provider ───────────────────────────────────────────────────────────────46// ── Provider ───────────────────────────────────────────────────────────────
46 47
47export const AuthProvider = ({ children }: { children: React.ReactNode }) => {48export const AuthProvider = ({
49 children,
50 initialHint,
51}: {
52 children: React.ReactNode;
53 initialHint?: AuthHint | null;
54}) => {
48 const posthog = usePostHog();55 const posthog = usePostHog();
49 56
50 const onIdentify = React.useCallback<IdentifyFn>(57 const onIdentify = React.useCallback<IdentifyFn>(
@@ -64,5 +71,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { · 13 unmodified lines
64 [posthog],71 [posthog],
65 );72 );
66 73
67 return <SharedAuthProvider onIdentify={onIdentify}>{children}</SharedAuthProvider>;74 return (
75 <SharedAuthProvider onIdentify={onIdentify} initialHint={initialHint}>
76 {children}
77 </SharedAuthProvider>
78 );
68};79};
e2e/cloud/auth-hint.test.ts+36−35
@@ -1,10 +1,11 @@
1// Cloud-specific: the auth-hint cookie lifecycle. The sealed session is1// Cloud-specific: the auth-hint lifecycle that lets the app paint the REAL
2// HttpOnly, so on a fresh page load the SPA can't know it's signed in until2// authenticated shell with no full-page skeleton, ever. The sealed session is
3// /account/me resolves — the hint (executor-auth-hint, non-HttpOnly, written3// HttpOnly, so the SPA can't derive identity from it — instead the SSR gate
4// by AuthProvider once /account/me confirms) is what lets the NEXT load paint4// verifies the session per document request, renders the shell from that
5// the real app shell immediately instead of a skeleton. These scenarios pin5// verified identity, and MINTS the non-HttpOnly hint cookie
6// the full loop: confirmed identity writes it, the next load seeds from it6// (executor-auth-hint) when the browser doesn't hold a current one. From
7// while the probe is still in flight, and logout takes it away.7// then on AuthProvider keeps the hint fresh on every confirmed /account/me
8// and logout takes it away with the session.
8import { expect } from "@effect/vitest";9import { expect } from "@effect/vitest";
9import { Effect } from "effect";10import { Effect } from "effect";
10 11
@@ -14,7 +15,7 @@ import { Browser, Target } from "../src/services"; · 3 unmodified lines
14const HINT_COOKIE = "executor-auth-hint";15const HINT_COOKIE = "executor-auth-hint";
15 16
16scenario(17scenario(
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",
18 {},19 {},
19 Effect.gen(function* () {20 Effect.gen(function* () {
20 const browser = yield* Browser;21 const browser = yield* Browser;
@@ -22,25 +23,10 @@ scenario( · 1 unmodified line
22 const identity = yield* target.newIdentity();23 const identity = yield* target.newIdentity();
23 24
24 yield* browser.session(identity, async ({ page, step }) => {25 yield* browser.session(identity, async ({ page, step }) => {
25 const hintCookie = async () =>26 // Hold the auth probe open from the very first request. This browser
26 (await page.context().cookies()).find((cookie) => cookie.name === HINT_COOKIE);27 // has NEVER loaded the app — no hint cookie exists — which used to be
27 28 // the one case that still showed a full-page skeleton for the whole
28 await step("First signed-in load → /account/me confirms → the hint is written", async () => {29 // /account/me round trip.
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.
44 let probeResolved = false;30 let probeResolved = false;
45 await page.route("**/api/account/me", async (route) => {31 await page.route("**/api/account/me", async (route) => {
46 await new Promise((resolve) => setTimeout(resolve, 2_000));32 await new Promise((resolve) => setTimeout(resolve, 2_000));
@@ -48,19 +34,34 @@ scenario( · 1 unmodified line
48 await route.continue();34 await route.continue();
49 });35 });
50 36
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 () => {
52 await page.goto("/", { waitUntil: "commit" });38 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.
54 await page.getByRole("link", { name: "Policies" }).waitFor();40 await page.getByRole("link", { name: "Policies" }).waitFor();
55 });41 });
56 42
57 expect(probeResolved, "the shell did NOT wait for /account/me — the hint seeded it").toBe(43 expect(probeResolved, "the shell did NOT wait for /account/me").toBe(false);
58 false,44 // The full-page skeleton was a text-free silhouette; the real shell has
59 );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]}`;
60 expect(51 expect(
61 await page.getByRole("link", { name: "Billing" }).isVisible(),52 await page.getByText(orgName).first().isVisible(),
62 "it is the full signed-in nav, not a placeholder",53 "the shell footer shows THIS identity's organization",
63 ).toBe(true);54 ).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);
64 });65 });
65 }),66 }),
66);67);
@@ -74,7 +75,7 @@ scenario( · 7 unmodified lines
74 const identity = yield* target.newIdentity();75 const identity = yield* target.newIdentity();
75 76
76 yield* browser.session(identity, async ({ page, step }) => {77 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 () => {
78 await page.goto("/", { waitUntil: "commit" });79 await page.goto("/", { waitUntil: "commit" });
79 await page.getByRole("link", { name: "Policies" }).waitFor();80 await page.getByRole("link", { name: "Policies" }).waitFor();
80 await expect81 await expect
e2e/cloud/session-gate.test.ts+28−0
@@ -81,6 +81,34 @@ scenario( · 80 unmodified lines
81 }),81 }),
82);82);
83 83
84scenario(
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
84// The sealed wos-session is iron-sealed JSON { accessToken, refreshToken, … }.112// The sealed wos-session is iron-sealed JSON { accessToken, refreshToken, … }.
85// Corrupting the access token's signature makes the gate's verify fail the113// Corrupting the access token's signature makes the gate's verify fail the
86// same way an EXPIRED token does (invalid JWT → refresh path) — without114// 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
162 await page.goto("/this-page-does-not-exist", { waitUntil: "commit" });162 await page.goto("/this-page-does-not-exist", { waitUntil: "commit" });
163 await page.getByText("Page not found").waitFor();163 await page.getByText("Page not found").waitFor();
164 });164 });
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.
165 expect(168 expect(
166 await page.locator('[data-slot="skeleton"]').count(),169 await page.getByRole("link", { name: "Policies" }).isVisible(),
167 "no app-shell skeleton on the 404 page",170 "the real shell frames the 404",
168 ).toBe(0);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);
169 });176 });
170 }),177 }),
171);178);
packages/react/src/multiplayer/auth-context.tsx+34−8
@@ -25,6 +25,13 @@ import { · 24 unmodified lines
25// as loading — which the host should never let happen for signed-out users25// as loading — which the host should never let happen for signed-out users
26// (cloud redirects them to /login during SSR). The hint is display-only;26// (cloud redirects them to /login during SSR). The hint is display-only;
27// the resolved `/account/me` answer always wins.27// 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.
28// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
29 36
30export type AuthUser = {37export type AuthUser = {
@@ -63,19 +70,23 @@ const hintState = (hint: AuthHint | null): AuthState | null => · 32 unmodified lines
63const AuthProviderClient = ({70const AuthProviderClient = ({
64 children,71 children,
65 onIdentify,72 onIdentify,
73 initialHint = null,
66}: {74}: {
67 children: React.ReactNode;75 children: React.ReactNode;
68 onIdentify?: IdentifyFn;76 onIdentify?: IdentifyFn;
77 initialHint?: AuthHint | null;
69}) => {78}) => {
70 const result = useAtomValue(meAtom);79 const result = useAtomValue(meAtom);
71 80
72 // The hint applies one frame AFTER mount: SSR always renders the loading81 // With a server-provided hint the first client render matches the SSR'd
73 // state and the first client render must match that HTML, so the cookie82 // authenticated HTML outright (both derive from the same dehydrated value).
74 // read is deferred to an effect (the documented pattern for client-only83 // Without one, the cookie read is deferred to an effect: SSR rendered
75 // values). The flip costs a frame, not a network round trip.84 // loading, and the first client render must match that HTML — the flip
76 const [hint, setHint] = useState<AuthHint | null>(null);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);
77 useEffect(() => {88 useEffect(() => {
78 setHint(readAuthHintCookie());89 setHint((current) => current ?? readAuthHintCookie());
79 }, []);90 }, []);
80 91
81 // What `/account/me` actually said — the authority. The atom value only92 // What `/account/me` actually said — the authority. The atom value only
@@ -127,12 +138,27 @@ const AuthProviderClient = ({ · 45 unmodified lines
127export const AuthProvider = ({138export const AuthProvider = ({
128 children,139 children,
129 onIdentify,140 onIdentify,
141 initialHint = null,
130}: {142}: {
131 children: React.ReactNode;143 children: React.ReactNode;
132 onIdentify?: IdentifyFn;144 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;
133}) => {151}) => {
134 if (typeof window === "undefined") {152 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 );
136 }158 }
137 return <AuthProviderClient onIdentify={onIdentify}>{children}</AuthProviderClient>;159 return (
160 <AuthProviderClient onIdentify={onIdentify} initialHint={initialHint}>
161 {children}
162 </AuthProviderClient>
163 );
138};164};
packages/react/src/multiplayer/auth-hint.tsx+3−2
@@ -40,12 +40,13 @@ export type AuthHint = typeof AuthHintSchema.Type; · 39 unmodified lines
40 40
41const AuthHintFromJson = Schema.fromJsonString(AuthHintSchema);41const AuthHintFromJson = Schema.fromJsonString(AuthHintSchema);
42const decodeAuthHintJson = Schema.decodeUnknownOption(AuthHintFromJson);42const decodeAuthHintJson = Schema.decodeUnknownOption(AuthHintFromJson);
43const encodeAuthHintJson = Schema.encodeSync(AuthHintFromJson);
44// The cookie value arrives percent-encoded; a truncated/corrupted one can43// The cookie value arrives percent-encoded; a truncated/corrupted one can
45// make decodeURIComponent itself throw, which must read as "no hint".44// make decodeURIComponent itself throw, which must read as "no hint".
46const decodeUriComponentOption = Option.liftThrowable(decodeURIComponent);45const decodeUriComponentOption = Option.liftThrowable(decodeURIComponent);
47 46
48/** Encode a hint as a cookie VALUE (URI-encoded JSON). */47const encodeAuthHintJson = Schema.encodeSync(AuthHintFromJson);
48
49/** Encode a hint as a wire-ready cookie VALUE (URI-encoded JSON). */
49export const encodeAuthHint = (hint: AuthHint): string =>50export const encodeAuthHint = (hint: AuthHint): string =>
50 encodeURIComponent(encodeAuthHintJson(hint));51 encodeURIComponent(encodeAuthHintJson(hint));
51 52