# SSR Auth Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Server-side session cookies so SSR knows the viewer — no auth flash, authenticated `callXrpc` during SSR. **Architecture:** OAuth callback sets a signed `HttpOnly` cookie (`did.timestamp.hmac`). SSR middleware forwards the cookie, resolves the viewer, and sets `globalThis.__hatk_viewer`. `callXrpc` and a new `getViewer()` export pick it up automatically. **Tech Stack:** Web Crypto HMAC-SHA256, existing OAuth keypair, globalThis bridge pattern. --- ### Task 1: Session Cookie Helpers **Files:** - Modify: `packages/hatk/src/oauth/server.ts:70-92` (after key initialization) **Step 1: Add cookie creation and parsing functions** Add after the `initOAuth` function (after line ~93). These use the existing `serverPrivateKey` from the module scope: ```typescript // --- SSR Session Cookie --- let _sessionCookieName = '__hatk_session' const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 // 30 days in seconds export function setSessionCookieName(name: string): void { _sessionCookieName = name } export function getSessionCookieName(): string { return _sessionCookieName } export async function createSessionCookie(did: string): Promise { const timestamp = Math.floor(Date.now() / 1000) const payload = `${did}.${timestamp}` const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(JSON.stringify(serverPrivateJwk)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ) const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)) const signature = base64UrlEncode(new Uint8Array(sig)) return `${payload}.${signature}` } export function sessionCookieHeader(value: string, secure: boolean): string { const parts = [ `${_sessionCookieName}=${value}`, 'HttpOnly', 'SameSite=Lax', 'Path=/', `Max-Age=${SESSION_COOKIE_MAX_AGE}`, ] if (secure) parts.push('Secure') return parts.join('; ') } export function clearSessionCookieHeader(): string { return `${_sessionCookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0` } export async function parseSessionCookie(request: Request): Promise<{ did: string } | null> { const cookieHeader = request.headers.get('cookie') if (!cookieHeader) return null const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${_sessionCookieName}=`)) if (!match) return null const value = match.slice(_sessionCookieName.length + 1) const parts = value.split('.') // Format: did:plc:xxx.timestamp.signature — DID contains dots so we take last 2 parts if (parts.length < 3) return null const signature = parts.pop()! const timestamp = parts.pop()! const did = parts.join('.') const ts = Number(timestamp) if (isNaN(ts) || (Date.now() / 1000 - ts) > SESSION_COOKIE_MAX_AGE) return null // Verify HMAC const payload = `${did}.${timestamp}` const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(JSON.stringify(serverPrivateJwk)), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'], ) const sigBytes = base64UrlDecode(signature) const valid = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload)) if (!valid) return null return { did } } ``` Note: `base64UrlEncode` and `base64UrlDecode` are already imported from `./crypto.ts` (line 14). **Step 2: Build and verify** Run: `cd /Users/chadmiller/code/hatk/.worktrees/server-directory && npm run build` Expected: Clean build --- ### Task 2: Return DID from handleCallback **Files:** - Modify: `packages/hatk/src/oauth/server.ts:324-449` **Step 1: Add `did` to the return type and value** The `handleCallback` function (line 324) currently returns `{ requestUri, clientRedirectUri, clientState }`. Add `did`: Change line 329: ```typescript ): Promise<{ requestUri: string; clientRedirectUri: string; clientState: string | null; did: string }> { ``` Change line 449: ```typescript return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did } ``` **Step 2: Build and verify** Run: `npm run build` Expected: Clean build --- ### Task 3: Set Cookie on OAuth Callback **Files:** - Modify: `packages/hatk/src/server.ts:660-670` **Step 1: Import new cookie functions** Add to the existing import from `./oauth/server.ts` (line 34-45): ```typescript import { // ... existing imports ... createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie, setSessionCookieName, } from './oauth/server.ts' ``` **Step 2: Set cookie on callback redirect** Replace lines 666-667: ```typescript const result = await handleCallback(oauth, code, state, iss) return new Response(null, { status: 302, headers: { Location: result.clientRedirectUri } }) ``` With: ```typescript const result = await handleCallback(oauth, code, state, iss) const isSecure = requestOrigin.startsWith('https') const cookie = await createSessionCookie(result.did) return new Response(null, { status: 302, headers: [ ['Location', result.clientRedirectUri], ['Set-Cookie', sessionCookieHeader(cookie, isSecure)], ], }) ``` **Step 3: Build and verify** Run: `npm run build` Expected: Clean build --- ### Task 4: Add POST /auth/logout Route **Files:** - Modify: `packages/hatk/src/server.ts` (add after the `/oauth/callback` block, around line 670) **Step 1: Add the logout route** After the OAuth callback block (after line ~670, before the `/oauth/token` block): ```typescript // Session cookie logout if (url.pathname === '/auth/logout' && request.method === 'POST') { return new Response(null, { status: 200, headers: { 'Set-Cookie': clearSessionCookieHeader() }, }) } ``` **Step 2: Configure cookie name from oauth config** In the `createHandler` function, after `oauth` is set up (look for where `initOAuth` or oauth config is used), add cookie name configuration. In `createHandler` (around line 160-170), add: ```typescript if (oauth?.cookieName) { setSessionCookieName(oauth.cookieName) } ``` This requires adding `cookieName?: string` to the `OAuthConfig` type in `config.ts`. **Step 3: Build and verify** Run: `npm run build` Expected: Clean build --- ### Task 5: Add cookieName to OAuthConfig **Files:** - Modify: `packages/hatk/src/config.ts` (find OAuthConfig type) **Step 1: Add the optional field** Find the `OAuthConfig` interface/type and add: ```typescript cookieName?: string ``` **Step 2: Build and verify** Run: `npm run build` Expected: Clean build --- ### Task 6: Forward Cookie Header in Vite SSR Middleware **Files:** - Modify: `packages/hatk/src/vite-plugin.ts:186-191` **Step 1: Pass cookies to Request** Change line 188 from: ```typescript const request = new Request(fullUrl.href) ``` To: ```typescript const headers: Record = {} if (req.headers.cookie) headers.cookie = req.headers.cookie const request = new Request(fullUrl.href, { headers }) ``` **Step 2: Build and verify** Run: `npm run build` Expected: Clean build --- ### Task 7: Resolve Viewer and Set globalThis in Vite SSR **Files:** - Modify: `packages/hatk/src/vite-plugin.ts:118-125` (module loading section) - Modify: `packages/hatk/src/vite-plugin.ts:188-195` (SSR render section) - Modify: `packages/hatk/src/dev-entry.ts:99-101` (exports) **Step 1: Export parseSessionCookie from dev-entry.ts** Add to the exports at the bottom of `dev-entry.ts`: ```typescript export { parseSessionCookie } from './oauth/server.ts' ``` **Step 2: Capture parseSessionCookie from module runner** In `vite-plugin.ts`, after line 125 (`(globalThis as any).__hatk_callXrpc = mod.callXrpc`), add: ```typescript // Capture cookie parser for SSR viewer resolution ssrParseSessionCookie = mod.parseSessionCookie ``` Add the variable declaration near the top of `configureServer` (near the other `let` declarations like `handler`, `ssrRenderPage`): ```typescript let ssrParseSessionCookie: ((request: Request) => Promise<{ did: string } | null>) | null = null ``` **Step 3: Set globalThis.__hatk_viewer before render, clear after** In the SSR middleware, after creating the `request` object and before calling `ssrRenderPage`, add viewer resolution: ```typescript // Resolve viewer from session cookie for SSR let viewer: { did: string } | null = null if (ssrParseSessionCookie) { try { viewer = await ssrParseSessionCookie(request) } catch {} } ;(globalThis as any).__hatk_viewer = viewer try { const renderedHtml = await ssrRenderPage!(template, request) // ... existing code ... } finally { ;(globalThis as any).__hatk_viewer = null } ``` This replaces the current `const renderedHtml = await ssrRenderPage!(template, request)` call (line 191) with a try/finally block. **Step 4: Build and verify** Run: `npm run build` Expected: Clean build --- ### Task 8: Thread Viewer Through callXrpc **Files:** - Modify: `packages/hatk/src/xrpc.ts:310-330` **Step 1: Pass viewer from globalThis in both paths** Replace lines 317-327: ```typescript // In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis. // The runner's module instance has all registered handlers; this (Node's) instance may not. if (handlers.size === 0 && (globalThis as any).__hatk_callXrpc) { return (globalThis as any).__hatk_callXrpc(nsid, params, input) } const stringParams: Record = {} for (const [k, v] of Object.entries(params)) { if (v != null) stringParams[k] = String(v) } const limit = params.limit ? Number(params.limit) : 20 const cursor = params.cursor ?? undefined const result = await executeXrpc(nsid, stringParams, cursor, limit, null, input) ``` With: ```typescript const viewer = (globalThis as any).__hatk_viewer ?? null // In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis. // The runner's module instance has all registered handlers; this (Node's) instance may not. if (handlers.size === 0 && (globalThis as any).__hatk_callXrpc) { return (globalThis as any).__hatk_callXrpc(nsid, params, input) } const stringParams: Record = {} for (const [k, v] of Object.entries(params)) { if (v != null) stringParams[k] = String(v) } const limit = params.limit ? Number(params.limit) : 20 const cursor = params.cursor ?? undefined const result = await executeXrpc(nsid, stringParams, cursor, limit, viewer, input) ``` Note: The bridge path (`__hatk_callXrpc`) calls the runner's `callXrpc`, which will also read `globalThis.__hatk_viewer` — so both paths get the viewer. **Step 2: Build and verify** Run: `npm run build` Expected: Clean build --- ### Task 9: Add getViewer() to Codegen **Files:** - Modify: `packages/hatk/src/cli.ts:1748-1768` **Step 1: Add getViewer() export to generated client file** After the `callXrpc` function generation (after line 1768, before `writeFileSync`), add: ```typescript clientOut += `\nexport function getViewer(): { did: string } | null {\n` clientOut += ` if (typeof window === 'undefined') {\n` clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n` clientOut += ` }\n` clientOut += ` // Client-side: delegate to OAuth client\n` clientOut += ` try {\n` clientOut += ` const mod = (globalThis as any).__hatk_auth\n` clientOut += ` if (mod?.viewerDid) {\n` clientOut += ` const did = mod.viewerDid()\n` clientOut += ` return did ? { did } : null\n` clientOut += ` }\n` clientOut += ` } catch {}\n` clientOut += ` return null\n` clientOut += `}\n` ``` Note: The client-side path reads from `globalThis.__hatk_auth` — templates wire this up by setting `(globalThis as any).__hatk_auth = { viewerDid }` in their auth module. This avoids the generated file importing template-specific auth code. **Step 2: Build and verify** Run: `npm run build` Expected: Clean build --- ### Task 10: Test End-to-End with Statusphere Template **Step 1: Regenerate client types** ```bash cd /Users/chadmiller/code/hatk-template-statusphere node /Users/chadmiller/code/hatk/.worktrees/server-directory/packages/hatk/dist/cli.js generate types ``` Verify `hatk.generated.client.ts` now exports `getViewer`. **Step 2: Wire up globalThis.__hatk_auth in template's auth module** In `hatk-template-statusphere/app/lib/auth.ts`, add near the bottom: ```typescript // Expose viewer for SSR getViewer() bridge ;(globalThis as any).__hatk_auth = { viewerDid } ``` **Step 3: Update Home.svelte to use getViewer()** Import `getViewer` from `../hatk.generated.client.ts` and use it to conditionally render auth UI without flash. **Step 4: Run dev server and test** ```bash cd /Users/chadmiller/code/hatk-template-statusphere npm run dev ``` 1. Visit page — should SSR the feed, auth section hidden during SSR 2. Log in — should set `__hatk_session` cookie (check DevTools → Application → Cookies) 3. Refresh page — server should resolve viewer from cookie, SSR authenticated UI 4. Log out — cookie should be cleared