~ 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:

```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<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:
```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<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`:

```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<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:

```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<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:

```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