Skip to content

Commit 5c21c8f

Browse files
authored
Render the real shell from the gate's verified identity — delete the full-page skeleton (#986)
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.
1 parent 51835e1 commit 5c21c8f

9 files changed

Lines changed: 295 additions & 151 deletions

File tree

apps/cloud/src/auth/route-paths.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,51 @@
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
1420
// earlier in the middleware chain). Everything else passes through untouched.
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 => {
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> =>
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(
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(
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

Lines changed: 33 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,17 @@ import { PostHogProvider } from "posthog-js/react";
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() {
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 }) {
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() {
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,35 +218,29 @@ function AuthGate() {
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) {
282232
return <Outlet />;
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 />

0 commit comments

Comments
 (0)