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:
// --- 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<string> {
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:
): Promise<{ requestUri: string; clientRedirectUri: string; clientState: string | null; did: string }> {
Change line 449:
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):
import {
// ... existing imports ...
createSessionCookie,
sessionCookieHeader,
clearSessionCookieHeader,
parseSessionCookie,
setSessionCookieName,
} from './oauth/server.ts'
Step 2: Set cookie on callback redirect
Replace lines 666-667:
const result = await handleCallback(oauth, code, state, iss)
return new Response(null, { status: 302, headers: { Location: result.clientRedirectUri } })
With:
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/callbackblock, around line 670)
Step 1: Add the logout route
After the OAuth callback block (after line ~670, before the /oauth/token block):
// 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:
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:
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:
const request = new Request(fullUrl.href)
To:
const headers: Record<string, string> = {}
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:
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:
// 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):
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:
// 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:
// 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<string, string> = {}
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:
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<string, string> = {}
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:
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
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:
// 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
cd /Users/chadmiller/code/hatk-template-statusphere
npm run dev
- Visit page — should SSR the feed, auth section hidden during SSR
- Log in — should set
__hatk_sessioncookie (check DevTools → Application → Cookies) - Refresh page — server should resolve viewer from cookie, SSR authenticated UI
- Log out — cookie should be cleared