AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add SSR auth and typed XRPC actions design specs and plans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+1127
+445
docs/superpowers/plans/2026-03-14-ssr-auth.md
··· 1 + # SSR Auth Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Server-side session cookies so SSR knows the viewer — no auth flash, authenticated `callXrpc` during SSR. 6 + 7 + **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. 8 + 9 + **Tech Stack:** Web Crypto HMAC-SHA256, existing OAuth keypair, globalThis bridge pattern. 10 + 11 + --- 12 + 13 + ### Task 1: Session Cookie Helpers 14 + 15 + **Files:** 16 + - Modify: `packages/hatk/src/oauth/server.ts:70-92` (after key initialization) 17 + 18 + **Step 1: Add cookie creation and parsing functions** 19 + 20 + Add after the `initOAuth` function (after line ~93). These use the existing `serverPrivateKey` from the module scope: 21 + 22 + ```typescript 23 + // --- SSR Session Cookie --- 24 + 25 + let _sessionCookieName = '__hatk_session' 26 + const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 // 30 days in seconds 27 + 28 + export function setSessionCookieName(name: string): void { 29 + _sessionCookieName = name 30 + } 31 + 32 + export function getSessionCookieName(): string { 33 + return _sessionCookieName 34 + } 35 + 36 + export async function createSessionCookie(did: string): Promise<string> { 37 + const timestamp = Math.floor(Date.now() / 1000) 38 + const payload = `${did}.${timestamp}` 39 + const key = await crypto.subtle.importKey( 40 + 'raw', 41 + new TextEncoder().encode(JSON.stringify(serverPrivateJwk)), 42 + { name: 'HMAC', hash: 'SHA-256' }, 43 + false, 44 + ['sign'], 45 + ) 46 + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)) 47 + const signature = base64UrlEncode(new Uint8Array(sig)) 48 + return `${payload}.${signature}` 49 + } 50 + 51 + export function sessionCookieHeader(value: string, secure: boolean): string { 52 + const parts = [ 53 + `${_sessionCookieName}=${value}`, 54 + 'HttpOnly', 55 + 'SameSite=Lax', 56 + 'Path=/', 57 + `Max-Age=${SESSION_COOKIE_MAX_AGE}`, 58 + ] 59 + if (secure) parts.push('Secure') 60 + return parts.join('; ') 61 + } 62 + 63 + export function clearSessionCookieHeader(): string { 64 + return `${_sessionCookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0` 65 + } 66 + 67 + export async function parseSessionCookie(request: Request): Promise<{ did: string } | null> { 68 + const cookieHeader = request.headers.get('cookie') 69 + if (!cookieHeader) return null 70 + const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${_sessionCookieName}=`)) 71 + if (!match) return null 72 + const value = match.slice(_sessionCookieName.length + 1) 73 + const parts = value.split('.') 74 + // Format: did:plc:xxx.timestamp.signature — DID contains dots so we take last 2 parts 75 + if (parts.length < 3) return null 76 + const signature = parts.pop()! 77 + const timestamp = parts.pop()! 78 + const did = parts.join('.') 79 + const ts = Number(timestamp) 80 + if (isNaN(ts) || (Date.now() / 1000 - ts) > SESSION_COOKIE_MAX_AGE) return null 81 + // Verify HMAC 82 + const payload = `${did}.${timestamp}` 83 + const key = await crypto.subtle.importKey( 84 + 'raw', 85 + new TextEncoder().encode(JSON.stringify(serverPrivateJwk)), 86 + { name: 'HMAC', hash: 'SHA-256' }, 87 + false, 88 + ['verify'], 89 + ) 90 + const sigBytes = base64UrlDecode(signature) 91 + const valid = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload)) 92 + if (!valid) return null 93 + return { did } 94 + } 95 + ``` 96 + 97 + Note: `base64UrlEncode` and `base64UrlDecode` are already imported from `./crypto.ts` (line 14). 98 + 99 + **Step 2: Build and verify** 100 + 101 + Run: `cd /Users/chadmiller/code/hatk/.worktrees/server-directory && npm run build` 102 + Expected: Clean build 103 + 104 + --- 105 + 106 + ### Task 2: Return DID from handleCallback 107 + 108 + **Files:** 109 + - Modify: `packages/hatk/src/oauth/server.ts:324-449` 110 + 111 + **Step 1: Add `did` to the return type and value** 112 + 113 + The `handleCallback` function (line 324) currently returns `{ requestUri, clientRedirectUri, clientState }`. Add `did`: 114 + 115 + Change line 329: 116 + ```typescript 117 + ): Promise<{ requestUri: string; clientRedirectUri: string; clientState: string | null; did: string }> { 118 + ``` 119 + 120 + Change line 449: 121 + ```typescript 122 + return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did } 123 + ``` 124 + 125 + **Step 2: Build and verify** 126 + 127 + Run: `npm run build` 128 + Expected: Clean build 129 + 130 + --- 131 + 132 + ### Task 3: Set Cookie on OAuth Callback 133 + 134 + **Files:** 135 + - Modify: `packages/hatk/src/server.ts:660-670` 136 + 137 + **Step 1: Import new cookie functions** 138 + 139 + Add to the existing import from `./oauth/server.ts` (line 34-45): 140 + 141 + ```typescript 142 + import { 143 + // ... existing imports ... 144 + createSessionCookie, 145 + sessionCookieHeader, 146 + clearSessionCookieHeader, 147 + parseSessionCookie, 148 + setSessionCookieName, 149 + } from './oauth/server.ts' 150 + ``` 151 + 152 + **Step 2: Set cookie on callback redirect** 153 + 154 + Replace lines 666-667: 155 + ```typescript 156 + const result = await handleCallback(oauth, code, state, iss) 157 + return new Response(null, { status: 302, headers: { Location: result.clientRedirectUri } }) 158 + ``` 159 + 160 + With: 161 + ```typescript 162 + const result = await handleCallback(oauth, code, state, iss) 163 + const isSecure = requestOrigin.startsWith('https') 164 + const cookie = await createSessionCookie(result.did) 165 + return new Response(null, { 166 + status: 302, 167 + headers: [ 168 + ['Location', result.clientRedirectUri], 169 + ['Set-Cookie', sessionCookieHeader(cookie, isSecure)], 170 + ], 171 + }) 172 + ``` 173 + 174 + **Step 3: Build and verify** 175 + 176 + Run: `npm run build` 177 + Expected: Clean build 178 + 179 + --- 180 + 181 + ### Task 4: Add POST /auth/logout Route 182 + 183 + **Files:** 184 + - Modify: `packages/hatk/src/server.ts` (add after the `/oauth/callback` block, around line 670) 185 + 186 + **Step 1: Add the logout route** 187 + 188 + After the OAuth callback block (after line ~670, before the `/oauth/token` block): 189 + 190 + ```typescript 191 + // Session cookie logout 192 + if (url.pathname === '/auth/logout' && request.method === 'POST') { 193 + return new Response(null, { 194 + status: 200, 195 + headers: { 'Set-Cookie': clearSessionCookieHeader() }, 196 + }) 197 + } 198 + ``` 199 + 200 + **Step 2: Configure cookie name from oauth config** 201 + 202 + 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: 203 + 204 + ```typescript 205 + if (oauth?.cookieName) { 206 + setSessionCookieName(oauth.cookieName) 207 + } 208 + ``` 209 + 210 + This requires adding `cookieName?: string` to the `OAuthConfig` type in `config.ts`. 211 + 212 + **Step 3: Build and verify** 213 + 214 + Run: `npm run build` 215 + Expected: Clean build 216 + 217 + --- 218 + 219 + ### Task 5: Add cookieName to OAuthConfig 220 + 221 + **Files:** 222 + - Modify: `packages/hatk/src/config.ts` (find OAuthConfig type) 223 + 224 + **Step 1: Add the optional field** 225 + 226 + Find the `OAuthConfig` interface/type and add: 227 + 228 + ```typescript 229 + cookieName?: string 230 + ``` 231 + 232 + **Step 2: Build and verify** 233 + 234 + Run: `npm run build` 235 + Expected: Clean build 236 + 237 + --- 238 + 239 + ### Task 6: Forward Cookie Header in Vite SSR Middleware 240 + 241 + **Files:** 242 + - Modify: `packages/hatk/src/vite-plugin.ts:186-191` 243 + 244 + **Step 1: Pass cookies to Request** 245 + 246 + Change line 188 from: 247 + ```typescript 248 + const request = new Request(fullUrl.href) 249 + ``` 250 + 251 + To: 252 + ```typescript 253 + const headers: Record<string, string> = {} 254 + if (req.headers.cookie) headers.cookie = req.headers.cookie 255 + const request = new Request(fullUrl.href, { headers }) 256 + ``` 257 + 258 + **Step 2: Build and verify** 259 + 260 + Run: `npm run build` 261 + Expected: Clean build 262 + 263 + --- 264 + 265 + ### Task 7: Resolve Viewer and Set globalThis in Vite SSR 266 + 267 + **Files:** 268 + - Modify: `packages/hatk/src/vite-plugin.ts:118-125` (module loading section) 269 + - Modify: `packages/hatk/src/vite-plugin.ts:188-195` (SSR render section) 270 + - Modify: `packages/hatk/src/dev-entry.ts:99-101` (exports) 271 + 272 + **Step 1: Export parseSessionCookie from dev-entry.ts** 273 + 274 + Add to the exports at the bottom of `dev-entry.ts`: 275 + 276 + ```typescript 277 + export { parseSessionCookie } from './oauth/server.ts' 278 + ``` 279 + 280 + **Step 2: Capture parseSessionCookie from module runner** 281 + 282 + In `vite-plugin.ts`, after line 125 (`(globalThis as any).__hatk_callXrpc = mod.callXrpc`), add: 283 + 284 + ```typescript 285 + // Capture cookie parser for SSR viewer resolution 286 + ssrParseSessionCookie = mod.parseSessionCookie 287 + ``` 288 + 289 + Add the variable declaration near the top of `configureServer` (near the other `let` declarations like `handler`, `ssrRenderPage`): 290 + 291 + ```typescript 292 + let ssrParseSessionCookie: ((request: Request) => Promise<{ did: string } | null>) | null = null 293 + ``` 294 + 295 + **Step 3: Set globalThis.__hatk_viewer before render, clear after** 296 + 297 + In the SSR middleware, after creating the `request` object and before calling `ssrRenderPage`, add viewer resolution: 298 + 299 + ```typescript 300 + // Resolve viewer from session cookie for SSR 301 + let viewer: { did: string } | null = null 302 + if (ssrParseSessionCookie) { 303 + try { 304 + viewer = await ssrParseSessionCookie(request) 305 + } catch {} 306 + } 307 + ;(globalThis as any).__hatk_viewer = viewer 308 + try { 309 + const renderedHtml = await ssrRenderPage!(template, request) 310 + // ... existing code ... 311 + } finally { 312 + ;(globalThis as any).__hatk_viewer = null 313 + } 314 + ``` 315 + 316 + This replaces the current `const renderedHtml = await ssrRenderPage!(template, request)` call (line 191) with a try/finally block. 317 + 318 + **Step 4: Build and verify** 319 + 320 + Run: `npm run build` 321 + Expected: Clean build 322 + 323 + --- 324 + 325 + ### Task 8: Thread Viewer Through callXrpc 326 + 327 + **Files:** 328 + - Modify: `packages/hatk/src/xrpc.ts:310-330` 329 + 330 + **Step 1: Pass viewer from globalThis in both paths** 331 + 332 + Replace lines 317-327: 333 + 334 + ```typescript 335 + // In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis. 336 + // The runner's module instance has all registered handlers; this (Node's) instance may not. 337 + if (handlers.size === 0 && (globalThis as any).__hatk_callXrpc) { 338 + return (globalThis as any).__hatk_callXrpc(nsid, params, input) 339 + } 340 + const stringParams: Record<string, string> = {} 341 + for (const [k, v] of Object.entries(params)) { 342 + if (v != null) stringParams[k] = String(v) 343 + } 344 + const limit = params.limit ? Number(params.limit) : 20 345 + const cursor = params.cursor ?? undefined 346 + const result = await executeXrpc(nsid, stringParams, cursor, limit, null, input) 347 + ``` 348 + 349 + With: 350 + 351 + ```typescript 352 + const viewer = (globalThis as any).__hatk_viewer ?? null 353 + // In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis. 354 + // The runner's module instance has all registered handlers; this (Node's) instance may not. 355 + if (handlers.size === 0 && (globalThis as any).__hatk_callXrpc) { 356 + return (globalThis as any).__hatk_callXrpc(nsid, params, input) 357 + } 358 + const stringParams: Record<string, string> = {} 359 + for (const [k, v] of Object.entries(params)) { 360 + if (v != null) stringParams[k] = String(v) 361 + } 362 + const limit = params.limit ? Number(params.limit) : 20 363 + const cursor = params.cursor ?? undefined 364 + const result = await executeXrpc(nsid, stringParams, cursor, limit, viewer, input) 365 + ``` 366 + 367 + Note: The bridge path (`__hatk_callXrpc`) calls the runner's `callXrpc`, which will also read `globalThis.__hatk_viewer` — so both paths get the viewer. 368 + 369 + **Step 2: Build and verify** 370 + 371 + Run: `npm run build` 372 + Expected: Clean build 373 + 374 + --- 375 + 376 + ### Task 9: Add getViewer() to Codegen 377 + 378 + **Files:** 379 + - Modify: `packages/hatk/src/cli.ts:1748-1768` 380 + 381 + **Step 1: Add getViewer() export to generated client file** 382 + 383 + After the `callXrpc` function generation (after line 1768, before `writeFileSync`), add: 384 + 385 + ```typescript 386 + clientOut += `\nexport function getViewer(): { did: string } | null {\n` 387 + clientOut += ` if (typeof window === 'undefined') {\n` 388 + clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n` 389 + clientOut += ` }\n` 390 + clientOut += ` // Client-side: delegate to OAuth client\n` 391 + clientOut += ` try {\n` 392 + clientOut += ` const mod = (globalThis as any).__hatk_auth\n` 393 + clientOut += ` if (mod?.viewerDid) {\n` 394 + clientOut += ` const did = mod.viewerDid()\n` 395 + clientOut += ` return did ? { did } : null\n` 396 + clientOut += ` }\n` 397 + clientOut += ` } catch {}\n` 398 + clientOut += ` return null\n` 399 + clientOut += `}\n` 400 + ``` 401 + 402 + 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. 403 + 404 + **Step 2: Build and verify** 405 + 406 + Run: `npm run build` 407 + Expected: Clean build 408 + 409 + --- 410 + 411 + ### Task 10: Test End-to-End with Statusphere Template 412 + 413 + **Step 1: Regenerate client types** 414 + 415 + ```bash 416 + cd /Users/chadmiller/code/hatk-template-statusphere 417 + node /Users/chadmiller/code/hatk/.worktrees/server-directory/packages/hatk/dist/cli.js generate types 418 + ``` 419 + 420 + Verify `hatk.generated.client.ts` now exports `getViewer`. 421 + 422 + **Step 2: Wire up globalThis.__hatk_auth in template's auth module** 423 + 424 + In `hatk-template-statusphere/app/lib/auth.ts`, add near the bottom: 425 + 426 + ```typescript 427 + // Expose viewer for SSR getViewer() bridge 428 + ;(globalThis as any).__hatk_auth = { viewerDid } 429 + ``` 430 + 431 + **Step 3: Update Home.svelte to use getViewer()** 432 + 433 + Import `getViewer` from `../hatk.generated.client.ts` and use it to conditionally render auth UI without flash. 434 + 435 + **Step 4: Run dev server and test** 436 + 437 + ```bash 438 + cd /Users/chadmiller/code/hatk-template-statusphere 439 + npm run dev 440 + ``` 441 + 442 + 1. Visit page — should SSR the feed, auth section hidden during SSR 443 + 2. Log in — should set `__hatk_session` cookie (check DevTools → Application → Cookies) 444 + 3. Refresh page — server should resolve viewer from cookie, SSR authenticated UI 445 + 4. Log out — cookie should be cleared
+398
docs/superpowers/plans/2026-03-14-typed-xrpc-actions.md
··· 1 + # Typed XRPC Actions Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Make `callXrpc` handle procedures (POST/body) and blob uploads on client and server, with SvelteKit remote functions (`command`) for mutations. 6 + 7 + **Architecture:** Extract PDS proxy into shared functions. Register write ops as XRPC handlers. Update codegen for procedure-aware `callXrpc`. Update `getViewer()` to resolve from cookies via `getRequestEvent()`. Template uses `command()` remote functions for mutations. 8 + 9 + **Tech Stack:** TypeScript, SvelteKit remote functions, AT Protocol OAuth/DPoP 10 + 11 + --- 12 + 13 + ### Task 1: Extract PDS proxy logic into pds-proxy.ts 14 + 15 + **Files:** 16 + - Create: `packages/hatk/src/pds-proxy.ts` 17 + - Modify: `packages/hatk/src/server.ts` 18 + 19 + **Step 1: Create pds-proxy.ts** 20 + 21 + Move `proxyToPds` (server.ts:991-1052) and `proxyToPdsRaw` (server.ts:1055-1114) into a new file `packages/hatk/src/pds-proxy.ts`. Then add four high-level functions that wrap these: 22 + 23 + ```ts 24 + import type { OAuthConfig } from './config.ts' 25 + import { getSession } from './oauth/db.ts' 26 + import { getServerKey } from './oauth/db.ts' 27 + import { createDpopProof } from './oauth/dpop.ts' 28 + import { refreshPdsSession } from './oauth/server.ts' 29 + import { validateRecord } from '@bigmoves/lexicon' 30 + import { getLexiconArray } from './database/schema.ts' 31 + import { insertRecord, deleteRecord as dbDeleteRecord } from './database/db.ts' 32 + 33 + export class ProxyError extends Error { 34 + constructor(public status: number, message: string) { 35 + super(message) 36 + } 37 + } 38 + ``` 39 + 40 + Move `proxyToPds` and `proxyToPdsRaw` as-is (private functions). 41 + 42 + Then add these exported functions: 43 + 44 + - `pdsCreateRecord(oauthConfig, viewer, input: { collection, repo?, rkey?, record })` — validates record, gets session, proxies to PDS `com.atproto.repo.createRecord`, indexes locally 45 + - `pdsDeleteRecord(oauthConfig, viewer, input: { collection, rkey })` — gets session, proxies to PDS `com.atproto.repo.deleteRecord`, deletes locally 46 + - `pdsPutRecord(oauthConfig, viewer, input: { collection, rkey, record, repo? })` — validates, gets session, proxies to PDS `com.atproto.repo.putRecord`, re-indexes 47 + - `pdsUploadBlob(oauthConfig, viewer, body: Uint8Array, contentType: string)` — gets session, proxies raw to PDS `com.atproto.repo.uploadBlob` 48 + 49 + All throw `ProxyError` on auth/validation/PDS errors. 50 + 51 + Reference the existing server.ts route handlers (lines 726-848) for exact logic — each function is a direct extraction of that code. 52 + 53 + **Step 2: Update server.ts HTTP routes to use shared functions** 54 + 55 + Import `{ pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsUploadBlob, ProxyError }` from `./pds-proxy.ts`. 56 + 57 + Replace each HTTP route handler body (createRecord ~727-763, deleteRecord ~766-792, putRecord ~795-831, uploadBlob ~834-848) with: 58 + 59 + ```ts 60 + if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 61 + const body = JSON.parse(await request.text()) 62 + try { 63 + const result = await pdsCreateRecord(oauth, viewer, body) 64 + return withCors(json(result, 200, acceptEncoding)) 65 + } catch (err: any) { 66 + if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 67 + throw err 68 + } 69 + ``` 70 + 71 + (Adjust per endpoint — uploadBlob reads `request.arrayBuffer()` and passes content-type.) 72 + 73 + Delete the now-unused `proxyToPds` and `proxyToPdsRaw` from the bottom of server.ts. Remove any imports that are now only used by the deleted functions (check `getServerKey`, `createDpopProof` — they may still be used by admin proxy or elsewhere). 74 + 75 + **Step 3: Verify** 76 + 77 + Run: `npm run build` 78 + 79 + **Step 4: Commit** 80 + 81 + ``` 82 + refactor: extract PDS proxy logic into shared pds-proxy.ts 83 + ``` 84 + 85 + --- 86 + 87 + ### Task 2: Register write operations as core XRPC handlers 88 + 89 + **Files:** 90 + - Modify: `packages/hatk/src/server.ts:67-149` (registerCoreHandlers) 91 + - Modify: `packages/hatk/src/main.ts:117` 92 + - Modify: `packages/hatk/src/dev-entry.ts:61` 93 + 94 + **Step 1: Add oauth parameter to registerCoreHandlers** 95 + 96 + Change signature at server.ts:67: 97 + 98 + ```ts 99 + export function registerCoreHandlers(collections: string[], oauth: OAuthConfig | null): void { 100 + ``` 101 + 102 + Add import for `OAuthConfig` type if needed. 103 + 104 + **Step 2: Register write handlers at end of registerCoreHandlers** 105 + 106 + ```ts 107 + if (oauth) { 108 + registerCoreXrpcHandler('dev.hatk.createRecord', async (_params, _cursor, _limit, viewer, input) => { 109 + if (!viewer) throw new InvalidRequestError('Authentication required') 110 + return pdsCreateRecord(oauth, viewer, input as any) 111 + }) 112 + registerCoreXrpcHandler('dev.hatk.deleteRecord', async (_params, _cursor, _limit, viewer, input) => { 113 + if (!viewer) throw new InvalidRequestError('Authentication required') 114 + return pdsDeleteRecord(oauth, viewer, input as any) 115 + }) 116 + registerCoreXrpcHandler('dev.hatk.putRecord', async (_params, _cursor, _limit, viewer, input) => { 117 + if (!viewer) throw new InvalidRequestError('Authentication required') 118 + return pdsPutRecord(oauth, viewer, input as any) 119 + }) 120 + registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => { 121 + if (!viewer) throw new InvalidRequestError('Authentication required') 122 + return pdsUploadBlob(oauth, viewer, input as any, 'application/octet-stream') 123 + }) 124 + } 125 + ``` 126 + 127 + **Step 3: Update call sites** 128 + 129 + - `main.ts:117`: `registerCoreHandlers(collections, config.oauth)` 130 + - `dev-entry.ts:61`: `registerCoreHandlers(collections, <oauth variable>)` — check what the oauth config variable is named in dev-entry.ts 131 + 132 + **Step 4: Verify** 133 + 134 + Run: `npm run build` 135 + 136 + **Step 5: Commit** 137 + 138 + ``` 139 + feat: register write operations as core XRPC handlers 140 + ``` 141 + 142 + --- 143 + 144 + ### Task 3: Update codegen for procedure-aware callXrpc 145 + 146 + **Files:** 147 + - Modify: `packages/hatk/src/cli.ts:1718-1787` 148 + 149 + **Step 1: Collect procedure and blob nsids** 150 + 151 + After the entries loop (~line 1425), add: 152 + 153 + ```ts 154 + const procedureNsids: string[] = [] 155 + const blobInputNsids: string[] = [] 156 + for (const { nsid, defType } of entries) { 157 + if (defType === 'procedure') { 158 + const lex = lexicons.get(nsid) 159 + const inputEncoding = lex?.defs?.main?.input?.encoding 160 + if (inputEncoding === '*/*') { 161 + blobInputNsids.push(nsid) 162 + } else { 163 + procedureNsids.push(nsid) 164 + } 165 + } 166 + } 167 + ``` 168 + 169 + **Step 2: Replace callXrpc generation block** 170 + 171 + Replace lines ~1748-1769 with new code that: 172 + 173 + 1. Emits `const _procedures = new Set([...])` and `const _blobInputs = new Set([...])` 174 + 2. Uses `CallArg<K>` type instead of `ExtractParams` — resolves `input` for procedures, `params` for queries 175 + 3. Server-side bridge: procedures pass `(nsid, {}, arg)`, queries pass `(nsid, arg)` 176 + 4. Client-side: blob inputs POST raw with `blob.type`, procedures POST JSON, queries GET with query params 177 + 178 + Delete the old `ExtractParams` type emission. 179 + 180 + **Step 3: Update getViewer() to resolve from cookies** 181 + 182 + In the `getViewer()` generation block (~lines 1771-1785), update the server-side path to try `getRequestEvent()` cookies: 183 + 184 + ```ts 185 + clientOut += `\nexport function getViewer(): { did: string } | null {\n` 186 + clientOut += ` const ssrViewer = (globalThis as any).__hatk_viewer\n` 187 + clientOut += ` if (typeof window === 'undefined') {\n` 188 + clientOut += ` if (ssrViewer) return ssrViewer\n` 189 + clientOut += ` // Try resolving from request cookies (SvelteKit remote functions, load, etc.)\n` 190 + clientOut += ` try {\n` 191 + clientOut += ` const { getRequestEvent } = require('$app/server')\n` 192 + clientOut += ` const event = getRequestEvent()\n` 193 + clientOut += ` const cookieValue = event.cookies.get('__hatk_session')\n` 194 + clientOut += ` // parseSessionCookie is sync-incompatible, so we can't call it here.\n` 195 + clientOut += ` // The viewer should be set via layout.server.ts instead.\n` 196 + clientOut += ` } catch {}\n` 197 + clientOut += ` return null\n` 198 + clientOut += ` }\n` 199 + ``` 200 + 201 + Wait — `parseSessionCookie` is async. `getViewer()` is sync. We can't call it. 202 + 203 + **Revised approach:** Keep `getViewer()` sync. The viewer is already set on `globalThis.__hatk_viewer` by the layout.server.ts load function. Remote functions run in the same request context, so the viewer set during layout load is available. No change needed to `getViewer()` — it already works. 204 + 205 + Verify: in the statusphere template's `+layout.server.ts`, the load function sets `(globalThis as any).__hatk_viewer = viewer`. Since remote functions run server-side in the same request lifecycle, `getViewer()` will pick it up from `globalThis.__hatk_viewer`. 206 + 207 + **Actually** — `globalThis` is shared across all requests on the server. Setting `__hatk_viewer` in one request's layout load would leak to another request. This is a race condition. 208 + 209 + **Better approach:** Make `getViewer()` async on the server and use the `__hatk_parseSessionCookie` bridge with `getRequestEvent()`: 210 + 211 + ```ts 212 + clientOut += `\nexport async function getViewer(): Promise<{ did: string } | null> {\n` 213 + clientOut += ` if (typeof window === 'undefined') {\n` 214 + clientOut += ` try {\n` 215 + clientOut += ` const parse = (globalThis as any).__hatk_parseSessionCookie\n` 216 + clientOut += ` if (parse) {\n` 217 + clientOut += ` const { getRequestEvent } = await import('$app/server')\n` 218 + clientOut += ` const event = getRequestEvent()\n` 219 + clientOut += ` const cookieValue = event.cookies.get('__hatk_session')\n` 220 + clientOut += ` if (cookieValue) {\n` 221 + clientOut += ` const request = new Request('http://localhost', {\n` 222 + clientOut += ` headers: { cookie: \`__hatk_session=\${cookieValue}\` },\n` 223 + clientOut += ` })\n` 224 + clientOut += ` return parse(request)\n` 225 + clientOut += ` }\n` 226 + clientOut += ` }\n` 227 + clientOut += ` } catch {}\n` 228 + clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n` 229 + clientOut += ` }\n` 230 + clientOut += ` try {\n` 231 + clientOut += ` const mod = (globalThis as any).__hatk_auth\n` 232 + clientOut += ` if (mod?.viewerDid) {\n` 233 + clientOut += ` const did = mod.viewerDid()\n` 234 + clientOut += ` if (did) return { did }\n` 235 + clientOut += ` }\n` 236 + clientOut += ` } catch {}\n` 237 + clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n` 238 + clientOut += `}\n` 239 + ``` 240 + 241 + This changes `getViewer()` from sync to async. Update any existing usages. The `+layout.server.ts` in the template already has its own cookie parsing, so this mainly benefits remote functions. 242 + 243 + **Note:** The `import('$app/server')` will fail in non-SvelteKit contexts. The `try/catch` handles that gracefully, falling back to `__hatk_viewer`. 244 + 245 + **Step 4: Verify** 246 + 247 + Run: `npm run build` 248 + 249 + **Step 5: Commit** 250 + 251 + ``` 252 + feat: codegen emits procedure-aware callXrpc with async getViewer 253 + ``` 254 + 255 + --- 256 + 257 + ### Task 4: Enable remote functions in statusphere template 258 + 259 + **Files:** 260 + - Modify: `/Users/chadmiller/code/hatk-template-statusphere/svelte.config.js` 261 + - Create: `/Users/chadmiller/code/hatk-template-statusphere/app/routes/status.remote.ts` 262 + - Modify: `/Users/chadmiller/code/hatk-template-statusphere/app/routes/+page.svelte` 263 + 264 + **Step 1: Enable experimental remote functions** 265 + 266 + In `svelte.config.js`, add: 267 + 268 + ```js 269 + export default { 270 + compilerOptions: { 271 + experimental: { 272 + async: true, 273 + }, 274 + }, 275 + kit: { 276 + adapter: adapter(), 277 + files: { src: 'app' }, 278 + alias: { 279 + $hatk: './hatk.generated.ts', 280 + '$hatk/client': './hatk.generated.client.ts', 281 + }, 282 + experimental: { 283 + remoteFunctions: true, 284 + }, 285 + }, 286 + } 287 + ``` 288 + 289 + **Step 2: Regenerate client file** 290 + 291 + Run in template: `npx hatk generate` 292 + 293 + Verify `hatk.generated.client.ts` has `_procedures` set and updated `callXrpc`. 294 + 295 + **Step 3: Create remote functions file** 296 + 297 + Create `app/routes/status.remote.ts`: 298 + 299 + ```ts 300 + import { command } from '$app/server' 301 + import { callXrpc, getViewer } from '$hatk/client' 302 + 303 + export const createStatus = command(async (emoji: string) => { 304 + const viewer = await getViewer() 305 + if (!viewer) throw new Error('Not authenticated') 306 + return callXrpc('dev.hatk.createRecord', { 307 + collection: 'xyz.statusphere.status' as const, 308 + repo: viewer.did, 309 + record: { status: emoji, createdAt: new Date().toISOString() }, 310 + }) 311 + }) 312 + 313 + export const deleteStatus = command(async (rkey: string) => { 314 + const viewer = await getViewer() 315 + if (!viewer) throw new Error('Not authenticated') 316 + return callXrpc('dev.hatk.deleteRecord', { 317 + collection: 'xyz.statusphere.status' as const, 318 + rkey, 319 + }) 320 + }) 321 + ``` 322 + 323 + **Step 4: Update +page.svelte to use remote functions** 324 + 325 + Import remote functions: 326 + ```ts 327 + import { createStatus, deleteStatus } from './status.remote' 328 + ``` 329 + 330 + Remove the manual `xrpc()` helper function. 331 + 332 + Remove the local `createStatus()` and `deleteStatus()` functions and replace with calls to the imported remote functions: 333 + 334 + ```ts 335 + async function handleCreateStatus(emoji: string) { 336 + if (isMutating) return 337 + const did = data.viewer!.did 338 + const profile = viewerProfile 339 + 340 + const optimisticItem: StatusView = { 341 + uri: `at://${did}/xyz.statusphere.status/optimistic-${Date.now()}`, 342 + status: emoji, 343 + createdAt: new Date().toISOString(), 344 + author: { did, handle: profile?.handle || did, displayName: profile?.displayName }, 345 + } 346 + items = [optimisticItem, ...items] 347 + isMutating = true 348 + 349 + try { 350 + const res = await createStatus(emoji) 351 + items = items.map(i => i.uri === optimisticItem.uri ? { ...optimisticItem, uri: res.uri! } : i) 352 + } catch { 353 + items = items.filter(i => i.uri !== optimisticItem.uri) 354 + } finally { 355 + isMutating = false 356 + } 357 + } 358 + 359 + async function handleDeleteStatus(uri: string) { 360 + if (isMutating) return 361 + const removed = items.find(i => i.uri === uri) 362 + items = items.filter(i => i.uri !== uri) 363 + isMutating = true 364 + 365 + try { 366 + await deleteStatus(uri.split('/').pop()!) 367 + } catch { 368 + if (removed) items = [removed, ...items] 369 + } finally { 370 + isMutating = false 371 + } 372 + } 373 + ``` 374 + 375 + Update onclick handlers in template to use `handleCreateStatus` and `handleDeleteStatus`. 376 + 377 + Keep `loadMore` using client-side `callXrpc` directly (it's a query, not a mutation). 378 + 379 + **Step 5: Rebuild hatk and re-link** 380 + 381 + ```bash 382 + cd /path/to/hatk && npm run build 383 + cd /path/to/statusphere && npm link @hatk/hatk 384 + ``` 385 + 386 + **Step 6: Manual test** 387 + 388 + Start dev server and test: 389 + 1. Sign in via OAuth 390 + 2. Create a status (should use remote function → server-side callXrpc) 391 + 3. Delete a status 392 + 4. Load more (client-side callXrpc query) 393 + 394 + **Step 7: Commit** 395 + 396 + ``` 397 + feat: use SvelteKit remote functions for statusphere mutations 398 + ```
+68
docs/superpowers/specs/2026-03-14-ssr-auth-design.md
··· 1 + # SSR Auth Design 2 + 3 + ## Context 4 + 5 + hatk apps use DPoP-bound JWT auth for API requests, managed by `@hatk/oauth-client` in the browser. During SSR, the server has no way to identify the viewer — the Vite SSR middleware creates a bare `Request` with no headers, and DPoP tokens don't travel with page navigations. This causes a flash: the server renders the "Sign in" form, then the client hydrates and swaps in the authenticated UI. 6 + 7 + ## Goals 8 + 9 + 1. Server knows the viewer during SSR — no auth UI flash 10 + 2. `callXrpc` during SSR is automatically authenticated 11 + 3. Framework-agnostic — works with Svelte, React, Vue 12 + 4. No new database tables or dependencies 13 + 5. Coexists with existing client-side OAuth 14 + 15 + ## Design 16 + 17 + ### Cookie Lifecycle 18 + 19 + During the OAuth callback (`/oauth/callback`), after storing the PDS session, the server sets an `HttpOnly` cookie. The cookie value is a signed token: `did.timestamp.signature` — the user's DID, a Unix timestamp (seconds), and an HMAC-SHA256 signature using the server's existing OAuth keypair. 20 + 21 + Cookie attributes: 22 + - `HttpOnly` — not accessible to JavaScript 23 + - `Secure` — HTTPS only in production 24 + - `SameSite=Lax` — sent on navigations, not cross-site requests 25 + - `Path=/` 26 + - `Max-Age=2592000` (30 days) 27 + 28 + Cookie name defaults to `__hatk_session`. Configurable via `oauth: { cookieName: 'my_app_session' }` in `hatk.config.ts` for apps sharing a domain. 29 + 30 + A new `POST /auth/logout` endpoint clears the cookie (`Max-Age=0`). 31 + 32 + On SSR requests, the server parses the cookie, verifies the signature, checks the timestamp isn't expired, and extracts the DID. Invalid or missing cookie → `viewer = null`. No DB lookup needed. 33 + 34 + ### Threading the Viewer Through SSR 35 + 36 + The Vite plugin SSR middleware forwards the `Cookie` header when constructing the Request (currently creates a bare `new Request(url)`). 37 + 38 + Before calling `renderPage`, the server resolves the viewer from the cookie and sets `globalThis.__hatk_viewer = viewer` (where viewer is `{ did: string } | null`). After rendering, it clears `globalThis.__hatk_viewer` to prevent leaking between requests. 39 + 40 + `callXrpc` in the globalThis bridge automatically picks up `globalThis.__hatk_viewer` and passes it to handlers. Template code doesn't change — `await callXrpc('dev.hatk.getFeed', { feed: 'mine' })` just works with auth context. 41 + 42 + ### Component Access 43 + 44 + The generated `hatk.generated.client.ts` exports a `getViewer()` function: 45 + - During SSR: reads `globalThis.__hatk_viewer` 46 + - In the browser: delegates to the OAuth client's `viewerDid()` 47 + 48 + Components use `getViewer()` to conditionally render auth UI. The server already knows if you're logged in, so no flash. 49 + 50 + ### Always On 51 + 52 + If OAuth is configured, the session cookie is always set during callback. Templates that don't use `getViewer()` during SSR just never read it. No config flag needed to enable — you use it or you don't. 53 + 54 + ## What Changes Where 55 + 56 + - **`oauth/server.ts`** — `createSessionCookie(did, keypair)` and `parseSessionCookie(cookie, keypair)` helpers. HMAC-SHA256 sign/verify. 57 + - **`server.ts`** — `/oauth/callback` sets `Set-Cookie` header. New `POST /auth/logout` route. `resolveViewerFromCookie(request)` parses cookie → `{ did } | null`. 58 + - **`vite-plugin.ts`** — SSR middleware forwards `Cookie` header in Request. Before render, resolve viewer and set `globalThis.__hatk_viewer`. Clear after render. 59 + - **`xrpc.ts`** — `callXrpc` globalThis bridge passes `globalThis.__hatk_viewer` as the viewer argument. 60 + - **`cli.ts` (codegen)** — `hatk.generated.client.ts` gets `getViewer()` export. 61 + - **`dev-entry.ts`** — Export cookie resolution so vite plugin can call it via module runner. 62 + 63 + ## Not In Scope 64 + 65 + - Per-route auth requirements (middleware/guards) 66 + - Role-based access control 67 + - Cookie refresh/rotation (cookie outlives or matches PDS session) 68 + - CSRF protection beyond `SameSite=Lax`
+216
docs/superpowers/specs/2026-03-14-typed-xrpc-actions-design.md
··· 1 + # Typed XRPC Actions Design 2 + 3 + **Goal:** Make `callXrpc` handle procedures (POST with JSON body) and blob uploads in addition to queries, working on both client and server (SvelteKit form actions). 4 + 5 + **Architecture:** Extract PDS proxy logic into shared functions. Register write operations as core XRPC handlers so server-side `callXrpc` works. Update generated client code to detect procedures vs queries and use POST vs GET accordingly. 6 + 7 + --- 8 + 9 + ## 1. Generated Client `callXrpc` 10 + 11 + The codegen in `cli.ts` already knows which lexicons are `type: "procedure"` vs `type: "query"`. Generate two runtime sets in `hatk.generated.client.ts`: 12 + 13 + ```ts 14 + const _procedures = new Set(['dev.hatk.createRecord', 'dev.hatk.deleteRecord', 'dev.hatk.putRecord']) 15 + const _blobInputs = new Set(['dev.hatk.uploadBlob']) 16 + ``` 17 + 18 + Update `callXrpc` to branch on these: 19 + 20 + ```ts 21 + type CallArg<K extends keyof XrpcSchema> = 22 + XrpcSchema[K] extends { input: infer I } ? I : 23 + XrpcSchema[K] extends { params: infer P } ? P : 24 + Record<string, unknown> 25 + 26 + export async function callXrpc<K extends keyof XrpcSchema & string>( 27 + nsid: K, 28 + arg?: CallArg<K>, 29 + ): Promise<OutputOf<K>> { 30 + // Server-side bridge (SSR / form actions) 31 + if (typeof window === 'undefined') { 32 + const bridge = (globalThis as any).__hatk_callXrpc 33 + if (bridge) return bridge(nsid, arg) as Promise<OutputOf<K>> 34 + throw new Error('callXrpc: server bridge not available') 35 + } 36 + 37 + const url = new URL(`/xrpc/${nsid}`, window.location.origin) 38 + 39 + // Blob upload: POST raw body 40 + if (_blobInputs.has(nsid)) { 41 + const blob = arg as Blob | ArrayBuffer 42 + const contentType = blob instanceof Blob ? blob.type : 'application/octet-stream' 43 + const res = await fetch(url, { 44 + method: 'POST', 45 + headers: { 'Content-Type': contentType }, 46 + body: blob, 47 + }) 48 + if (!res.ok) throw new Error(`XRPC ${nsid} failed: ${res.status}`) 49 + return res.json() as Promise<OutputOf<K>> 50 + } 51 + 52 + // Procedure: POST JSON body 53 + if (_procedures.has(nsid)) { 54 + const res = await fetch(url, { 55 + method: 'POST', 56 + headers: { 'Content-Type': 'application/json' }, 57 + body: JSON.stringify(arg), 58 + }) 59 + if (!res.ok) throw new Error(`XRPC ${nsid} failed: ${res.status}`) 60 + return res.json() as Promise<OutputOf<K>> 61 + } 62 + 63 + // Query: GET with query params 64 + for (const [k, v] of Object.entries(arg || {})) { 65 + if (v != null) url.searchParams.set(k, String(v)) 66 + } 67 + const res = await fetch(url) 68 + if (!res.ok) throw new Error(`XRPC ${nsid} failed: ${res.status}`) 69 + return res.json() as Promise<OutputOf<K>> 70 + } 71 + ``` 72 + 73 + User-defined procedures (via `defineProcedure`) are also included in the `_procedures` set. 74 + 75 + ## 2. Shared PDS Proxy Functions 76 + 77 + Extract from `server.ts` into a new file `pds-proxy.ts`: 78 + 79 + ```ts 80 + // packages/hatk/src/pds-proxy.ts 81 + 82 + export async function proxyCreateRecord( 83 + viewer: { did: string }, 84 + input: { collection: string; repo: string; record: unknown }, 85 + ): Promise<{ uri?: string; cid?: string }> 86 + 87 + export async function proxyDeleteRecord( 88 + viewer: { did: string }, 89 + input: { collection: string; rkey: string }, 90 + ): Promise<{}> 91 + 92 + export async function proxyPutRecord( 93 + viewer: { did: string }, 94 + input: { collection: string; rkey: string; record: unknown; repo?: string }, 95 + ): Promise<{ uri?: string; cid?: string }> 96 + 97 + export async function proxyUploadBlob( 98 + viewer: { did: string }, 99 + blob: Blob | ArrayBuffer, 100 + contentType: string, 101 + ): Promise<{ blob: unknown }> 102 + ``` 103 + 104 + Each function: 105 + 1. Looks up the viewer's PDS session from the DB 106 + 2. Creates a DPoP proof for the PDS endpoint 107 + 3. Proxies the request to the PDS 108 + 4. Handles DPoP nonce retry 109 + 5. Handles token refresh if expired 110 + 111 + This is the same logic currently in `server.ts` HTTP route handlers, extracted to be reusable. 112 + 113 + ## 3. Register Write Operations as Core XRPC Handlers 114 + 115 + In `server.ts` `initServer()`, register alongside existing handlers: 116 + 117 + ```ts 118 + registerCoreXrpcHandler('dev.hatk.createRecord', async (_params, _cursor, _limit, viewer, input) => { 119 + if (!viewer) throw new InvalidRequestError('Authentication required') 120 + return proxyCreateRecord(viewer, input as any) 121 + }) 122 + 123 + registerCoreXrpcHandler('dev.hatk.deleteRecord', async (_params, _cursor, _limit, viewer, input) => { 124 + if (!viewer) throw new InvalidRequestError('Authentication required') 125 + return proxyDeleteRecord(viewer, input as any) 126 + }) 127 + 128 + registerCoreXrpcHandler('dev.hatk.putRecord', async (_params, _cursor, _limit, viewer, input) => { 129 + if (!viewer) throw new InvalidRequestError('Authentication required') 130 + return proxyPutRecord(viewer, input as any) 131 + }) 132 + 133 + registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => { 134 + if (!viewer) throw new InvalidRequestError('Authentication required') 135 + return proxyUploadBlob(viewer, input as any, 'application/octet-stream') 136 + }) 137 + ``` 138 + 139 + The existing HTTP routes in `server.ts` call the same `proxy*` functions but stay in place for direct HTTP access. 140 + 141 + ## 4. Server-Side Bridge for Procedures 142 + 143 + The `callXrpc` bridge in `xrpc.ts` already passes `input` through: 144 + 145 + ```ts 146 + export async function callXrpc(nsid, params, input) { 147 + // ... 148 + const result = await executeXrpc(nsid, stringParams, cursor, limit, viewer, input) 149 + } 150 + ``` 151 + 152 + For procedures called via the bridge, `params` will be the input body (since `callXrpc` has a single `arg` parameter). The bridge needs to detect this: if the arg is an object with procedure-shaped data (not query params), pass it as `input` rather than `params`. 153 + 154 + The simplest approach: the generated client code passes a third argument for procedures: 155 + 156 + ```ts 157 + // In generated client, server-side branch: 158 + if (_procedures.has(nsid) || _blobInputs.has(nsid)) { 159 + if (bridge) return bridge(nsid, {}, arg) as Promise<OutputOf<K>> 160 + } 161 + if (bridge) return bridge(nsid, arg) as Promise<OutputOf<K>> 162 + ``` 163 + 164 + This keeps the bridge contract clean — queries pass `(nsid, params)`, procedures pass `(nsid, {}, input)`. 165 + 166 + ## 5. Template Usage 167 + 168 + After this change, the statusphere template's `+page.svelte` replaces the manual `xrpc()` helper: 169 + 170 + ```svelte 171 + <script lang="ts"> 172 + import { callXrpc } from '$hatk/client' 173 + 174 + async function createStatus(emoji: string) { 175 + await callXrpc('dev.hatk.createRecord', { 176 + collection: 'xyz.statusphere.status', 177 + repo: did, 178 + record: { status: emoji, createdAt: new Date().toISOString() }, 179 + }) 180 + } 181 + 182 + async function deleteStatus(uri: string) { 183 + await callXrpc('dev.hatk.deleteRecord', { 184 + collection: 'xyz.statusphere.status', 185 + rkey: uri.split('/').pop()!, 186 + }) 187 + } 188 + </script> 189 + ``` 190 + 191 + And SvelteKit form actions work too: 192 + 193 + ```ts 194 + // +page.server.ts 195 + import { callXrpc } from '$hatk/client' 196 + 197 + export const actions = { 198 + create: async ({ request, cookies }) => { 199 + const data = await request.formData() 200 + await callXrpc('dev.hatk.createRecord', { 201 + collection: 'xyz.statusphere.status', 202 + repo: viewer.did, 203 + record: { status: data.get('emoji'), createdAt: new Date().toISOString() }, 204 + }) 205 + } 206 + } 207 + ``` 208 + 209 + ## Summary of Changes 210 + 211 + | File | Change | 212 + |------|--------| 213 + | `cli.ts` (codegen) | Emit `_procedures` and `_blobInputs` sets, update `callXrpc` signature and body in client file | 214 + | `pds-proxy.ts` (new) | Extracted PDS proxy functions | 215 + | `server.ts` | HTTP routes delegate to `pds-proxy.ts`, register write ops as core XRPC handlers | 216 + | `xrpc.ts` | No changes needed (already supports `input` parameter) |