···11+# SSR Auth Design
22+33+## Context
44+55+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.
66+77+## Goals
88+99+1. Server knows the viewer during SSR — no auth UI flash
1010+2. `callXrpc` during SSR is automatically authenticated
1111+3. Framework-agnostic — works with Svelte, React, Vue
1212+4. No new database tables or dependencies
1313+5. Coexists with existing client-side OAuth
1414+1515+## Design
1616+1717+### Cookie Lifecycle
1818+1919+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.
2020+2121+Cookie attributes:
2222+- `HttpOnly` — not accessible to JavaScript
2323+- `Secure` — HTTPS only in production
2424+- `SameSite=Lax` — sent on navigations, not cross-site requests
2525+- `Path=/`
2626+- `Max-Age=2592000` (30 days)
2727+2828+Cookie name defaults to `__hatk_session`. Configurable via `oauth: { cookieName: 'my_app_session' }` in `hatk.config.ts` for apps sharing a domain.
2929+3030+A new `POST /auth/logout` endpoint clears the cookie (`Max-Age=0`).
3131+3232+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.
3333+3434+### Threading the Viewer Through SSR
3535+3636+The Vite plugin SSR middleware forwards the `Cookie` header when constructing the Request (currently creates a bare `new Request(url)`).
3737+3838+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.
3939+4040+`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.
4141+4242+### Component Access
4343+4444+The generated `hatk.generated.client.ts` exports a `getViewer()` function:
4545+- During SSR: reads `globalThis.__hatk_viewer`
4646+- In the browser: delegates to the OAuth client's `viewerDid()`
4747+4848+Components use `getViewer()` to conditionally render auth UI. The server already knows if you're logged in, so no flash.
4949+5050+### Always On
5151+5252+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.
5353+5454+## What Changes Where
5555+5656+- **`oauth/server.ts`** — `createSessionCookie(did, keypair)` and `parseSessionCookie(cookie, keypair)` helpers. HMAC-SHA256 sign/verify.
5757+- **`server.ts`** — `/oauth/callback` sets `Set-Cookie` header. New `POST /auth/logout` route. `resolveViewerFromCookie(request)` parses cookie → `{ did } | null`.
5858+- **`vite-plugin.ts`** — SSR middleware forwards `Cookie` header in Request. Before render, resolve viewer and set `globalThis.__hatk_viewer`. Clear after render.
5959+- **`xrpc.ts`** — `callXrpc` globalThis bridge passes `globalThis.__hatk_viewer` as the viewer argument.
6060+- **`cli.ts` (codegen)** — `hatk.generated.client.ts` gets `getViewer()` export.
6161+- **`dev-entry.ts`** — Export cookie resolution so vite plugin can call it via module runner.
6262+6363+## Not In Scope
6464+6565+- Per-route auth requirements (middleware/guards)
6666+- Role-based access control
6767+- Cookie refresh/rotation (cookie outlives or matches PDS session)
6868+- CSRF protection beyond `SameSite=Lax`