~ knotless Search/
↑↓ move↵ open⇧↵ all results

peterullrich.com/hatk

AT Protocol app framework

Forked from chadtmiller.com/hatk

0
0
main
445 lines · 13.3 KB Raw History

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/callback block, 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
  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