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
1723import { 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
2234import { isAppOwnedPath } from "../app-paths" ;
35+ import { makeDbLayer } from "../db/db" ;
36+ import { makeUserStoreLayer , UserStoreService } from "./context" ;
2337import { parseCookie } from "./cookies" ;
38+ import { sealedSessionDisplayName } from "./middleware" ;
2439import { loginPath , safeReturnTo } from "./return-to" ;
40+ import { ONBOARDING_PATHS , PUBLIC_PATHS } from "./route-paths" ;
2541import { WorkOSClient } from "./workos" ;
2642
2743const SESSION_COOKIE = "wos-session" ;
2844/** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */
2945const SESSION_COOKIE_ATTRIBUTES = "Path=/; HttpOnly; Secure; SameSite=Lax" ;
3046const 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
3250const isDocumentRequest = ( request : Request ) : boolean => {
3351 if ( request . method !== "GET" && request . method !== "HEAD" ) return false ;
@@ -48,7 +66,14 @@ const isDocumentRequest = (request: Request): boolean => {
4866let runtime : ManagedRuntime . ManagedRuntime < WorkOSClient , unknown > | undefined ;
4967const 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+
64155const 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) ;
0 commit comments