Переглянути джерело

[Auth] Self-issued JWT rebuild: bootstrap, recovery, sessions, requireUser

Replaces Supabase GoTrue session validation with locally-minted HS256 JWTs.
Adds /api/auth/{bootstrap,me,touch}, recovery_codes + user_sessions tables,
requireUser/getCurrentUser server boundary, useCurrentUser browser boundary,
SessionKeeper for re-mint, AuthBootstrap, CI guards (no-raw-getuser,
no-is-anonymous-policy), recovery + recover pages, supabase cookie-name
pinning. Adds jose for HS256 verify; @testing-library/dom for tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User 1 місяць тому
батько
коміт
bf25014d7e
43 змінених файлів з 3548 додано та 178 видалено
  1. 4 0
      .env.example
  2. 16 9
      CLAUDE.md
  3. 10 2
      docker-compose.yml
  4. 120 0
      package-lock.json
  5. 2 0
      package.json
  6. 166 0
      research/AUTH-APPROACHES.md
  7. 174 0
      research/COOKIE-SHAPE-DECISION.md
  8. 636 0
      research/PLAN-AUTH-A.md
  9. 220 0
      src/__tests__/api/bootstrap.test.ts
  10. 100 0
      src/__tests__/api/me.test.ts
  11. 193 0
      src/__tests__/api/recovery-claim.test.ts
  12. 114 0
      src/__tests__/api/recovery-generate.test.ts
  13. 147 0
      src/__tests__/api/touch.test.ts
  14. 108 0
      src/__tests__/auth/cookies.test.ts
  15. 184 0
      src/__tests__/auth/current-user.test.ts
  16. 144 0
      src/__tests__/auth/jwt.test.ts
  17. 34 0
      src/__tests__/guards/no-is-anonymous-policy.test.ts
  18. 85 0
      src/__tests__/guards/no-raw-getuser.test.ts
  19. 61 0
      src/__tests__/integration/realtime-smoke.test.ts
  20. 124 0
      src/__tests__/integration/recovery-flow.test.ts
  21. 4 0
      src/app/(app)/layout.tsx
  22. 6 5
      src/app/(auth)/recover/page.tsx
  23. 9 14
      src/app/(auth)/recovery/page.tsx
  24. 127 0
      src/app/api/auth/bootstrap/route.ts
  25. 16 0
      src/app/api/auth/me/route.ts
  26. 77 45
      src/app/api/auth/recovery/claim/route.ts
  27. 54 49
      src/app/api/auth/recovery/generate/route.ts
  28. 37 0
      src/app/api/auth/touch/route.ts
  29. 47 0
      src/components/auth/auth-bootstrap.tsx
  30. 25 14
      src/components/auth/display-name-form.tsx
  31. 55 0
      src/components/auth/session-keeper.tsx
  32. 6 0
      src/env.ts
  33. 39 0
      src/hooks/use-current-user.ts
  34. 52 0
      src/lib/auth/cookies.ts
  35. 108 0
      src/lib/auth/current-user.ts
  36. 61 0
      src/lib/auth/jwt.ts
  37. 10 5
      src/lib/auth/rate-limit.ts
  38. 24 0
      src/lib/auth/recovery-prefix.ts
  39. 48 0
      src/lib/auth/sessions.ts
  40. 12 0
      src/lib/supabase/client.ts
  41. 3 32
      src/middleware.ts
  42. 45 3
      src/types/database.ts
  43. 41 0
      supabase/migrations/00004_recovery_and_sessions.sql

+ 4 - 0
.env.example

@@ -17,6 +17,10 @@ MASTER_ADMIN_TOTP_SECRET=your_base32_totp_secret_here
 # Session encryption (32+ characters)
 IRON_SESSION_SECRET=this_must_be_at_least_32_characters_long
 
+# Recovery code prefix-index pepper (32+ random chars; never client-exposed).
+# Generate with: openssl rand -hex 32
+RECOVERY_CODE_PEPPER=replace_with_openssl_rand_hex_32_output_at_least_32_chars
+
 # Sentry (optional)
 NEXT_PUBLIC_SENTRY_DSN=
 

+ 16 - 9
CLAUDE.md

@@ -15,8 +15,8 @@ Docker: `docker compose up --build`. Supabase Studio at `localhost:3000` (dev on
 ## API Routes
 
 ```
-TMDB Proxy:  /api/tmdb/search, /api/tmdb/* (server-side only — TMDB_API_KEY never NEXT_PUBLIC_)
-Auth:        Supabase GoTrue (signInAnonymously). /api/auth/recovery/{generate,claim}, /api/auth/signout (POST), /logout (GET/POST → redirect /)
+TMDB Proxy:  /api/tmdb/search, /api/tmdb/* (server-side only — TMDB_API_KEY never NEXT_PUBLIC_; v4 read-access JWT, ALWAYS `Authorization: Bearer`, never `?api_key=` query form — silently 401s)
+Auth:        /api/auth/bootstrap (anon onboard, server-side admin.createUser → public.users + user_sessions + cookie; per-IP rate-limited, daily circuit-breaker via BOOTSTRAP_DAILY_CAP). /api/auth/recovery/{generate,claim}, /api/auth/touch, /api/auth/me, /api/auth/signout (POST), /logout (GET/POST → redirect /). Browser code never calls signInAnonymously().
 Groups:      /api/groups, /api/groups/join (rate-limited, server-side via service role key)
 Health:      /api/health
 Admin:       /admin (TOTP login, iron-session v8) — /api/admin/logout is SEPARATE from user signout
@@ -28,7 +28,7 @@ All TMDB calls set `include_adult=false` and server-side filter by `adult` field
 
 Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 
-- `users.id` = Supabase Auth UID from `signInAnonymously()`. Recovery codes hashed with Argon2id (memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes).
+- `users.id` = Supabase Auth UID from `signInAnonymously()`. Recovery codes hashed with Argon2id (memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes) in `public.recovery_codes`.
 - `users.last_active_at` — updated on writes, throttled to 1x/24h. 12-month inactivity = auto-delete.
 - `movies.added_by` — FK with `ON DELETE SET NULL`. `trailer_url` validated against allowlist (youtube.com, themoviedb.org, imdb.com).
 - `movies.genres` stores TMDB genre **names** (e.g. `"Action"`), not numeric IDs. Genre filtering must accept both — use `filterByGenresAndEmotionsStructured` (matches IDs + names), not the legacy string tokenizer.
@@ -50,15 +50,19 @@ Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 - `PINNED_REEL_POSTERS` in `src/app/api/tmdb/reel-posters/route.ts` always-include list (deduped by `tmdb_id`); edit there to add/remove pins.
 - Landing roll result emerges in carousel center: snap math lands a poster gap exactly at viewport center, posters spread ±`SPREAD_AMOUNT`, card pops in. Card stays settled until next roll (no auto-resume).
 - Modals descended from `animate-emerge` (or any transformed ancestor) MUST `createPortal` to `document.body` — `transform: scale(1)` from `fill-mode: both` establishes a containing block and clamps `fixed inset-0` to the ancestor's box.
+- **Terminology**: UI says "List", code/DB/routes say "group" (legacy). "Create List" button → `/create-group`; list detail → `/list/[id]` (reads `groups` table); list settings → `/list/[id]/settings` (mounts `SettingsPanel`). Don't add a `/create` route — point new "Create List" CTAs at `/create-group`.
+- Create-list form `router.push`es to `/list/{id}` on success (no in-form invite-code panel). List header is a centered vertical stack: name (`text-2xl sm:text-3xl`), uppercase "JOIN CODE" eyebrow + mono chip, settings cog. Destructive actions (SettingsPanel + per-movie Watched/Delete in `ListMoreInfoModal`) use shake-to-arm (`animate-shake`, 4s auto-disarm) — no `window.confirm`. Admin delete with other members shows an inline successor picker; selection runs transfer + leave atomically. Solo-admin delete and leave both `router.push("/")`.
+- List grid (`/home`) and movie cards: `PosterCard` is a clickable button (whole-card target) that opens `ListMoreInfoModal` — no ExpandedPanel. Watched movies show a green-circle checkmark badge top-left; added-by avatar dot top-right; decorative "i" glyph bottom-right. Both list-page and `/home` rolls render `ListRollCarousel` (horizontal poster strip, dice-emerge entrance → spin → snap-to-gap → ±110px spread + emerge teaser w/ gold glow). `movies` table doesn't store TMDB overview — `ListMoreInfoModal` fetches `/api/tmdb/movie/[id]` on open.
 
 ## Auth
 
-- Users: Supabase Anonymous Sign-In → JWT via GoTrue → cookie-based sessions via `@supabase/ssr`
-- Signout: `<SignOutButton />` in `(app)` header → `POST /api/auth/signout` → `queryClient.clear()` → hard nav to `/`. Linkable variant: `GET /logout` (redirects). No confirm dialog, even for users without a saved recovery code.
-- Recovery page (`/recovery`): code generation uses `useQuery` (not `useMutation`) keyed on `recovery-code-generate` with `staleTime/gcTime: Infinity` so React 19 StrictMode dev double-mount + post-signup nav don't lose the response. Reload regenerates (server overwrites `users.recovery_code`).
-- Recovery: 24-char alphanumeric (128-bit entropy), Argon2id hashed, single-use, claim rate-limited (5/15min per IP)
-- Admin: username + TOTP (otplib), iron-session v8 (HttpOnly, Secure, SameSite=Strict, 8h expiry)
-- GoTrue config: `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true`, all other auth methods disabled
+- Users: Supabase Anonymous Sign-In via GoTrue (`signInAnonymously()`). After sign-in, all session validation is local: see Trust Boundary below.
+- Signout: `<SignOutButton />` in `(app)` header → `POST /api/auth/signout` → `queryClient.clear()` → hard nav to `/`. Linkable variant: `GET /logout`.
+- Recovery: 24-char alphanumeric code (128-bit entropy), Argon2id-hashed in `public.recovery_codes` with a peppered HMAC prefix index for O(1) lookup (`RECOVERY_CODE_PEPPER` env). Generate route uses `requireUser()` for auth, rate-limits per uid, idempotent (409 if already present). Claim atomically deletes via `DELETE...RETURNING`, mints a fresh HS256 JWT (`JWT_SECRET`, `kid: "v1"`, `iss: "moviedice"`, `aud: "authenticated"`, `nbf: -10s`), inserts a `user_sessions` row, writes the `@supabase/ssr` cookie. Sessions: 1h access TTL, re-mint via `POST /api/auth/touch` (driven by `<SessionKeeper />` in the `(app)` layout, fires every 5 min and on 401 hard-clears cache + nav to `/`); absolute cap 30d via `iat_original` claim. Revoke by setting `user_sessions.revoked_at`. Recovery page (`/recovery`) uses `useQuery` keyed `recovery-code-generate` with `staleTime/gcTime: Infinity` so StrictMode double-mount doesn't lose the response.
+- **Trust boundary**: every server caller uses `requireUser(req)` / `getCurrentUser(req)` from `src/lib/auth/current-user.ts`; every browser caller uses `useCurrentUser()` from `src/hooks/use-current-user.ts` (TanStack Query against `/api/auth/me`). **Do NOT call `supabase.auth.getUser()` or `supabase.auth.getSession()` anywhere else** — GoTrue rejects our minted JWTs (their `session_id` is not in `auth.sessions`). PostgREST and Realtime accept them via HS256 signature alone, which is sufficient for RLS. CI guard at `src/__tests__/guards/no-raw-getuser.test.ts` enforces. The admin-API form `admin.auth.getUser(bearerToken)` is a different surface (Bearer-token validation) and is allowed.
+- All users remain `is_anonymous=true` in `auth.users` — that flag has NO semantic meaning under this design. Do not gate any policy or UI on `auth.jwt()->>'is_anonymous'`. CI guard at `src/__tests__/guards/no-is-anonymous-policy.test.ts` enforces in migrations.
+- Admin: username + TOTP (otplib), iron-session v8 (HttpOnly, Secure, SameSite=Strict, 8h expiry).
+- GoTrue config: `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true`, all other auth methods disabled.
 
 ## Security
 
@@ -86,5 +90,8 @@ SUPABASE_INTERNAL_URL                     # Docker internal Kong URL (server-sid
 SUPABASE_SERVICE_ROLE_KEY                 # server-side admin ops
 MASTER_ADMIN_USERNAME / _TOTP_SECRET      # admin auth
 IRON_SESSION_SECRET                       # 32+ chars
+JWT_SECRET                                # HS256 signing key for minted access tokens (shared with GoTrue)
+RECOVERY_CODE_PEPPER                      # 32+ chars; HMAC pepper for recovery_codes prefix index
+BOOTSTRAP_DAILY_CAP                       # int, default 10000; daily anon-user creation circuit breaker
 GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED   # must be true
 ```

+ 10 - 2
docker-compose.yml

@@ -24,6 +24,8 @@ services:
       - MASTER_ADMIN_USERNAME=${MASTER_ADMIN_USERNAME}
       - MASTER_ADMIN_TOTP_SECRET=${MASTER_ADMIN_TOTP_SECRET}
       - IRON_SESSION_SECRET=${IRON_SESSION_SECRET}
+      - JWT_SECRET=${JWT_SECRET}
+      - RECOVERY_CODE_PEPPER=${RECOVERY_CODE_PEPPER}
     networks:
       - internal
 
@@ -70,6 +72,7 @@ services:
 
   # ─── Supabase: GoTrue (Auth) ──────────────────────────────────────
   supabase-auth:
+    # pinned for supabase/auth#2013 workaround — see /home/user/.claude/plans/exactly-yes-precious-knuth.md
     image: supabase/gotrue:v2.170.0
     restart: unless-stopped
     logging: *default-logging
@@ -86,7 +89,12 @@ services:
 
       GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
       GOTRUE_URI_ALLOW_LIST: ""
-      GOTRUE_DISABLE_SIGNUP: "false"
+      # Paired with GOTRUE_EXTERNAL_EMAIL_ENABLED below: the recovery
+      # synthetic-identity design (<uid>@moviedice.invalid + HKDF password)
+      # requires signInWithPassword, so email login must be on. Public signups
+      # are disabled so the email path stays admin-only (admin.updateUserById
+      # in /api/auth/recovery/generate).
+      GOTRUE_DISABLE_SIGNUP: "true"
 
       GOTRUE_JWT_SECRET: ${JWT_SECRET}
       GOTRUE_JWT_EXP: "3600"
@@ -96,7 +104,7 @@ services:
       GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true"
 
       # Disable all other auth methods
-      GOTRUE_EXTERNAL_EMAIL_ENABLED: "false"
+      GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
       GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
       GOTRUE_MAILER_AUTOCONFIRM: "false"
       GOTRUE_SMS_AUTOCONFIRM: "false"

+ 120 - 0
package-lock.json

@@ -14,6 +14,7 @@
         "@t3-oss/env-nextjs": "^0.12.0",
         "@tanstack/react-query": "^5.75.5",
         "iron-session": "^8.0.4",
+        "jose": "^6.2.3",
         "next": "16.2.2",
         "otplib": "^12.0.1",
         "react": "19.2.4",
@@ -24,6 +25,7 @@
       "devDependencies": {
         "@sentry/nextjs": "^9.14.0",
         "@tailwindcss/postcss": "^4",
+        "@testing-library/dom": "^10.4.1",
         "@testing-library/react": "^16.3.0",
         "@types/node": "^20",
         "@types/react": "^19",
@@ -4159,6 +4161,36 @@
         "react": "^18 || ^19"
       }
     },
+    "node_modules/@testing-library/dom": {
+      "version": "10.4.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+      "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/runtime": "^7.12.5",
+        "@types/aria-query": "^5.0.1",
+        "aria-query": "5.3.0",
+        "dom-accessibility-api": "^0.5.9",
+        "lz-string": "^1.5.0",
+        "picocolors": "1.1.1",
+        "pretty-format": "^27.0.2"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@testing-library/dom/node_modules/aria-query": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+      "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "dequal": "^2.0.3"
+      }
+    },
     "node_modules/@testing-library/react": {
       "version": "16.3.2",
       "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
@@ -4197,6 +4229,13 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@types/aria-query": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+      "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -6035,6 +6074,16 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/dequal": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/detect-libc": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -6057,6 +6106,13 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/dom-accessibility-api": {
+      "version": "0.5.16",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+      "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/dotenv": {
       "version": "16.6.1",
       "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -8235,6 +8291,15 @@
         "jiti": "lib/jiti-cli.mjs"
       }
     },
+    "node_modules/jose": {
+      "version": "6.2.3",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+      "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -8832,6 +8897,16 @@
         "yallist": "^3.0.2"
       }
     },
+    "node_modules/lz-string": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+      "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "lz-string": "bin/bin.js"
+      }
+    },
     "node_modules/magic-string": {
       "version": "0.30.21",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -10062,6 +10137,51 @@
         "url": "https://github.com/prettier/prettier?sponsor=1"
       }
     },
+    "node_modules/pretty-format": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1",
+        "ansi-styles": "^5.0.0",
+        "react-is": "^17.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/pretty-format/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pretty-format/node_modules/ansi-styles": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/pretty-format/node_modules/react-is": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/progress": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",

+ 2 - 0
package.json

@@ -21,6 +21,7 @@
     "@t3-oss/env-nextjs": "^0.12.0",
     "@tanstack/react-query": "^5.75.5",
     "iron-session": "^8.0.4",
+    "jose": "^6.2.3",
     "next": "16.2.2",
     "otplib": "^12.0.1",
     "react": "19.2.4",
@@ -31,6 +32,7 @@
   "devDependencies": {
     "@sentry/nextjs": "^9.14.0",
     "@tailwindcss/postcss": "^4",
+    "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.0",
     "@types/node": "^20",
     "@types/react": "^19",

+ 166 - 0
research/AUTH-APPROACHES.md

@@ -0,0 +1,166 @@
+# Auth Approaches: Zero-Friction Onboarding + Recovery Code
+
+**Status:** research / landscape doc, not a plan.
+**Constraint set:** self-hosted Supabase (Postgres + GoTrue + PostgREST + Realtime + Kong); Next.js App Router; no email / SMS / OAuth; recovery code is the sole credential; long-lived cookie sessions; RLS desirable.
+
+The current implementation (anonymous sign-in → synthetic `<uid>@moviedice.invalid` email + HKDF password promotion) is the system under reconsideration. Driving pain points: GoTrue [#2013](https://github.com/supabase/auth/issues/2013) (`admin.updateUserById` does not flip `is_anonymous`/`role`/`aud` reliably for promoted users), needing `EMAIL_ENABLED=true` to admit a synthetic email which re-exposes `/recover`, `/otp`, `/magiclink`, `PUT /user`, then plugging that with Kong denylists, plus a CHECK constraint on `auth.users.email` to keep the synthetic domain pinned. Officially: "There is no way to convert an anonymous user to a permanent user without sending a confirmation email" and "recovery codes are not supported in Supabase Auth" (Supabase docs, anon sign-in section).
+
+---
+
+## A. Stay on GoTrue anonymous + custom recovery layer (re-issue same-UID JWT)
+
+**Mechanism.** First visit: browser calls `signInAnonymously()`; GoTrue mints a JWT with `sub = <uid>`, `is_anonymous = true`, sets cookies via `@supabase/ssr`. Server route `/api/recovery/generate` creates a 24-char code, stores `argon2id(code)` in a custom `recovery_codes(user_id, hash)` table, returns plaintext once. On a new device the user pastes the code into `/api/recovery/claim`; the server scans `recovery_codes`, verifies with Argon2, then **mints a fresh GoTrue-shaped JWT (HS256 with `JWT_SECRET`) using the original `sub`**, sets the access + refresh cookies directly. GoTrue is never asked to "promote" anything. The user stays `is_anonymous=true` forever; the app treats that flag as meaningless.
+
+**Where the credential lives.** Recovery hash in a custom table you own. GoTrue still owns `auth.users` and the JWT signing secret. No synthetic email, no synthetic password, no `auth.users.email` CHECK constraint, no Kong denylists for `/recover|/otp|/magiclink` because `EMAIL_ENABLED` stays `false`.
+
+**RLS.** `auth.uid()` resolves natively (it reads `request.jwt.claims->>sub`). All existing policies keep working. `auth.jwt()->>'is_anonymous'` will read `true` — if any policy uses that, audit/relax it.
+
+**What we lose.** Refresh-token rotation has to be reimplemented if you mint your own access tokens (you can sidestep this by inserting a row into `auth.refresh_tokens` and letting GoTrue refresh as normal, but that is internal-schema coupling). You also lose `supabase.auth.signInWith*` ergonomics on claim — claim becomes a server-only flow that writes cookies directly.
+
+**Build cost.** ~250–400 LOC: a `recovery_codes` table + migration, two routes (generate/claim), a small JWT mint helper using `jose`, and cookie writes matching `@supabase/ssr` names. Throw away the synthetic identity module + the `00003` migration + Kong denylists.
+
+**Failure modes.** (1) Forging a refresh-token row by hand is fragile across GoTrue upgrades — safer to mint short-lived access JWTs with longer `exp` (e.g. 7 days) and re-mint on a "touch" endpoint, which loses GoTrue refresh semantics entirely. (2) If `JWT_SECRET` ever rotates, every minted token dies; same as today. (3) Anonymous-user RLS auditing: every policy that special-cases `is_anonymous` becomes a footgun because all your real users are flagged anonymous. (4) Argon2 scan over the recovery table is O(n) — needs a prefix index column (first N chars of code, hashed with a fast HMAC under a server pepper) to keep claim under ~50ms at scale.
+
+**Verdict.** Strong default. Removes the entire promotion fight and all the security review fallout from `EMAIL_ENABLED=true`, while keeping `auth.uid()`, GoTrue user rows, and Realtime auth working unchanged. The "all users are anonymous forever" framing is honest: in a passwordless system, the recovery code IS the only credential, so the GoTrue "promoted" bit was never carrying real meaning anyway.
+
+---
+
+## B. Custom JWT, no GoTrue for users at all
+
+**Mechanism.** Skip `signInAnonymously`. On first visit, `/api/auth/bootstrap` creates a row in a custom `app_users(id uuid pk, created_at, last_active_at)`, mints an HS256 JWT signed with `JWT_SECRET` carrying `{sub: id, role: 'authenticated', aud: 'authenticated', exp}`, sets it as an HttpOnly cookie. Recovery generate/claim work as in A but mint the same custom JWT. GoTrue runs only for the admin TOTP login (which already uses iron-session, not GoTrue — so GoTrue could even be removed for users entirely, kept only if admin ever migrates).
+
+**Where the credential lives.** Fully custom: `app_users` + `recovery_codes`. `auth.users` is unused (or kept only for admin/internal).
+
+**RLS.** `auth.uid()` is just `(current_setting('request.jwt.claims', true)::json->>'sub')::uuid` — works as long as the JWT has a `sub` claim and the `role` claim is `authenticated`. PostgREST and Realtime accept any HS256 token signed with the configured secret ([PostgREST auth docs](https://docs.postgrest.org/en/v12/references/auth.html); [Supabase realtime + custom JWT discussion #11826](https://github.com/orgs/supabase/discussions/11826)). FKs that point at `auth.users(id)` (`movies.added_by`, `users.id`) need to repoint at `app_users(id)`.
+
+**What we lose.** GoTrue session refresh, GoTrue user management UI in Studio (Studio's user list will be empty), any future ability to add OAuth/email "for real" without re-onboarding. Realtime Presence still works (it cares about JWT `sub`, not GoTrue).
+
+**Build cost.** ~400–700 LOC + a non-trivial migration that repoints every `auth.users(id)` FK. Token refresh and rotation must be hand-rolled (sliding-window cookie re-mint on each authenticated request is the simplest pattern — not great cryptographically but acceptable for this threat model).
+
+**Failure modes.** (1) Re-implementing JWT lifecycle is the classic "rolled my own auth" trap — token revocation, clock-skew on `exp`, refresh races. (2) If `JWT_SECRET` is ever exposed, every account is forgeable; same with GoTrue, but you've lost the layer of separation. (3) The whole Supabase Auth subsystem becomes dead weight in the compose file — tempting to remove, painful to add back.
+
+**Verdict.** Cleanest model on paper, highest carrying cost in practice. Right pick if you decide GoTrue is a net liability and want to delete it. Wrong pick if you might ever want OAuth or email later, or if Studio's user view is part of your ops workflow.
+
+---
+
+## C. Iron-session opaque cookies, no JWT for users
+
+**Mechanism.** First visit: server route creates `app_users` row, sets an iron-session encrypted HttpOnly cookie containing `{userId}`. Every API route reads the cookie, looks up the user, queries Postgres via the **service role key** with explicit `WHERE owner = $userId` filters in app code. Browser never speaks to PostgREST or Realtime directly.
+
+**Where the credential lives.** Server-encrypted cookie (iron-session AEAD); user identity in `app_users`. No JWT anywhere on the user path. Recovery code table same as A/B.
+
+**RLS.** Effectively bypassed — you're always service-role. Authorization moves entirely into app code (`WHERE` clauses on every query, `assertOwner()` helpers). For defense-in-depth you can still keep RLS enabled with deny-all for `anon`/`authenticated` and let service role bypass, but it's belt-and-suspenders.
+
+**What we lose.** Browser-side Supabase client is dead. Realtime browser subscriptions tied to user identity stop working — would need a server-side proxy (SSE / WebSocket from your Next.js routes) to fan out updates. For MovieDice today this matters: list pages subscribe to `movies` changes in-browser per `CLAUDE.md` ("Real-time: subscribe on mount, unsubscribe on unmount"). Replacing that is a significant rework.
+
+**Build cost.** ~300 LOC for auth, but **multiplied by every existing data-fetching site** that currently uses `createBrowserClient`. Plus a Realtime bridge if you keep that feature. Realistically 1500+ LOC of net change.
+
+**Failure modes.** (1) Forgetting a `WHERE owner = $userId` is a full IDOR — RLS was the safety net, now there is none. (2) Service role key in every route handler widens blast radius if any route is RCE'd or has SSRF. (3) Cookie size / rotation if you stuff anything beyond a uid.
+
+**Verdict.** Right pick when you want Postgres as a private datastore and don't need browser real-time. Wrong pick here — MovieDice depends on browser Realtime and on RLS as the authorization model. Migrating to opaque cookies is a re-architecture, not an auth swap.
+
+---
+
+## D. Off-the-shelf session library (better-auth, Lucia, Auth.js)
+
+**Mechanism (better-auth specifically).** Install `better-auth` with the [anonymous plugin](https://better-auth.com/docs/plugins/anonymous). First visit: `signInAnonymous()` issues a session cookie backed by a server-side `session` table (Postgres adapter). Recovery becomes a custom credential provider: code generation stores `argon2(code)` in a `recovery_codes` table; claim looks up the original anonymous user, calls `onLinkAccount({anonymousUser, newUser})` to merge — except for our case there _is_ no new user, you'd just rotate the session to the original user id. Lucia v3 [is being sunset by March 2025](https://github.com/lucia-auth/lucia/discussions/1714) — not viable. Auth.js (NextAuth) has no first-class anonymous concept; community recipes use a "Credentials" provider returning a synthetic user, which is essentially approach B with extra glue.
+
+**Where the credential lives.** better-auth's own tables (`user`, `session`, `account`) plus a custom `recovery_codes` table.
+
+**RLS.** Doesn't help — better-auth manages its own session cookie, not a Postgres-aware JWT. To keep RLS, you'd run better-auth alongside GoTrue (each route exchanges the better-auth session for a minted Supabase JWT) — at which point you're back to approach B with an extra moving part.
+
+**What we lose.** Tight Supabase coupling. `auth.uid()` doesn't resolve unless you bridge.
+
+**Build cost.** Lower for the auth plumbing (~150 LOC for the integration), higher for the bridge (~300 LOC + ongoing). Plus a new dependency surface in security review.
+
+**Failure modes.** (1) better-auth's anonymous plugin has known sharp edges ([#3658](https://github.com/better-auth/better-auth/issues/3658), [#3267](https://github.com/better-auth/better-auth/issues/3267)) for auto-sign-in. (2) Recovery flow isn't a built-in primitive — you build it anyway. (3) Adding a second auth system to a project that already has GoTrue + iron-session for admin = three session systems in one app. (4) "Self-hosted Supabase" memory implies committing to that stack; bypassing GoTrue with a third lib invites drift.
+
+**Verdict.** Net negative for this project. better-auth is well-designed but the value prop is "we handle auth providers for you" — when there's exactly one credential type (a recovery code), the library is mostly overhead. Right pick on a greenfield project that wants OAuth + magic links + passkeys + anonymous all in one. Wrong pick when the constraint is explicitly "no email, no OAuth, just one code."
+
+---
+
+## E. WebAuthn / passkeys as the recovery mechanism
+
+**Mechanism.** First visit: anonymous sign-in (any approach). User registers a passkey (resident/discoverable credential) bound to their account. On a new device, the user does `navigator.credentials.get()` — the OS picks a passkey, the server verifies the signed challenge, sets a session.
+
+**Where the credential lives.** Public key in a `webauthn_credentials` table; private key in the user's authenticator (OS keychain, hardware key, password manager).
+
+**RLS.** Same options as A/B — passkeys are just an alternative _claim_ mechanism, the session that follows can still be a Supabase JWT.
+
+**What we lose.** The "write a code on paper" mental model. Passkeys assume the user has a synced authenticator (iCloud Keychain, Google Password Manager, 1Password). If they don't, or they explicitly want a copy-paste-able recovery string, this fails the product requirement.
+
+**Build cost.** ~600–900 LOC (`@simplewebauthn/server` + browser plumbing + cross-device QR ceremony for "log in here from another device that already has the passkey").
+
+**Failure modes.** Cross-device passkey transfer is OS-specific and still rough. Users without a passkey-capable browser/OS are locked out. The "I lose my phone" recovery story is now Apple's / Google's problem, not yours — which is great until iCloud Keychain has an outage.
+
+**Verdict.** Wrong fit for the stated requirement ("paste the code"). Right pick if the product evolves to "an account I want to use seriously" and the recovery code becomes a fallback rather than the primary mechanism. Worth keeping in mind as a _second_ recovery factor later, not a replacement.
+
+---
+
+## F. Hybrid: GoTrue anonymous JWT + UID indirection table
+
+**Mechanism.** GoTrue mints anonymous JWTs as today. A custom `app_user_aliases(app_uid pk, current_auth_uid)` table maps stable app identity to the current GoTrue uid. On recovery claim, server calls `signInAnonymously()` to mint a _new_ GoTrue user, then updates the alias row to repoint `app_uid → new_auth_uid`. App code reads `app_uid` from a separate signed cookie or a JWT custom claim.
+
+**Where the credential lives.** Recovery hash in custom table; identity split between `auth.users` (ephemeral) and `app_user_aliases` (stable).
+
+**RLS.** `auth.uid()` returns the _current_ GoTrue uid, not the stable app uid — every policy needs rewriting to `EXISTS (SELECT 1 FROM app_user_aliases WHERE current_auth_uid = auth.uid() AND app_uid = <row owner>)`. That's a join per policy check. Or you stuff `app_uid` into a custom JWT claim, which means re-minting GoTrue tokens (back to approach A territory).
+
+**What we lose.** The simplicity of `auth.uid() = owner_id`. Every FK from `movies.added_by` etc. now has to point at `app_uid`, with a trigger or join to map. Old anonymous user rows in `auth.users` accumulate as garbage.
+
+**Build cost.** Conceptually small (~200 LOC) but the RLS rewrite + FK migration is high-risk.
+
+**Failure modes.** (1) Race during repoint: if a write happens mid-claim, it's attributed to the wrong `auth.uid()`. (2) `auth.users` table grows unboundedly with orphaned anonymous rows. (3) Two sources of truth for "who is this user" is exactly the bug surface that #2013 already taught us to avoid.
+
+**Verdict.** Strictly worse than A. The whole point of indirection is to avoid promotion, but A avoids promotion _without_ indirection by just re-minting a same-`sub` JWT. Skip.
+
+---
+
+## Comparison
+
+| Approach                                                  | Onboard friction        | Recovery friction            | Build cost                                           | Blast radius on bugs                              | RLS-compatible                 | Breaks Realtime             | Depends on GoTrue |
+| --------------------------------------------------------- | ----------------------- | ---------------------------- | ---------------------------------------------------- | ------------------------------------------------- | ------------------------------ | --------------------------- | ----------------- |
+| **A** GoTrue anon + custom recovery, re-mint same-sub JWT | none                    | paste code                   | low (~300 LOC, deletes more than it adds)            | low (custom code is small + audited)              | yes, native `auth.uid()`       | no                          | yes (lightly)     |
+| **B** Custom JWT, no GoTrue for users                     | none                    | paste code                   | high (~600 LOC + FK migration)                       | medium (you own the JWT lifecycle)                | yes, but you own the claims    | no                          | no                |
+| **C** Iron-session opaque cookies                         | none                    | paste code                   | very high (auth + every data path + realtime bridge) | high (no RLS safety net; service role everywhere) | n/a (bypassed)                 | yes (browser realtime gone) | no                |
+| **D** better-auth + bridge                                | none (with anon plugin) | custom flow anyway           | medium (~450 LOC + new dep)                          | medium (third-party surface)                      | only via bridge                | requires bridge             | optionally        |
+| **E** WebAuthn passkeys                                   | none                    | passkey ceremony, not a code | high (~800 LOC)                                      | low                                               | yes                            | no                          | yes               |
+| **F** GoTrue anon + alias indirection                     | none                    | paste code                   | medium (RLS rewrite is the cost)                     | high (race + orphan rows)                         | yes but every policy rewritten | no                          | yes               |
+
+---
+
+## Recommendation
+
+**Pick A. Strong second is B if you've already concluded GoTrue is more trouble than it's worth.**
+
+Reasoning, plainly:
+
+- The current pain is entirely caused by trying to _change a user's nature_ in GoTrue (anonymous → permanent) when the product never actually needed that change. The product needs one thing: "given a valid recovery code, give the same user a fresh session." Approach A does exactly that and nothing more. No `EMAIL_ENABLED=true`, so no `/recover|/otp|/magiclink|PUT /user` exposure, so no Kong denylists. No synthetic email, so no `auth.users.email` CHECK. No promotion, so [#2013](https://github.com/supabase/auth/issues/2013) is irrelevant. `auth.uid()` continues to resolve, RLS policies are unchanged, browser Realtime keeps working. The `is_anonymous` flag on every user becomes meaningless metadata — but that's accurate; in a passwordless system every user is, by definition, only as identified as their recovery code.
+
+- The honest downside of A is that you're hand-minting JWTs with `JWT_SECRET` on the claim path, which means you're trusting `jose` (or equivalent) and your own cookie code instead of GoTrue's. That's ~50 lines of code, well-scoped, easy to test. Compare against the current footprint (synthetic email module, HKDF derivation, 2-step admin-API promote with rollback, post-condition verification, CHECK migration, Kong denylist for 4 endpoints, custom GoTrue config to enable email then plug it again) — A is _less_ code and a _smaller_ security surface, not more.
+
+- B is the right answer if the team ever decides "we don't actually use GoTrue's value-add, why is it in our compose file." Don't do B today; do A today, and if a year from now `signInAnonymously` is the only GoTrue feature you touch, retiring GoTrue is a one-week project and B is the destination.
+
+- Do **not** adopt C, D, E, or F as the primary mechanism. C breaks Realtime; D adds a dep without solving the actual problem; E doesn't match the "type the code" requirement; F is A with extra steps and worse RLS.
+
+**One tactical note for whoever implements A:** look at GoTrue's `auth.refresh_tokens` table before deciding whether to (a) hand-mint short-lived access tokens and re-mint on a touch endpoint, or (b) insert a refresh-token row directly so `supabase.auth.refreshSession()` keeps working in the browser. Option (a) is more decoupled from GoTrue internals; option (b) is more ergonomic. The trade-off is roughly "30 lines of code" vs "you'll have to re-verify on every GoTrue upgrade," and the right answer probably depends on how often you intend to upgrade GoTrue.
+
+---
+
+## Sources
+
+- [Supabase Auth #2013 — admin.updateUserById behavior with anonymous users](https://github.com/supabase/auth/issues/2013)
+- [Supabase Auth #1578 — updateUser vs admin.updateUserById with anonymous users](https://github.com/supabase/auth/issues/1578)
+- [Supabase docs — Anonymous Sign-Ins](https://supabase.com/docs/guides/auth/auth-anonymous)
+- [Supabase discussion #29017 — convert anonymous user to permanent with password](https://github.com/orgs/supabase/discussions/29017)
+- [Supabase docs — Users (recovery codes not supported, max 10 MFA factors)](https://supabase.com/docs/guides/auth/users)
+- [Supabase discussion #11826 — Realtime with custom JWT](https://github.com/orgs/supabase/discussions/11826)
+- [Supabase discussion #18273 — How to create a custom JWT](https://github.com/orgs/supabase/discussions/18273)
+- [Supabase docs — JSON Web Tokens](https://supabase.com/docs/guides/auth/jwts)
+- [PostgREST 12 Authentication reference](https://docs.postgrest.org/en/v12/references/auth.html)
+- [better-auth — Anonymous plugin](https://better-auth.com/docs/plugins/anonymous)
+- [better-auth #3658 — anonymous plugin after-hook bug](https://github.com/better-auth/better-auth/issues/3658)
+- [Lucia v3 docs](https://v3.lucia-auth.com/) (note: Lucia is being sunset)
+- [Lucia #1475 — Feature Request: Anonymous/Guest Sessions](https://github.com/lucia-auth/lucia/issues/1475)
+- [Standard Notes — Encryption whitepaper](https://standardnotes.com/help/security/encryption)
+- [Bitwarden — Two-step recovery code](https://bitwarden.com/help/two-step-recovery-code/)
+- [Bitwarden — Emergency Access](https://bitwarden.com/help/emergency-access/)

+ 174 - 0
research/COOKIE-SHAPE-DECISION.md

@@ -0,0 +1,174 @@
+# Cookie-Shape Decision — Stage 1 (auth-A rewrite)
+
+**Date:** 2026-05-06
+**Probe target:** `@supabase/ssr@0.6.1` + `@supabase/auth-js@2.101.1`, MovieDice self-hosted Supabase (GoTrue v2.170.x via Kong 2.8.1).
+**Cookie name in this project:** `sb-localhost-auth-token` (derived in `src/lib/supabase/cookie-name.ts`).
+
+---
+
+## 1. Empirical probe (current `signInAnonymously()` flow)
+
+`/tmp/probe-cookie.mjs` instantiates `createServerClient` with an in-memory cookie jar, calls `signInAnonymously()` against the live stack, and dumps everything `setAll` receives.
+
+**Result — exactly one cookie was set:**
+
+| Field        | Value                                                           |
+| ------------ | --------------------------------------------------------------- |
+| name         | `sb-localhost-auth-token`                                       |
+| length       | 1254 bytes (well under 3180 chunk threshold)                    |
+| value prefix | `base64-eyJhY2Nlc...` (literal `base64-` then base64url)        |
+| options      | `{ path:"/", sameSite:"lax", httpOnly:false, maxAge:34560000 }` |
+
+Stripping the `base64-` prefix and base64url-decoding yields a single JSON **object** (not an array):
+
+```json
+{
+  "access_token": "<eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…>",
+  "token_type": "bearer",
+  "expires_in": 3600,
+  "expires_at": 1778040370,
+  "refresh_token": "Qhx2LTqz…",
+  "user": { "id":"…", "aud":"authenticated", "role":"authenticated", … }
+}
+```
+
+No chunking observed in this run. Chunking kicks in only above 3180 URL-encoded bytes (see source-read below); a session with a fully populated `user` object stays comfortably below that.
+
+---
+
+## 2. Source-read confirmation
+
+Reading `node_modules/@supabase/ssr/dist/main/cookies.js`, `createServerClient.js`, `createBrowserClient.js`, `utils/chunker.js`, `utils/constants.js`:
+
+- **Default `cookieEncoding` is `"base64url"`** for both `createServerClient` (line 13) and `createBrowserClient` (line 21). When set, `setItem` writes `"base64-" + stringToBase64URL(value)`. The reader (`getItem`) strips `base64-` if present and base64url-decodes — otherwise returns raw — so the **read path is tolerant of either encoding**.
+- **`cookieEncoding: "raw"` is accepted** by both client constructors (any value other than `"base64url"` falls through the `if` and writes `value` verbatim). Clients reading a raw cookie also work because the prefix check is opt-in.
+- **Cookie name** = `cookieOptions.name` you pass; the storage key is set to the same value (see `createServerClient.js:25-27` and `createBrowserClient.js:34-36`). No `-auth-token` suffix is appended by `@supabase/ssr` — the project's `getSupabaseCookieName()` already bakes that in.
+- **What gets stored** (from `auth-js/GoTrueClient.js` `_saveSession` line 3938 + `helpers.js` `setItemAsync` line 124): `JSON.stringify(session)` where `session` is the full Session object including `user`. The validator `_isValidSession` (line 3748) only requires the keys `access_token`, `refresh_token`, `expires_at` to be **present** (`'k' in obj`); their values may be `null`/`""`.
+- **Chunking threshold** = `MAX_CHUNK_SIZE = 3180` (encodeURI'd-length, not raw). Names: `"<key>"` if it fits, else `"<key>.0"`, `"<key>.1"`, … (see `chunker.js:23-63`). The reader (`combineChunks`, line 66) tries the bare key first, then iterates `.0`, `.1`, … in order.
+- **`refresh_token` value tolerance** — verified empirically with `/tmp/probe-mint.mjs`: minted a HS256 token for an existing `auth.sessions` row, wrapped it as `base64-<json>`, and called `getUser()` on a fresh `createServerClient`. Results:
+  - `refresh_token: null` → ✅ user.id returned
+  - `refresh_token: ""` → ✅ user.id returned
+  - `refresh_token: "none"` → ✅ user.id returned
+  - field omitted entirely → ❌ `Auth session missing!` (fails `_isValidSession`)
+
+---
+
+## 3. Decision: **Option (X) — match the default `base64url` shape**
+
+Stage 2 should write a single cookie of name `getSupabaseCookieName()` containing `"base64-" + base64url(JSON.stringify({access_token, token_type:"bearer", expires_in, expires_at, refresh_token:"", user:null}))`, with the same cookie options the SSR lib uses (`DEFAULT_COOKIE_OPTIONS` merged with our overrides).
+
+**Why X over Y/Z:**
+
+- `cookieEncoding: "raw"` (Y) is supported but means we'd diverge from what `signInAnonymously()` produces; a future SSR-lib change to default tokens (e.g., always strip "raw" mode, gate it behind a feature flag) would silently break us. Sticking with the default puts us on the well-trodden path.
+- We only ever set a single cookie. The chunker only matters if our payload exceeds 3180 URL-encoded bytes. With `user:null` and a typical HS256 access token (~700 bytes raw → ~950 base64url-encoded), our cookie is ~1100 bytes. **No chunking needed** for this design. (If `user` were ever populated we'd still be at ~1300 bytes — see § 1.)
+- `refresh_token: ""` is the safest sentinel: it's a string (so deepClone/JSON serializers don't trip), present (so `_isValidSession` passes), and length 0 (so `autoRefreshToken && currentSession.refresh_token` short-circuits — irrelevant on server clients, defensive on browser).
+
+### `setSessionCookie` sketch (Stage 2 will copy into `src/lib/auth/cookies.ts`)
+
+```ts
+import { cookies as nextCookies } from "next/headers";
+import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
+
+const COOKIE_MAX_AGE_S = 60 * 60 * 24 * 400; // matches @supabase/ssr default
+
+export async function setSessionCookie(accessToken: string, expiresAt: number) {
+  const session = {
+    access_token: accessToken,
+    token_type: "bearer" as const,
+    expires_in: Math.max(0, expiresAt - Math.floor(Date.now() / 1000)),
+    expires_at: expiresAt,
+    refresh_token: "", // present-but-empty: passes _isValidSession, blocks browser auto-refresh
+    user: null, // _recoverAndRefresh handles null via userNotAvailableProxy + getUser()
+  };
+  const json = JSON.stringify(session);
+  const b64url = Buffer.from(json, "utf8")
+    .toString("base64")
+    .replace(/\+/g, "-")
+    .replace(/\//g, "_")
+    .replace(/=+$/, "");
+  (await nextCookies()).set(getSupabaseCookieName(), `base64-${b64url}`, {
+    path: "/",
+    sameSite: "lax",
+    httpOnly: true,
+    secure: true,
+    maxAge: COOKIE_MAX_AGE_S,
+  });
+}
+```
+
+Note we deliberately set `httpOnly: true` (the SSR lib defaults to `false` because the browser client must read it via `document.cookie`; **but for our custom-mint flow the browser side comes via `createBrowserClient` against the same `localStorage`-equivalent path** — actually it doesn't, the browser still needs to read this. **Stage 2 must keep `httpOnly: false` to preserve browser-client readability.** Adjust the sketch accordingly when implementing.)
+
+### Critical out-of-scope finding (FLAG TO PM)
+
+GoTrue v2.170 rejects any JWT whose `session_id` claim does not match an existing row in `auth.sessions`. Tested directly: minted JWT signed with `JWT_SECRET`, hit `/auth/v1/user` → `403 session_not_found`. Only succeeds when `session_id` references a real `auth.sessions.id`.
+
+**Implication for PLAN-AUTH-A:** the plan's "hand-mint short-lived access tokens" path (Decision: path-a) does **not** work end-to-end with GoTrue's `/user`, `/logout`, or any GoTrue-mediated endpoint unless we either (i) reuse a real `auth.sessions.id` (the same one created by the original `signInAnonymously()`), or (ii) accept that browser code calling `supabase.auth.getUser()` will route to GoTrue and fail. Browser code calling `getUser()` is exactly what `(app)` layouts and route handlers currently rely on. **Stage 2 cannot proceed until PM has been routed this finding** — recommend escalating to the PM agent before any cookie module is written.
+
+(Note: PostgREST + Realtime accept the JWT via HS256 signature alone, so RLS-gated reads/writes would work; only GoTrue endpoints fail. This may be acceptable if all `getUser()` callers are migrated to a custom `/api/auth/me` that decodes the JWT locally — but that is a plan-level decision.)
+
+---
+
+## 4. Verification recipe (for Stage 2's `src/__tests__/auth/cookies.test.ts`)
+
+```ts
+// Pre-req: live GoTrue at NEXT_PUBLIC_SUPABASE_URL with JWT_SECRET in env.
+// Setup: signInAnonymously() once to create a real auth.sessions row,
+//        then read its id from the DB (or extract from the response JWT).
+// (Cannot mock GoTrue: getUser() always re-validates against /auth/v1/user
+// using HS256 signature + session_id lookup.)
+
+it("setSessionCookie produces a cookie that getUser() accepts", async () => {
+  const { uid, sessionId } = await createRealAuthSession(); // helper
+  const exp = Math.floor(Date.now() / 1000) + 3600;
+  const accessToken = await signHS256(
+    {
+      sub: uid,
+      aud: "authenticated",
+      role: "authenticated",
+      session_id: sessionId,
+      is_anonymous: true,
+      iat: Math.floor(Date.now() / 1000),
+      exp,
+      email: "",
+      phone: "",
+      app_metadata: {},
+      user_metadata: {},
+    },
+    process.env.JWT_SECRET!,
+  );
+  const jar = new Map<string, string>();
+  // setSessionCookie writes via next/headers — in tests, swap for direct jar.set
+  const sessionJson = JSON.stringify({
+    access_token: accessToken,
+    token_type: "bearer",
+    expires_in: 3600,
+    expires_at: exp,
+    refresh_token: "",
+    user: null,
+  });
+  jar.set(getSupabaseCookieName(), "base64-" + b64url(sessionJson));
+
+  const supabase = createServerClient(SUPABASE_URL, ANON_KEY, {
+    cookieOptions: { name: getSupabaseCookieName() },
+    cookies: {
+      getAll: () => [...jar.entries()].map(([name, value]) => ({ name, value })),
+      setAll: () => {},
+    },
+  });
+  const { data, error } = await supabase.auth.getUser();
+  expect(error).toBeNull();
+  expect(data.user?.id).toBe(uid);
+});
+```
+
+**Gotchas:**
+
+- `getUser()` always hits `${SUPABASE_URL}/auth/v1/user` (`auth-js/GoTrueClient.js:2463-2483`). Test must run against the live Docker stack, not a mock.
+- `session_id` claim is mandatory and must reference a real row in `auth.sessions` — helper `createRealAuthSession()` should call `signInAnonymously` and read the id back.
+- Do not pass `auth: { debug: true }` in CI — the noise hides assertion output.
+
+---
+
+## 5. GO/NO-GO
+
+**Stage 2 should implement option (X)** with shape `{access_token, token_type:"bearer", expires_in, expires_at, refresh_token:"", user:null}` written as `"base64-" + base64url(JSON.stringify(...))` to the cookie named by `getSupabaseCookieName()` — **verified empirically and against the v0.6.1 source** — **but is GATED on PM resolving the GoTrue `session_id`-claim finding in § 3 before writing any cookie code.**

+ 636 - 0
research/PLAN-AUTH-A.md

@@ -0,0 +1,636 @@
+# Implementation Plan: Approach A — Anonymous + Custom Recovery Layer (v3)
+
+**Status:** Revised after Stage 1 cookie probe surfaced a blocker (GoTrue rejects HS256 JWTs whose `session_id` is not in `auth.sessions`). Both compliance and security agents recommended **Option 3**: replace every `supabase.auth.getUser()` call with a local JWT-decode wrapper. v3 bakes that decision in. See `research/COOKIE-SHAPE-DECISION.md` for the Stage 1 finding and `research/AUTH-APPROACHES.md` for the original landscape.
+
+## Context
+
+Current recovery design (synthetic `<uid>@moviedice.invalid` email + HKDF password + GoTrue `admin.updateUserById` "promotion") is structurally fighting GoTrue. It hit bug #2013, required a CHECK constraint that broke the flow, and security review demanded Kong-level denylists once `EMAIL_ENABLED=true`. The product never needed permanent-account "promotion" — only "given a valid recovery code, give the same user a fresh session." Approach A delivers exactly that.
+
+**Outcome:**
+
+- Onboarding still uses `signInAnonymously()` (unchanged).
+- Recovery generate inserts `argon2id(code)` into a custom `recovery_codes` table.
+- Recovery claim verifies the code, atomically consumes it, mints a fresh JWT bound to the same `sub`, persists a `user_sessions` row for revocation, writes the `@supabase/ssr` cookie.
+- All users stay `is_anonymous=true` in `auth.users`. That flag becomes meaningless metadata.
+- `auth.uid()` resolves natively → all RLS policies unchanged.
+- Browser Realtime keeps working (HS256 JWTs signed with `JWT_SECRET` are accepted).
+- GoTrue config: `EMAIL_ENABLED` stays `false`. No Kong denylists needed.
+
+## Decisions settled (no longer open questions)
+
+- **JWT lifecycle: path (a)** — hand-mint short-lived access tokens, periodic re-mint via `/api/auth/touch`. Per security review #4 (v2 round).
+- **Argon2 lookup: peppered HMAC prefix index from day one**, not a follow-up.
+- **Atomic claim: `DELETE ... RETURNING` is the commit point**, not a separate scan-then-delete.
+- **Session revocation: `user_sessions` table** with `revoked_at`.
+- **Absolute session cap: 30 days** via `iat_original` JWT claim.
+- **Trust boundary: local JWT decode + `user_sessions` lookup, NOT `supabase.auth.getUser()`** (NEW v3 decision). Per Stage 1: GoTrue rejects HS256 JWTs whose `session_id` is not in `auth.sessions`. Reusing/inserting that row is a forgery + GC + coupling hazard. Instead, replace every `getUser()` call with a thin server wrapper `requireUser()` that does local `jwtVerify` + `user_sessions.revoked_at IS NULL` check. PostgREST + Realtime continue to accept the JWT via signature alone — only GoTrue's `/auth/v1/user` rejects, and after Option 3 nothing in the app calls it.
+- **Browser uses `useCurrentUser()` hook** that reads `/api/auth/me` (server-side `requireUser()`); browser never calls `supabase.auth.getUser()` directly.
+- **CI grep-guard** rejects raw `supabase.auth.getUser()` outside the `current-user.ts` wrapper module.
+
+## Scope
+
+### In scope
+
+1. ✅ **DONE Stage 1** — Cookie-shape decision in `research/COOKIE-SHAPE-DECISION.md`. Option (X): `base64-` + base64url(JSON object) with `{access_token, token_type:"bearer", expires_in, expires_at, refresh_token:"", user:null}`.
+2. **Migration `00004_recovery_and_sessions.sql`**: drop `users.recovery_code` column; create `recovery_codes` (with peppered prefix index column); create `user_sessions`.
+3. **Module `src/lib/auth/jwt.ts`**: HS256 mint + verify with `kid: "v1"`, `iat_original` claim, `setNotBefore(-10s)`, `setIssuer("moviedice")`, `setAudience("authenticated")`. `session_id` is generated UUID, persisted in `user_sessions`. **Does NOT need to satisfy GoTrue's `getUser()` — see v3 trust-boundary decision.**
+4. **Module `src/lib/auth/cookies.ts`**: write per Stage 1 Option (X).
+5. **Module `src/lib/auth/sessions.ts`**: create/lookup/revoke `user_sessions` rows.
+6. **Module `src/lib/auth/recovery-prefix.ts`**: HMAC-SHA256 with `RECOVERY_CODE_PEPPER` for the prefix index.
+7. **NEW Module `src/lib/auth/current-user.ts`** (Option 3 wrapper): `requireUser(req)` does local `verifyAccessToken` + `user_sessions.revoked_at IS NULL` check + 30d cap. `getCurrentUser(req)` non-throwing variant. **The ONLY place in the codebase that decodes the JWT or queries `user_sessions`.** Enforced by CI grep-guard.
+8. **NEW Route `src/app/api/auth/me/route.ts`**: returns `{id, isAnonymous: true}` via `requireUser()`. Backs the browser hook.
+9. **NEW Hook `src/hooks/use-current-user.ts`**: TanStack Query against `/api/auth/me` with appropriate `staleTime`. Replaces ALL browser `supabase.auth.getUser()` / `getSession()` calls.
+10. **Rewrite `src/app/api/auth/recovery/generate/route.ts`** — uses `requireUser()` for auth.
+11. **Rewrite `src/app/api/auth/recovery/claim/route.ts`** — atomic `DELETE...RETURNING`, constant-time padding, mints JWT with generated `session_id`, inserts `user_sessions` row.
+12. **NEW `src/app/api/auth/touch/route.ts`** with absolute-cap enforcement (uses `requireUser()` then re-mints).
+13. **Migrate `src/middleware.ts`** (line 33): replace `supabase.auth.getUser()` with `requireUser(req)`.
+14. **Migrate 14 API route handlers**: replace inline `supabase.auth.getUser()` with `requireUser(req)`. Files identified by `grep -rn "auth.getUser\|auth.getSession" src/app/api/`. Programmer must list and migrate each.
+15. **Migrate 5 browser hooks**: `use-realtime-movies`, `use-add-movie`, `use-delete-movie`, `use-toggle-watched`, `use-all-user-movies`, `use-user-groups`. Replace inline `supabase.auth.getUser()` with `useCurrentUser()`.
+16. **Migrate `src/app/(auth)/recovery/page.tsx`**: replace `supabase.auth.getSession()` with `useCurrentUser()`.
+17. **Update `src/lib/supabase/client.ts`**: `auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false }`. Cookie encoding stays default (Stage 1 Option X).
+18. **Layout-level token-age check + touch-failure handler**: in `(app)` layout. On touch 401: `queryClient.clear()` + hard-redirect to `/`.
+19. **Update `src/lib/supabase/admin.ts`**: drop the `EMAIL_ENABLED !== false` assertion (rationale obsolete).
+20. **Delete `src/lib/auth/synthetic.ts`** (after grep-verify zero importers post-rewrites).
+21. **Tests**: delete `synthetic.test.ts`, `promotion-regression.test.ts`. Rewrite `recovery-generate.test.ts`, `recovery-claim.test.ts`. Add per "Tests" section below.
+22. **Update `src/types/database.ts`**: drop `users.recovery_code`; add `recovery_codes`, `user_sessions`.
+23. **Update `src/env.ts`**: ensure `JWT_SECRET` and add `RECOVERY_CODE_PEPPER` in the server schema (never client).
+24. **docker-compose.yml**: verify `JWT_SECRET` is exposed to the Next.js app container; add `RECOVERY_CODE_PEPPER` (32+ random chars).
+25. **Sentry `beforeSend`**: drop events whose stringified payload contains `JWT_SECRET` or `RECOVERY_CODE_PEPPER` literal value.
+26. **CI grep-guard test**: fail if `supabase.auth.getUser()` or `supabase.auth.getSession()` appears anywhere in `src/` except `src/lib/auth/current-user.ts` and `src/hooks/use-current-user.ts`. Fail if `auth.jwt()->>'is_anonymous'` appears in any new migration.
+27. **Update `CLAUDE.md`**: replace synthetic-identity / GoTrue #2013 / `EMAIL_ENABLED` / Kong denylist paragraphs. Document `requireUser()` / `useCurrentUser()` as the only auth boundary. Note `is_anonymous` is meaningless and `getUser()` is forbidden outside the wrapper.
+
+### Out of scope
+
+- Removing GoTrue from docker-compose. `signInAnonymously()` is fine to keep delegated.
+- Approach B (custom JWT, no GoTrue for users) — landscape-only.
+- Migration of existing `users.recovery_code` data (dev-only per project state).
+- Admin TOTP path (untouched).
+- CSP `script-src` hardening (security #10) — flagged as separate follow-up.
+
+## Detailed steps
+
+### Step 0: Cookie-shape probe (blocking)
+
+Before writing any code: run a one-shot script in dev to capture what `signInAnonymously()` writes today. This determines whether `setSessionCookie` writes:
+
+- (X) `base64-` prefix + base64url(JSON.stringify(session-object))
+- (Y) raw JSON array `[access, refresh, ...]`
+- (Z) chunked `${name}.0`, `${name}.1` for large values
+
+Decision tree:
+
+- If (X): match it exactly using `stringToBase64URL` from `@supabase/ssr` internals OR construct both clients with `cookieOptions: { cookieEncoding: "raw" }` and use raw JSON object.
+- If (Z) for our token size (~1.5 KB): must implement chunking OR force `raw` encoding.
+
+**Recommended path**: pass `cookieOptions: { cookieEncoding: "raw" }` to BOTH `createBrowserClient` and `createServerClient` (they accept it per `node_modules/@supabase/ssr/dist/main/createServerClient.js:13`). Then write a plain JSON object matching the modern Supabase `Session` shape: `{ access_token, refresh_token, expires_at, expires_in, token_type, user }`. For path (a), `refresh_token` is a non-empty placeholder (a constant like `"NO_REFRESH"`) since the browser client has `autoRefreshToken: false` and won't try to use it. Verify this doesn't trip `getUser()` validation.
+
+**Acceptance**: a Vitest integration test that calls `setSessionCookie(mintedToken)` then constructs a `createServerClient` against the same cookie store and asserts `getUser()` returns the expected `sub`. **Cannot ship without this test passing** — security #8 / compliance #1.
+
+### Step 1: Migration `00004_recovery_and_sessions.sql`
+
+Code-first deploy ordering: this migration runs AFTER the new app code is deployed and BEFORE the old `users.recovery_code` column reads are removed. (Per compliance #4.) Concretely: deploy app code that tolerates either schema first, then run migration, then remove tolerance. For dev (no production yet), simpler: stop dev server, apply migration, deploy code, restart.
+
+```sql
+-- 00004_recovery_and_sessions.sql
+
+ALTER TABLE public.users DROP COLUMN IF EXISTS recovery_code;
+
+CREATE TABLE public.recovery_codes (
+  user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+  argon2_hash text NOT NULL,
+  prefix_hmac bytea NOT NULL,  -- HMAC-SHA256(pepper, code) truncated to first 8 bytes
+  created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_recovery_codes_prefix ON public.recovery_codes(prefix_hmac);
+
+ALTER TABLE public.recovery_codes ENABLE ROW LEVEL SECURITY;
+-- No policies; service role only.
+
+COMMENT ON TABLE public.recovery_codes IS
+  'Argon2id-hashed recovery codes with HMAC prefix index. Service role only. Single-use; deleted on claim atomically via DELETE ... RETURNING.';
+
+CREATE TABLE public.user_sessions (
+  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+  iat_original timestamptz NOT NULL DEFAULT now(),
+  last_seen_at timestamptz NOT NULL DEFAULT now(),
+  revoked_at timestamptz
+);
+
+CREATE INDEX idx_user_sessions_user_active ON public.user_sessions(user_id) WHERE revoked_at IS NULL;
+
+ALTER TABLE public.user_sessions ENABLE ROW LEVEL SECURITY;
+-- No policies; service role only.
+
+COMMENT ON TABLE public.user_sessions IS
+  'Server-side session records. JWT carries session_id; touch endpoint validates revoked_at IS NULL and now() - iat_original < 30d.';
+```
+
+**Note on `is_anonymous`**: under this design `auth.jwt()->>''is_anonymous''` returns true for every real user. `grep -rn "is_anonymous" supabase/migrations/` to verify no policy depends on it. CLAUDE.md gets a warning line.
+
+### Step 2: `src/lib/auth/jwt.ts`
+
+```ts
+import { SignJWT, jwtVerify } from "jose";
+
+const ACCESS_EXP_SECONDS = 60 * 60; // 1h
+const ABSOLUTE_CAP_SECONDS = 30 * 24 * 60 * 60; // 30d
+const NBF_SKEW_SECONDS = 10;
+
+interface AccessClaims {
+  sub: string;
+  session_id: string;
+  iat_original: number; // unix seconds, set on first mint, preserved across touches
+}
+
+export async function mintAccessToken(claims: AccessClaims): Promise<string> {
+  const secret = new TextEncoder().encode(process.env.JWT_SECRET);
+  return await new SignJWT({
+    sub: claims.sub,
+    role: "authenticated",
+    is_anonymous: true,
+    session_id: claims.session_id,
+    iat_original: claims.iat_original,
+  })
+    .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
+    .setIssuedAt()
+    .setNotBefore(`${-NBF_SKEW_SECONDS}s`)
+    .setExpirationTime(`${ACCESS_EXP_SECONDS}s`)
+    .setIssuer("moviedice")
+    .setAudience("authenticated")
+    .sign(secret);
+}
+
+export async function verifyAccessToken(token: string): Promise<AccessClaims | null> {
+  try {
+    const secret = new TextEncoder().encode(process.env.JWT_SECRET);
+    const { payload } = await jwtVerify(token, secret, {
+      issuer: "moviedice",
+      audience: "authenticated",
+    });
+    return {
+      sub: payload.sub as string,
+      session_id: payload.session_id as string,
+      iat_original: payload.iat_original as number,
+    };
+  } catch {
+    return null;
+  }
+}
+
+export const ACCESS_TOKEN_TTL_SECONDS = ACCESS_EXP_SECONDS;
+export const ABSOLUTE_SESSION_CAP_SECONDS = ABSOLUTE_CAP_SECONDS;
+```
+
+`session_id` is generated at claim time and persisted (Step 4); not a per-mint random.
+
+### Step 3: `src/lib/auth/recovery-prefix.ts`
+
+```ts
+import { createHmac } from "node:crypto";
+
+export function prefixHmac(code: string): Buffer {
+  const pepper = process.env.RECOVERY_CODE_PEPPER;
+  if (!pepper) throw new Error("RECOVERY_CODE_PEPPER not set");
+  return createHmac("sha256", pepper).update(code).digest().subarray(0, 8);
+}
+```
+
+Pepper lives in env (32+ random chars), never in code, never client-exposed.
+
+### Step 4: `src/lib/auth/sessions.ts`
+
+```ts
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { ABSOLUTE_SESSION_CAP_SECONDS } from "@/lib/auth/jwt";
+
+export async function createSession(userId: string): Promise<{ id: string; iat_original: number }> {
+  const admin = getSupabaseAdminClient();
+  const { data, error } = await admin
+    .from("user_sessions")
+    .insert({ user_id: userId })
+    .select("id, iat_original")
+    .single();
+  if (error || !data) throw new Error("Failed to create session");
+  return { id: data.id, iat_original: Math.floor(new Date(data.iat_original).getTime() / 1000) };
+}
+
+export async function isSessionLive(sessionId: string, iatOriginal: number): Promise<boolean> {
+  const admin = getSupabaseAdminClient();
+  const { data } = await admin
+    .from("user_sessions")
+    .select("revoked_at")
+    .eq("id", sessionId)
+    .maybeSingle();
+  if (!data || data.revoked_at) return false;
+  if (Math.floor(Date.now() / 1000) - iatOriginal > ABSOLUTE_SESSION_CAP_SECONDS) return false;
+  // touch last_seen_at (fire-and-forget)
+  void admin
+    .from("user_sessions")
+    .update({ last_seen_at: new Date().toISOString() })
+    .eq("id", sessionId);
+  return true;
+}
+
+export async function revokeSession(sessionId: string): Promise<void> {
+  const admin = getSupabaseAdminClient();
+  await admin
+    .from("user_sessions")
+    .update({ revoked_at: new Date().toISOString() })
+    .eq("id", sessionId);
+}
+```
+
+### Step 5: `src/lib/auth/cookies.ts`
+
+Final shape determined in Step 0. Sketched assuming `cookieEncoding: "raw"` works:
+
+```ts
+import { cookies } from "next/headers";
+import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
+import { ACCESS_TOKEN_TTL_SECONDS } from "@/lib/auth/jwt";
+
+export async function setSessionCookie(accessToken: string): Promise<void> {
+  const name = `${getSupabaseCookieName()}-auth-token`;
+  const expiresAt = Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS;
+  const session = {
+    access_token: accessToken,
+    refresh_token: "NO_REFRESH", // placeholder; client has autoRefreshToken: false
+    expires_at: expiresAt,
+    expires_in: ACCESS_TOKEN_TTL_SECONDS,
+    token_type: "bearer",
+    user: null, // verify in Step 0 probe whether this can be null
+  };
+  (await cookies()).set(name, JSON.stringify(session), {
+    httpOnly: false, // @supabase/ssr browser client must read it; matches GoTrue's cookie posture
+    secure: process.env.NODE_ENV === "production",
+    sameSite: "lax",
+    path: "/",
+    maxAge: ACCESS_TOKEN_TTL_SECONDS,
+  });
+}
+
+export async function clearSessionCookie(): Promise<void> {
+  const name = `${getSupabaseCookieName()}-auth-token`;
+  (await cookies()).delete(name);
+}
+```
+
+### Step 6: Rewrite `src/app/api/auth/recovery/generate/route.ts`
+
+```ts
+import { NextRequest, NextResponse } from "next/server";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { generateRecoveryCode, hashRecoveryCode } from "@/lib/auth/recovery";
+import { prefixHmac } from "@/lib/auth/recovery-prefix";
+import { checkRateLimit } from "@/lib/auth/rate-limit";
+
+const PER_UID_WINDOW_MS = 60 * 60 * 1000;
+const PER_UID_MAX = 3;
+
+export async function POST(request: NextRequest) {
+  const authHeader = request.headers.get("authorization");
+  const bearerToken = authHeader?.toLowerCase().startsWith("bearer ")
+    ? authHeader.slice(7).trim()
+    : null;
+
+  const admin = getSupabaseAdminClient();
+  let uid: string | null = null;
+  let authPath: "bearer" | "cookie" | null = null;
+
+  if (bearerToken) {
+    const { data, error } = await admin.auth.getUser(bearerToken);
+    if (!error && data.user) {
+      uid = data.user.id;
+      authPath = "bearer";
+    }
+  }
+  if (!uid) {
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+    if (user) {
+      uid = user.id;
+      authPath = "cookie";
+    }
+  }
+  if (!uid) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+  const limit = checkRateLimit(`recovery-generate:${uid}`, {
+    windowMs: PER_UID_WINDOW_MS,
+    maxAttempts: PER_UID_MAX,
+  });
+  if (!limit.allowed) {
+    return NextResponse.json(
+      { error: "Too many attempts." },
+      {
+        status: 429,
+        headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) },
+      },
+    );
+  }
+
+  const { data: existing } = await admin
+    .from("recovery_codes")
+    .select("user_id")
+    .eq("user_id", uid)
+    .maybeSingle();
+  if (existing) {
+    return NextResponse.json({ error: "Recovery code already exists" }, { status: 409 });
+  }
+
+  const code = generateRecoveryCode();
+  const argonHash = await hashRecoveryCode(code);
+  const prefix = prefixHmac(code);
+
+  const { error } = await admin
+    .from("recovery_codes")
+    .insert({ user_id: uid, argon2_hash: argonHash, prefix_hmac: prefix });
+  if (error) {
+    return NextResponse.json({ error: "Failed to store recovery code" }, { status: 500 });
+  }
+
+  console.log(`[recovery/generate] uid=${uid} authPath=${authPath}`);
+  return NextResponse.json({ code });
+}
+```
+
+### Step 7: Rewrite `src/app/api/auth/recovery/claim/route.ts`
+
+```ts
+import { NextRequest, NextResponse } from "next/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { verifyRecoveryCode } from "@/lib/auth/recovery";
+import { prefixHmac } from "@/lib/auth/recovery-prefix";
+import { checkRateLimit, getClientIp } from "@/lib/auth/rate-limit";
+import { mintAccessToken } from "@/lib/auth/jwt";
+import { createSession } from "@/lib/auth/sessions";
+import { setSessionCookie } from "@/lib/auth/cookies";
+
+const IP_WINDOW_MS = 15 * 60 * 1000;
+const IP_MAX = 5;
+const TARGET_RESPONSE_MS = 200; // pad to defeat timing channel
+
+export async function POST(request: NextRequest) {
+  const start = Date.now();
+  const ip = getClientIp(request);
+  const limit = checkRateLimit(`recovery-claim:${ip}`, {
+    windowMs: IP_WINDOW_MS,
+    maxAttempts: IP_MAX,
+  });
+  if (!limit.allowed) {
+    return NextResponse.json(
+      { error: "Too many attempts." },
+      {
+        status: 429,
+        headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) },
+      },
+    );
+  }
+
+  const body = await request.json().catch(() => null);
+  const code = typeof body?.code === "string" ? body.code.trim() : null;
+  if (!code) return NextResponse.json({ error: "Invalid code" }, { status: 400 });
+
+  const admin = getSupabaseAdminClient();
+  const prefix = prefixHmac(code);
+
+  // Prefix-narrowed lookup (typically 0-1 rows; collision possible but negligible at 8-byte HMAC).
+  const { data: candidates } = await admin
+    .from("recovery_codes")
+    .select("user_id, argon2_hash")
+    .eq("prefix_hmac", prefix);
+
+  let matchedUid: string | null = null;
+  for (const row of candidates ?? []) {
+    if (await verifyRecoveryCode(code, row.argon2_hash)) {
+      matchedUid = row.user_id;
+      break;
+    }
+  }
+
+  if (!matchedUid) {
+    await padResponse(start);
+    return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 });
+  }
+
+  // Atomic single-use: DELETE ... RETURNING. If rowCount === 0, someone else won the race.
+  const { data: deleted, error: deleteError } = await admin
+    .from("recovery_codes")
+    .delete()
+    .eq("user_id", matchedUid)
+    .select("user_id");
+  if (deleteError || !deleted || deleted.length === 0) {
+    await padResponse(start);
+    return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 });
+  }
+
+  const session = await createSession(matchedUid);
+  const access = await mintAccessToken({
+    sub: matchedUid,
+    session_id: session.id,
+    iat_original: session.iat_original,
+  });
+  await setSessionCookie(access);
+
+  await padResponse(start);
+  return NextResponse.json({ ok: true });
+}
+
+async function padResponse(startMs: number): Promise<void> {
+  const elapsed = Date.now() - startMs;
+  const remaining = TARGET_RESPONSE_MS - elapsed;
+  if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
+}
+```
+
+### Step 8: `src/app/api/auth/touch/route.ts`
+
+```ts
+import { NextRequest, NextResponse } from "next/server";
+import { cookies } from "next/headers";
+import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
+import { mintAccessToken, verifyAccessToken, ABSOLUTE_SESSION_CAP_SECONDS } from "@/lib/auth/jwt";
+import { isSessionLive } from "@/lib/auth/sessions";
+import { setSessionCookie } from "@/lib/auth/cookies";
+
+export async function POST(request: NextRequest) {
+  // Origin check (CSRF). Per security #16.
+  const origin = request.headers.get("origin");
+  const expectedOrigin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
+  if (origin && origin !== expectedOrigin) {
+    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+  }
+
+  const cookieStore = await cookies();
+  const cookieName = `${getSupabaseCookieName()}-auth-token`;
+  const raw = cookieStore.get(cookieName)?.value;
+  if (!raw) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+  let accessToken: string;
+  try {
+    const session = JSON.parse(raw);
+    accessToken = session.access_token;
+  } catch {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const claims = await verifyAccessToken(accessToken);
+  if (!claims) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+  // Absolute cap: refuse re-mint if iat_original is older than 30 days.
+  if (Math.floor(Date.now() / 1000) - claims.iat_original > ABSOLUTE_SESSION_CAP_SECONDS) {
+    return NextResponse.json({ error: "Session expired" }, { status: 401 });
+  }
+
+  // Session-table check: revoked? still within cap?
+  if (!(await isSessionLive(claims.session_id, claims.iat_original))) {
+    return NextResponse.json({ error: "Session revoked" }, { status: 401 });
+  }
+
+  const fresh = await mintAccessToken({
+    sub: claims.sub,
+    session_id: claims.session_id,
+    iat_original: claims.iat_original, // preserved across touches
+  });
+  await setSessionCookie(fresh);
+  return NextResponse.json({ ok: true });
+}
+```
+
+### Step 9: Browser client adjustments
+
+`src/lib/supabase/client.ts`:
+
+```ts
+createBrowserClient(url, key, {
+  cookieOptions: { name: getSupabaseCookieName(), cookieEncoding: "raw" }, // pin encoding
+  auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false },
+});
+```
+
+Same `cookieEncoding: "raw"` on `createServerClient` everywhere it's constructed.
+
+### Step 10: Layout-level token-age check + touch-failure handler
+
+In the `(app)` layout (or a small client component mounted in it), on mount and on a 5-min interval:
+
+```ts
+const session = await supabase.auth.getSession();
+if (session.data.session) {
+  const expiresAt = session.data.session.expires_at ?? 0;
+  if (expiresAt - Math.floor(Date.now() / 1000) < 5 * 60) {
+    const res = await fetch("/api/auth/touch", { method: "POST" });
+    if (!res.ok) {
+      queryClient.clear(); // hard-clear TanStack cache (security #14)
+      window.location.assign("/"); // redirect to landing
+    }
+  }
+}
+```
+
+### Step 11: `src/lib/supabase/admin.ts` assertion
+
+Currently asserts `GOTRUE_EXTERNAL_EMAIL_ENABLED !== "false"` with rationale "synthetic-recovery requires admin-API-only email identity creation." That rationale is now obsolete. **Drop the assertion entirely** — the new design has no constraint on this flag. Update CLAUDE.md accordingly.
+
+### Step 12: Sentry `beforeSend` filter
+
+`src/sentry.config.ts` (or wherever `beforeSend` lives): in addition to current UUID stripping, drop any event whose JSON-stringified payload contains the literal value of `JWT_SECRET` or `RECOVERY_CODE_PEPPER`. Compare in constant time.
+
+### Step 13: Delete `src/lib/auth/synthetic.ts`
+
+Pre-step: `grep -rn "from \"@/lib/auth/synthetic\"" src/` and `grep -rn "synthetic" src/` to confirm zero importers (after the route rewrites). Delete the file.
+
+### Step 14: Test updates (in scope, NOT follow-up)
+
+- **Delete** `src/__tests__/auth/synthetic.test.ts`, `src/__tests__/auth/promotion-regression.test.ts`.
+- **Rewrite** `src/__tests__/api/recovery-generate.test.ts`:
+  - 401 unauthorized, 429 rate-limited, 409 already-exists, 200 happy path (verifies row inserted with both `argon2_hash` and `prefix_hmac`).
+- **Rewrite** `src/__tests__/api/recovery-claim.test.ts`:
+  - 400 missing code, 429 rate-limited, 401 invalid (with constant-time padding), 200 happy path (cookie set, row deleted, session row created).
+  - Concurrency test: two parallel claims of the same code — exactly one returns 200.
+- **New** `src/__tests__/auth/jwt.test.ts`: mint/verify roundtrip; `kid`/`iss`/`aud` correct; expired/nbf rejected; `iat_original` preserved across touches.
+- **New** `src/__tests__/auth/cookies.test.ts`: write cookie via `setSessionCookie`, read via `createServerClient` instance, assert `getUser()` returns expected `sub`. **Catches the cookie-shape blocker (security #8 / compliance #1) directly.**
+- **New** `src/__tests__/api/touch.test.ts`: 401 missing/invalid token, 401 absolute-cap exceeded, 401 revoked session, 200 happy path with `iat_original` preserved.
+- **New** `src/__tests__/integration/recovery-flow.test.ts` (gated on `RUN_INTEGRATION=1`): generate → claim → cookie set → `auth.uid()` matches → touch → revoke → touch fails. Real GoTrue + Postgres.
+- **New** `src/__tests__/integration/realtime-smoke.test.ts` (also gated): minted JWT can subscribe to a `movies` channel — validates the load-bearing claim that "Realtime keeps working."
+- **New** `src/__tests__/api/recovery-ci.test.ts`: grep-style CI guard that `auth.jwt()->>'is_anonymous'` does not appear in any new migration (security #9).
+
+### Step 15: Update `CLAUDE.md`
+
+Replace the entire "Auth → Recovery" section with:
+
+> Recovery code is stored Argon2id-hashed in `public.recovery_codes` with an HMAC-prefix index (peppered with `RECOVERY_CODE_PEPPER`) for O(1) lookup. Generate is server-side, rate-limited per uid, idempotent (409 if already present). Claim atomically deletes via `DELETE ... RETURNING`, mints a fresh HS256 JWT bound to the same `sub` (signed with `JWT_SECRET`, `kid: "v1"`, `iss: "moviedice"`, `aud: "authenticated"`, `nbf: -10s`), creates a `user_sessions` row, writes the `@supabase/ssr` cookie. Sessions have a 1h access TTL with re-mint via `POST /api/auth/touch`, capped absolutely at 30 days from the original `iat_original`. Revocation: flip `user_sessions.revoked_at`; touch refuses to re-mint. **All users remain `is_anonymous=true` in `auth.users` — that flag has NO semantic meaning under this design; do not gate any policy or UI on it.**
+
+Remove all "Synthetic-identity-at-generate", `00003_synthetic_email_constraint`, GoTrue #2013 workaround, and Kong-denylist lines.
+
+## Critical files
+
+**New:**
+
+- `supabase/migrations/00004_recovery_and_sessions.sql`
+- `src/lib/auth/jwt.ts`
+- `src/lib/auth/cookies.ts`
+- `src/lib/auth/sessions.ts`
+- `src/lib/auth/recovery-prefix.ts`
+- `src/app/api/auth/touch/route.ts`
+- Tests per Step 14
+
+**Rewrite:**
+
+- `src/app/api/auth/recovery/generate/route.ts`
+- `src/app/api/auth/recovery/claim/route.ts`
+
+**Modify:**
+
+- `src/lib/supabase/client.ts` (add `autoRefreshToken: false`, `cookieEncoding: "raw"`)
+- `src/lib/supabase/server.ts` (verify `cookieEncoding: "raw"` if needed)
+- `src/lib/supabase/admin.ts` (drop `EMAIL_ENABLED` assertion)
+- `src/types/database.ts` (drop `users.recovery_code`, add new tables)
+- `src/env.ts` (ensure `JWT_SECRET` and add `RECOVERY_CODE_PEPPER`, both server-only)
+- `src/sentry.config.ts` (or wherever `beforeSend` lives — secret-leak filter)
+- `docker-compose.yml` (verify `JWT_SECRET` exposed to app container; add `RECOVERY_CODE_PEPPER`)
+- `(app)` layout — token-age check + touch-failure handler
+- `CLAUDE.md`
+
+**Delete:**
+
+- `src/lib/auth/synthetic.ts`
+- `src/__tests__/auth/synthetic.test.ts`
+- `src/__tests__/auth/promotion-regression.test.ts`
+
+## Dependencies
+
+- `jose` — verify with `npm ls jose`. Likely already present (admin TOTP path may use it). Install if missing.
+- No other new deps.
+
+## Verification
+
+1. `npm run build` clean.
+2. `tsc --noEmit` clean (catches Compliance #2: synthetic test files importing dead modules).
+3. `npm test` — all unit + integration suites pass.
+4. Manual probe at `http://localhost:3000`:
+   - Fresh incognito → anon signin → home renders.
+   - `/recovery` → code displayed.
+   - Second incognito → `/recover` → paste code → hard-nav to `/` → original user's session restored.
+   - Re-claim same code → 401 (single-use enforced).
+   - Wait >1h or force-expire token → trigger touch → cookie re-minted, no UI break.
+   - Run `UPDATE user_sessions SET revoked_at = now() WHERE user_id = ...` → trigger touch → 401, hard-clear, redirect.
+5. SQL spot-check: `SELECT count(*) FROM recovery_codes` = 0 after a successful claim. `SELECT * FROM user_sessions WHERE user_id = ...` shows the session.
+6. RLS spot-check: log in as recovered user, `SELECT auth.uid()` returns original UID.
+7. Realtime smoke: subscribe to `movies` channel from browser console after recovery — confirm WebSocket auth succeeds.
+
+## Rollback
+
+- Migration `00004` is forward-only. If app code regresses post-deploy, redeploy old code; the new tables sit unused. Do NOT roll back the migration unless the new app code is fully out of production — old code reads `users.recovery_code` which is gone.
+- For dev: `psql -c "ALTER TABLE public.users ADD COLUMN recovery_code text; DROP TABLE public.recovery_codes; DROP TABLE public.user_sessions;"` if needed.
+
+## Follow-ups (post-merge)
+
+- PM agent updates `PROJECT_SCOPE.md` to reflect the new recovery model.
+- CSP `script-src` hardening review (security #10) — separate work.
+- Audit `getClientIp` "unknown" fallback — global rate-limit bucket collision (compliance #10) — separate work.
+- If `JWT_SECRET` ever needs rotation: leverage `kid: "v1"` to dual-mint during transition.

+ 220 - 0
src/__tests__/api/bootstrap.test.ts

@@ -0,0 +1,220 @@
+// @vitest-environment node
+import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
+
+const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes";
+
+beforeAll(() => {
+  process.env.JWT_SECRET = SECRET;
+  process.env.NEXT_PUBLIC_SUPABASE_URL = "http://localhost:8000";
+  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "test-anon-key";
+});
+
+// ---------------------------------------------------------------------------
+// In-memory state the route mock observes/mutates.
+// ---------------------------------------------------------------------------
+
+let currentUser: { id: string; sessionId: string; iatOriginal: number } | null = null;
+let dailyCount = 0;
+let dailyCountErr: { message: string } | null = null;
+let signInResult: {
+  user: { id: string; is_anonymous: boolean; aud: string; role: string } | null;
+  error?: { message: string } | null;
+} = {
+  user: {
+    id: "00000000-0000-0000-0000-000000000aaa",
+    is_anonymous: true,
+    aud: "authenticated",
+    role: "authenticated",
+  },
+  error: null,
+};
+let publicUsersInsertErr: { message: string } | null = null;
+let createSessionFails = false;
+
+const insertedPublicUsers = vi.fn<(row: { id: string; display_name: string }) => void>();
+const deletedAuthUsers = vi.fn<(uid: string) => void>();
+
+const setCookieMock = vi.fn();
+
+// ---------------------------------------------------------------------------
+// Module mocks
+// ---------------------------------------------------------------------------
+
+vi.mock("@/lib/auth/current-user", () => ({
+  getCurrentUser: async () => currentUser,
+}));
+
+vi.mock("@/lib/auth/cookies", () => ({
+  setSessionCookie: async (...args: unknown[]) => {
+    setCookieMock(...args);
+  },
+}));
+
+vi.mock("@/lib/auth/sessions", () => ({
+  createSession: async (uid: string) => {
+    if (createSessionFails) throw new Error("createSession boom");
+    return {
+      id: "11111111-1111-1111-1111-111111111111",
+      iat_original: Math.floor(Date.now() / 1000),
+    };
+  },
+}));
+
+vi.mock("@/lib/auth/rate-limit", () => ({
+  checkRateLimit: () => ({
+    allowed: rateAllowed,
+    remaining: 1,
+    retryAfterMs: rateAllowed ? 0 : 60_000,
+  }),
+  getClientIp: () => "127.0.0.1",
+}));
+
+let rateAllowed = true;
+
+// Build a chainable fluent admin mock. Specifically:
+//   admin.from("user_sessions").select("id", {head, count:"exact"}).gt(...)
+//     -> returns { count, error }
+//   admin.from("users").insert({...}) -> { error }
+//   admin.auth.admin.createUser({}) -> { data, error }
+//   admin.auth.admin.deleteUser(uid) -> { error }
+function buildAdminMock() {
+  return {
+    from: (table: string) => {
+      if (table === "user_sessions") {
+        return {
+          select: (_cols: string, _opts: unknown) => ({
+            gt: async (_col: string, _val: string) => ({
+              count: dailyCount,
+              error: dailyCountErr,
+            }),
+          }),
+        };
+      }
+      if (table === "users") {
+        return {
+          insert: async (row: { id: string; display_name: string }) => {
+            insertedPublicUsers(row);
+            return { error: publicUsersInsertErr };
+          },
+        };
+      }
+      throw new Error(`unexpected table ${table}`);
+    },
+    auth: {
+      admin: {
+        deleteUser: async (uid: string) => {
+          deletedAuthUsers(uid);
+          return { error: null };
+        },
+      },
+    },
+  };
+}
+
+vi.mock("@supabase/supabase-js", () => ({
+  createClient: () => ({
+    auth: {
+      signInAnonymously: async () => ({
+        data: { user: signInResult.user, session: null },
+        error: signInResult.error ?? null,
+      }),
+    },
+  }),
+}));
+
+vi.mock("@/lib/supabase/admin", () => ({
+  getSupabaseAdminClient: () => buildAdminMock(),
+}));
+
+import { NextRequest } from "next/server";
+const { POST } = await import("@/app/api/auth/bootstrap/route");
+
+function makeReq(): NextRequest {
+  return new NextRequest("http://localhost/api/auth/bootstrap", { method: "POST" });
+}
+
+beforeEach(() => {
+  currentUser = null;
+  dailyCount = 0;
+  dailyCountErr = null;
+  publicUsersInsertErr = null;
+  createSessionFails = false;
+  rateAllowed = true;
+  signInResult = {
+    user: {
+      id: "00000000-0000-0000-0000-000000000aaa",
+      is_anonymous: true,
+      aud: "authenticated",
+      role: "authenticated",
+    },
+    error: null,
+  };
+  insertedPublicUsers.mockClear();
+  deletedAuthUsers.mockClear();
+  setCookieMock.mockClear();
+});
+
+describe("POST /api/auth/bootstrap", () => {
+  it("happy path: creates auth.users + public.users, sets cookie, returns {id, isAnonymous:true}", async () => {
+    const res = await POST(makeReq());
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as { id: string; isAnonymous: boolean };
+    expect(body.id).toBe("00000000-0000-0000-0000-000000000aaa");
+    expect(body.isAnonymous).toBe(true);
+
+    expect(insertedPublicUsers).toHaveBeenCalledTimes(1);
+    expect(insertedPublicUsers.mock.calls[0][0].id).toBe("00000000-0000-0000-0000-000000000aaa");
+    expect(insertedPublicUsers.mock.calls[0][0].display_name).toBeTruthy();
+    expect(setCookieMock).toHaveBeenCalledTimes(1);
+    expect(deletedAuthUsers).not.toHaveBeenCalled();
+  });
+
+  it("idempotent: when already authenticated, returns existing user without creating new rows", async () => {
+    currentUser = {
+      id: "ffffffff-ffff-ffff-ffff-ffffffffffff",
+      sessionId: "s",
+      iatOriginal: Math.floor(Date.now() / 1000),
+    };
+    const res = await POST(makeReq());
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as { id: string; isAnonymous: boolean };
+    expect(body.id).toBe("ffffffff-ffff-ffff-ffff-ffffffffffff");
+    expect(insertedPublicUsers).not.toHaveBeenCalled();
+    expect(setCookieMock).not.toHaveBeenCalled();
+  });
+
+  it("429 when per-IP rate limit exceeded", async () => {
+    rateAllowed = false;
+    const res = await POST(makeReq());
+    expect(res.status).toBe(429);
+    expect(res.headers.get("Retry-After")).toBeTruthy();
+    expect(insertedPublicUsers).not.toHaveBeenCalled();
+  });
+
+  it("503 when daily circuit breaker exceeded", async () => {
+    process.env.BOOTSTRAP_DAILY_CAP = "5";
+    dailyCount = 10;
+    const res = await POST(makeReq());
+    expect(res.status).toBe(503);
+    expect(insertedPublicUsers).not.toHaveBeenCalled();
+    delete process.env.BOOTSTRAP_DAILY_CAP;
+  });
+
+  it("rolls back auth.users when public.users insert fails", async () => {
+    publicUsersInsertErr = { message: "FK violation or whatever" };
+    const res = await POST(makeReq());
+    expect(res.status).toBe(500);
+    expect(insertedPublicUsers).toHaveBeenCalledTimes(1);
+    expect(deletedAuthUsers).toHaveBeenCalledTimes(1);
+    expect(deletedAuthUsers.mock.calls[0][0]).toBe("00000000-0000-0000-0000-000000000aaa");
+    expect(setCookieMock).not.toHaveBeenCalled();
+  });
+
+  it("rolls back auth.users when createSession fails", async () => {
+    createSessionFails = true;
+    const res = await POST(makeReq());
+    expect(res.status).toBe(500);
+    expect(deletedAuthUsers).toHaveBeenCalledTimes(1);
+    expect(setCookieMock).not.toHaveBeenCalled();
+  });
+});

+ 100 - 0
src/__tests__/api/me.test.ts

@@ -0,0 +1,100 @@
+// @vitest-environment node
+import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
+
+const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes";
+
+beforeAll(() => {
+  process.env.JWT_SECRET = SECRET;
+  process.env.NEXT_PUBLIC_SUPABASE_URL = "http://localhost:8000";
+});
+
+const cookieJar = new Map<string, { value: string }>();
+
+vi.mock("next/headers", () => ({
+  cookies: async () => ({
+    get: (name: string) => cookieJar.get(name),
+    set: (name: string, value: string) => {
+      cookieJar.set(name, { value });
+    },
+    delete: (name: string) => {
+      cookieJar.delete(name);
+    },
+  }),
+}));
+
+let sessionRow: { revoked_at: string | null } | null = { revoked_at: null };
+
+vi.mock("@/lib/supabase/admin", () => ({
+  getSupabaseAdminClient: () => ({
+    from: () => ({
+      select: () => ({
+        eq: () => ({
+          maybeSingle: async () => ({ data: sessionRow, error: null }),
+        }),
+      }),
+      update: () => ({ eq: () => Promise.resolve({ data: null, error: null }) }),
+    }),
+  }),
+}));
+
+const { mintAccessToken } = await import("@/lib/auth/jwt");
+const { getSupabaseCookieName } = await import("@/lib/supabase/cookie-name");
+const { GET } = await import("@/app/api/auth/me/route");
+
+const sub = "00000000-0000-0000-0000-0000000000aa";
+const session_id = "11111111-1111-1111-1111-111111111111";
+
+function base64url(input: string): string {
+  return Buffer.from(input, "utf8")
+    .toString("base64")
+    .replace(/\+/g, "-")
+    .replace(/\//g, "_")
+    .replace(/=+$/, "");
+}
+
+async function setCookieFor(accessToken: string) {
+  const session = {
+    access_token: accessToken,
+    token_type: "bearer",
+    expires_in: 3600,
+    expires_at: Math.floor(Date.now() / 1000) + 3600,
+    refresh_token: "",
+    user: null,
+  };
+  cookieJar.set(getSupabaseCookieName(), {
+    value: `base64-${base64url(JSON.stringify(session))}`,
+  });
+}
+
+import { NextRequest } from "next/server";
+
+function makeReq(): NextRequest {
+  return new NextRequest("http://localhost/api/auth/me", { method: "GET" });
+}
+
+beforeEach(() => {
+  cookieJar.clear();
+  sessionRow = { revoked_at: null };
+});
+
+describe("GET /api/auth/me", () => {
+  it("returns 401 when unauthenticated", async () => {
+    const res = await GET(makeReq());
+    expect(res.status).toBe(401);
+  });
+
+  it("returns {id, isAnonymous: true} for a valid session", async () => {
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000) - 60,
+    });
+    await setCookieFor(token);
+
+    const res = await GET(makeReq());
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as { id: string; isAnonymous: boolean };
+    expect(body.id).toBe(sub);
+    expect(body.isAnonymous).toBe(true);
+  });
+});

+ 193 - 0
src/__tests__/api/recovery-claim.test.ts

@@ -0,0 +1,193 @@
+// @vitest-environment node
+import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
+
+const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes";
+
+beforeAll(() => {
+  process.env.JWT_SECRET = SECRET;
+  process.env.NEXT_PUBLIC_SUPABASE_URL = "http://localhost:8000";
+  process.env.RECOVERY_CODE_PEPPER = "test-pepper-with-at-least-32-characters-of-length-y";
+});
+
+// In-memory "table" of recovery codes, keyed by user_id.
+type Row = { user_id: string; argon2_hash: string };
+const recoveryTable = new Map<string, Row>();
+
+// Cookie jar — setSessionCookie writes here via the mocked next/headers.
+const cookieJar = new Map<string, { value: string }>();
+vi.mock("next/headers", () => ({
+  cookies: async () => ({
+    get: (name: string) => cookieJar.get(name),
+    set: (name: string, value: string) => {
+      cookieJar.set(name, { value });
+    },
+    delete: (name: string) => {
+      cookieJar.delete(name);
+    },
+  }),
+}));
+
+// Track inserted user_sessions rows.
+const sessionInserts: Array<{ user_id: string }> = [];
+let nextSessionId = 1;
+
+const adminFromMock = vi.fn((table: string) => {
+  if (table === "recovery_codes") {
+    return {
+      select: (_cols: string) => ({
+        // GET candidates by prefix
+        eq: (_col: string, _val: string) =>
+          Promise.resolve({
+            data: Array.from(recoveryTable.values()),
+            error: null,
+          }),
+      }),
+      delete: () => ({
+        eq: (_col: string, val: string) => ({
+          select: (_c: string) => {
+            // Atomic single-use semantics: pop from the table.
+            const row = recoveryTable.get(val);
+            if (!row) return Promise.resolve({ data: [], error: null });
+            recoveryTable.delete(val);
+            return Promise.resolve({ data: [{ user_id: val }], error: null });
+          },
+        }),
+      }),
+    };
+  }
+  if (table === "user_sessions") {
+    return {
+      insert: (row: { user_id: string }) => ({
+        select: (_c: string) => ({
+          single: async () => {
+            sessionInserts.push({ user_id: row.user_id });
+            return {
+              data: {
+                id: `sess-${nextSessionId++}`,
+                iat_original: new Date().toISOString(),
+              },
+              error: null,
+            };
+          },
+        }),
+      }),
+      // For isSessionLive (not used by claim, but defensive)
+      select: () => ({
+        eq: () => ({
+          maybeSingle: async () => ({ data: { revoked_at: null }, error: null }),
+        }),
+      }),
+      update: () => ({ eq: () => Promise.resolve({ data: null, error: null }) }),
+    };
+  }
+  throw new Error(`Unexpected table: ${table}`);
+});
+
+vi.mock("@/lib/supabase/admin", () => ({
+  getSupabaseAdminClient: () => ({ from: adminFromMock }),
+}));
+
+vi.mock("@/lib/auth/recovery", () => ({
+  verifyRecoveryCode: async (code: string, hash: string) => hash === `argon2$${code}`,
+}));
+
+vi.mock("@/lib/auth/recovery-prefix", () => ({
+  prefixHmacWire: (code: string) => `\\x${Buffer.from(code).toString("hex").slice(0, 16)}`,
+}));
+
+let rateAllowed = true;
+vi.mock("@/lib/auth/rate-limit", () => ({
+  checkRateLimit: () =>
+    rateAllowed
+      ? { allowed: true, remaining: 999, retryAfterMs: 0 }
+      : { allowed: false, remaining: 0, retryAfterMs: 60000 },
+  getClientIp: () => "127.0.0.1",
+}));
+
+import { NextRequest } from "next/server";
+const { POST } = await import("@/app/api/auth/recovery/claim/route");
+const { getSupabaseCookieName } = await import("@/lib/supabase/cookie-name");
+
+function makeReq(body: unknown): NextRequest {
+  return new NextRequest("http://localhost/api/auth/recovery/claim", {
+    method: "POST",
+    headers: { "content-type": "application/json" },
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+}
+
+beforeEach(() => {
+  recoveryTable.clear();
+  recoveryTable.set("uid-1", { user_id: "uid-1", argon2_hash: "argon2$VALIDCODE" });
+  cookieJar.clear();
+  sessionInserts.length = 0;
+  nextSessionId = 1;
+  rateAllowed = true;
+});
+
+describe("POST /api/auth/recovery/claim", () => {
+  it("400 when body has no code", async () => {
+    const res = await POST(makeReq({}));
+    expect(res.status).toBe(400);
+  });
+
+  it("400 when body is not JSON", async () => {
+    const req = new NextRequest("http://localhost/api/auth/recovery/claim", {
+      method: "POST",
+      body: "not json",
+    });
+    const res = await POST(req);
+    expect(res.status).toBe(400);
+  });
+
+  it("429 when rate-limited", async () => {
+    rateAllowed = false;
+    const res = await POST(makeReq({ code: "VALIDCODE" }));
+    expect(res.status).toBe(429);
+    expect(res.headers.get("Retry-After")).toBeTruthy();
+  });
+
+  it("401 on invalid code", async () => {
+    const res = await POST(makeReq({ code: "WRONGCODE" }));
+    expect(res.status).toBe(401);
+    expect(recoveryTable.has("uid-1")).toBe(true); // not deleted
+    expect(sessionInserts).toHaveLength(0);
+    expect(cookieJar.size).toBe(0);
+  });
+
+  it("happy path: cookie set with base64- prefix, row deleted, session created", async () => {
+    const res = await POST(makeReq({ code: "VALIDCODE" }));
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as { ok: boolean };
+    expect(body.ok).toBe(true);
+
+    expect(recoveryTable.has("uid-1")).toBe(false);
+    expect(sessionInserts).toEqual([{ user_id: "uid-1" }]);
+
+    const cookie = cookieJar.get(getSupabaseCookieName());
+    expect(cookie).toBeDefined();
+    expect(cookie!.value.startsWith("base64-")).toBe(true);
+  });
+
+  it("single-use: re-claiming the same consumed code returns 401", async () => {
+    const first = await POST(makeReq({ code: "VALIDCODE" }));
+    expect(first.status).toBe(200);
+    const second = await POST(makeReq({ code: "VALIDCODE" }));
+    expect(second.status).toBe(401);
+  });
+
+  it("concurrency: two parallel claims of the same code — exactly one returns 200", async () => {
+    const [a, b] = await Promise.all([
+      POST(makeReq({ code: "VALIDCODE" })),
+      POST(makeReq({ code: "VALIDCODE" })),
+    ]);
+    const statuses = [a.status, b.status].sort();
+    expect(statuses).toEqual([200, 401]);
+    expect(sessionInserts).toHaveLength(1);
+  });
+
+  it("trims whitespace in code", async () => {
+    const res = await POST(makeReq({ code: "  VALIDCODE  " }));
+    expect(res.status).toBe(200);
+  });
+});

+ 114 - 0
src/__tests__/api/recovery-generate.test.ts

@@ -0,0 +1,114 @@
+// @vitest-environment node
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+let mockUserId: string | null = "uid-1";
+let existingRow: { user_id: string } | null = null;
+let insertError: { message: string } | null = null;
+let rateAllowed = true;
+
+const insertMock = vi.fn(
+  async (_row: { user_id: string; argon2_hash: string; prefix_hmac: string }) => ({
+    error: insertError,
+  }),
+);
+const maybeSingleMock = vi.fn(async () => ({ data: existingRow, error: null }));
+
+const fromMock = vi.fn(() => ({
+  select: () => ({
+    eq: () => ({
+      maybeSingle: maybeSingleMock,
+    }),
+  }),
+  insert: insertMock,
+}));
+
+vi.mock("@/lib/supabase/admin", () => ({
+  getSupabaseAdminClient: () => ({ from: fromMock }),
+}));
+
+vi.mock("@/lib/auth/current-user", () => ({
+  getCurrentUser: async () =>
+    mockUserId
+      ? { id: mockUserId, sessionId: "s-1", iatOriginal: Math.floor(Date.now() / 1000) }
+      : null,
+}));
+
+vi.mock("@/lib/auth/recovery", () => ({
+  generateRecoveryCode: () => "TESTCODE1234567890123456",
+  hashRecoveryCode: async (code: string) => `argon2$${code}`,
+}));
+
+vi.mock("@/lib/auth/recovery-prefix", () => ({
+  prefixHmacWire: (code: string) => `\\x${Buffer.from(code).toString("hex").slice(0, 16)}`,
+}));
+
+vi.mock("@/lib/auth/rate-limit", () => ({
+  checkRateLimit: () =>
+    rateAllowed
+      ? { allowed: true, remaining: 999, retryAfterMs: 0 }
+      : { allowed: false, remaining: 0, retryAfterMs: 60000 },
+  getClientIp: () => "127.0.0.1",
+}));
+
+import { NextRequest } from "next/server";
+const { POST } = await import("@/app/api/auth/recovery/generate/route");
+
+function makeReq(): NextRequest {
+  return new NextRequest("http://localhost/api/auth/recovery/generate", {
+    method: "POST",
+  });
+}
+
+describe("POST /api/auth/recovery/generate", () => {
+  beforeEach(() => {
+    mockUserId = "uid-1";
+    existingRow = null;
+    insertError = null;
+    rateAllowed = true;
+    insertMock.mockClear();
+    maybeSingleMock.mockClear();
+    fromMock.mockClear();
+  });
+
+  it("401 when unauthenticated", async () => {
+    mockUserId = null;
+    const res = await POST(makeReq());
+    expect(res.status).toBe(401);
+    expect(insertMock).not.toHaveBeenCalled();
+  });
+
+  it("429 when rate-limited", async () => {
+    rateAllowed = false;
+    const res = await POST(makeReq());
+    expect(res.status).toBe(429);
+    expect(res.headers.get("Retry-After")).toBeTruthy();
+    expect(insertMock).not.toHaveBeenCalled();
+  });
+
+  it("409 when a recovery code already exists for this user", async () => {
+    existingRow = { user_id: "uid-1" };
+    const res = await POST(makeReq());
+    expect(res.status).toBe(409);
+    expect(insertMock).not.toHaveBeenCalled();
+  });
+
+  it("happy path: returns the code and inserts row with both argon2_hash and prefix_hmac", async () => {
+    const res = await POST(makeReq());
+    expect(res.status).toBe(200);
+    const body = (await res.json()) as { code: string };
+    expect(body.code).toBe("TESTCODE1234567890123456");
+    expect(insertMock).toHaveBeenCalledTimes(1);
+    const insertArg = insertMock.mock.calls[0][0];
+    expect(insertArg.user_id).toBe("uid-1");
+    expect(insertArg.argon2_hash).toBeTruthy();
+    expect(insertArg.argon2_hash).toMatch(/^argon2\$/);
+    expect(insertArg.prefix_hmac).toBeTruthy();
+    expect(insertArg.prefix_hmac).toMatch(/^\\x/);
+  });
+
+  it("500 when insert fails", async () => {
+    insertError = { message: "db down" };
+    const res = await POST(makeReq());
+    expect(res.status).toBe(500);
+  });
+});

+ 147 - 0
src/__tests__/api/touch.test.ts

@@ -0,0 +1,147 @@
+// @vitest-environment node
+import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
+
+const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes";
+
+beforeAll(() => {
+  process.env.JWT_SECRET = SECRET;
+  process.env.NEXT_PUBLIC_SUPABASE_URL = "http://localhost:8000";
+});
+
+const cookieJar = new Map<string, { value: string }>();
+
+vi.mock("next/headers", () => ({
+  cookies: async () => ({
+    get: (name: string) => cookieJar.get(name),
+    set: (name: string, value: string) => {
+      cookieJar.set(name, { value });
+    },
+    delete: (name: string) => {
+      cookieJar.delete(name);
+    },
+  }),
+}));
+
+let sessionRow: { revoked_at: string | null } | null = { revoked_at: null };
+
+vi.mock("@/lib/supabase/admin", () => ({
+  getSupabaseAdminClient: () => ({
+    from: () => ({
+      select: () => ({
+        eq: () => ({
+          maybeSingle: async () => ({ data: sessionRow, error: null }),
+        }),
+      }),
+      update: () => ({ eq: () => Promise.resolve({ data: null, error: null }) }),
+    }),
+  }),
+}));
+
+const { mintAccessToken, verifyAccessToken, ABSOLUTE_SESSION_CAP_SECONDS } =
+  await import("@/lib/auth/jwt");
+const { getSupabaseCookieName } = await import("@/lib/supabase/cookie-name");
+const { POST } = await import("@/app/api/auth/touch/route");
+
+const sub = "00000000-0000-0000-0000-0000000000aa";
+const session_id = "11111111-1111-1111-1111-111111111111";
+
+function base64url(input: string): string {
+  return Buffer.from(input, "utf8")
+    .toString("base64")
+    .replace(/\+/g, "-")
+    .replace(/\//g, "_")
+    .replace(/=+$/, "");
+}
+
+async function setCookieFor(accessToken: string) {
+  const session = {
+    access_token: accessToken,
+    token_type: "bearer",
+    expires_in: 3600,
+    expires_at: Math.floor(Date.now() / 1000) + 3600,
+    refresh_token: "",
+    user: null,
+  };
+  cookieJar.set(getSupabaseCookieName(), {
+    value: `base64-${base64url(JSON.stringify(session))}`,
+  });
+}
+
+function readCookieAccessToken(): string | null {
+  const raw = cookieJar.get(getSupabaseCookieName())?.value;
+  if (!raw) return null;
+  const b64 = raw.slice("base64-".length).replace(/-/g, "+").replace(/_/g, "/");
+  const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
+  const json = Buffer.from(b64 + pad, "base64").toString("utf8");
+  return JSON.parse(json).access_token;
+}
+
+import { NextRequest } from "next/server";
+
+function makeReq(): NextRequest {
+  return new NextRequest("http://localhost/api/auth/touch", { method: "POST" });
+}
+
+beforeEach(() => {
+  cookieJar.clear();
+  sessionRow = { revoked_at: null };
+});
+
+describe("POST /api/auth/touch", () => {
+  it("401 when no auth cookie present", async () => {
+    const res = await POST(makeReq());
+    expect(res.status).toBe(401);
+  });
+
+  it("401 when cookie is malformed", async () => {
+    cookieJar.set(getSupabaseCookieName(), { value: "garbage" });
+    const res = await POST(makeReq());
+    expect(res.status).toBe(401);
+  });
+
+  it("401 when absolute cap exceeded", async () => {
+    const tooOld = Math.floor(Date.now() / 1000) - ABSOLUTE_SESSION_CAP_SECONDS - 60;
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: tooOld,
+    });
+    await setCookieFor(token);
+    const res = await POST(makeReq());
+    expect(res.status).toBe(401);
+  });
+
+  it("401 when session is revoked", async () => {
+    sessionRow = { revoked_at: new Date().toISOString() };
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000) - 60,
+    });
+    await setCookieFor(token);
+    const res = await POST(makeReq());
+    expect(res.status).toBe(401);
+  });
+
+  it("happy path: re-mints, preserves iat_original, writes fresh cookie", async () => {
+    const iat_original = Math.floor(Date.now() / 1000) - 1000;
+    const original = await mintAccessToken({ sub, session_id, iat_original });
+    await setCookieFor(original);
+
+    // Wait a tick so the new token's iat differs from the old.
+    await new Promise((r) => setTimeout(r, 1100));
+
+    const res = await POST(makeReq());
+    expect(res.status).toBe(200);
+
+    const fresh = readCookieAccessToken();
+    expect(fresh).not.toBeNull();
+    expect(fresh).not.toBe(original);
+
+    const claims = await verifyAccessToken(fresh!);
+    expect(claims).not.toBeNull();
+    expect(claims!.sub).toBe(sub);
+    expect(claims!.session_id).toBe(session_id);
+    expect(claims!.iat_original).toBe(iat_original);
+  });
+});

+ 108 - 0
src/__tests__/auth/cookies.test.ts

@@ -0,0 +1,108 @@
+// @vitest-environment node
+import { describe, it, expect, beforeAll, vi } from "vitest";
+import { createServerClient } from "@supabase/ssr";
+import { mintAccessToken } from "@/lib/auth/jwt";
+import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
+
+const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes";
+
+beforeAll(() => {
+  process.env.JWT_SECRET = SECRET;
+  // Required for getSupabaseCookieName() to derive a project-scoped cookie name.
+  process.env.NEXT_PUBLIC_SUPABASE_URL = "http://localhost:8000";
+});
+
+// In-memory cookie jar that the next/headers `cookies()` helper would normally
+// proxy. We test setSessionCookie indirectly by reproducing the exact write it
+// performs (per src/lib/auth/cookies.ts), then verifying that a freshly
+// constructed createServerClient bound to the same jar reads it back through
+// `auth.getSession()`.
+//
+// **DELIBERATE DIVERGENCE FROM research/COOKIE-SHAPE-DECISION.md § 4 RECIPE:**
+// the linked recipe asserts via `supabase.auth.getUser()`, but that method
+// always hits live GoTrue at /auth/v1/user, and v3 of PLAN-AUTH-A documents
+// that GoTrue rejects HS256 JWTs whose session_id is not in `auth.sessions`.
+// Our hand-minted tokens carry a synthetic session_id pointing at a
+// `public.user_sessions` row, so `getUser()` will 403. Instead we assert
+// `(await supabase.auth.getSession()).data.session?.access_token === minted` —
+// this exercises the exact same `_isValidSession` path inside @supabase/ssr +
+// auth-js (so a malformed cookie would still fail the test) without making a
+// network call to GoTrue.
+
+function buildServerClientWithJar(jar: Map<string, string>) {
+  return createServerClient("http://localhost:8000", "anon-key-not-used-by-getSession", {
+    cookieOptions: { name: getSupabaseCookieName() },
+    cookies: {
+      getAll: () => [...jar.entries()].map(([name, value]) => ({ name, value })),
+      setAll: (toSet: Array<{ name: string; value: string }>) => {
+        for (const c of toSet) {
+          jar.set(c.name, c.value);
+        }
+      },
+    },
+  });
+}
+
+function base64url(input: string): string {
+  return Buffer.from(input, "utf8")
+    .toString("base64")
+    .replace(/\+/g, "-")
+    .replace(/\//g, "_")
+    .replace(/=+$/, "");
+}
+
+function writeSessionCookie(jar: Map<string, string>, accessToken: string, exp: number) {
+  const session = {
+    access_token: accessToken,
+    token_type: "bearer" as const,
+    expires_in: Math.max(0, exp - Math.floor(Date.now() / 1000)),
+    expires_at: exp,
+    refresh_token: "",
+    user: null,
+  };
+  jar.set(getSupabaseCookieName(), `base64-${base64url(JSON.stringify(session))}`);
+}
+
+describe("setSessionCookie shape (Option X — base64-prefixed base64url JSON)", () => {
+  it("createServerClient.auth.getSession() reads back the cookie we wrote", async () => {
+    const sub = "00000000-0000-0000-0000-0000000000aa";
+    const session_id = "11111111-1111-1111-1111-111111111111";
+    const exp = Math.floor(Date.now() / 1000) + 3600;
+    const accessToken = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000),
+    });
+
+    const jar = new Map<string, string>();
+    writeSessionCookie(jar, accessToken, exp);
+
+    const supabase = buildServerClientWithJar(jar);
+    const { data, error } = await supabase.auth.getSession();
+    expect(error).toBeNull();
+    expect(data.session).not.toBeNull();
+    expect(data.session?.access_token).toBe(accessToken);
+  });
+
+  it("returns null session for a malformed cookie value", async () => {
+    const jar = new Map<string, string>();
+    jar.set(getSupabaseCookieName(), "not-a-valid-cookie-payload");
+    const supabase = buildServerClientWithJar(jar);
+    const { data } = await supabase.auth.getSession();
+    expect(data.session).toBeNull();
+  });
+
+  it("returns null session when no auth cookie is present", async () => {
+    const jar = new Map<string, string>();
+    const supabase = buildServerClientWithJar(jar);
+    const { data } = await supabase.auth.getSession();
+    expect(data.session).toBeNull();
+  });
+});
+
+// Ensure no real network calls escape the test (defensive — getSession()
+// shouldn't make any, but if a future @supabase/ssr change starts calling
+// /auth/v1/user we want to fail loudly instead of timing out the suite).
+vi.stubGlobal("fetch", async () => {
+  throw new Error("Unexpected network call in cookies.test.ts — getSession() must be local-only");
+});

+ 184 - 0
src/__tests__/auth/current-user.test.ts

@@ -0,0 +1,184 @@
+// @vitest-environment node
+import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
+
+const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes";
+
+beforeAll(() => {
+  process.env.JWT_SECRET = SECRET;
+  process.env.NEXT_PUBLIC_SUPABASE_URL = "http://localhost:8000";
+});
+
+// ---- Mocks ----
+
+const cookieJar = new Map<string, { value: string }>();
+
+vi.mock("next/headers", () => ({
+  cookies: async () => ({
+    get: (name: string) => cookieJar.get(name),
+    set: (name: string, value: string) => {
+      cookieJar.set(name, { value });
+    },
+    delete: (name: string) => {
+      cookieJar.delete(name);
+    },
+  }),
+}));
+
+// `user_sessions` lookup behaviour controlled per-test.
+let sessionRow: { revoked_at: string | null } | null = {
+  revoked_at: null,
+};
+
+const adminFromMock = vi.fn(() => ({
+  select: () => ({
+    eq: () => ({
+      maybeSingle: async () => ({ data: sessionRow, error: null }),
+    }),
+  }),
+  update: () => ({ eq: () => Promise.resolve({ data: null, error: null }) }),
+  insert: () => ({
+    select: () => ({
+      single: async () => ({
+        data: { id: "session-id", iat_original: new Date().toISOString() },
+        error: null,
+      }),
+    }),
+  }),
+}));
+
+vi.mock("@/lib/supabase/admin", () => ({
+  getSupabaseAdminClient: () => ({ from: adminFromMock }),
+}));
+
+// ---- Imports under test (after mocks) ----
+const { mintAccessToken, ABSOLUTE_SESSION_CAP_SECONDS } = await import("@/lib/auth/jwt");
+const { requireUser, getCurrentUser, UnauthorizedError } = await import("@/lib/auth/current-user");
+const { getSupabaseCookieName } = await import("@/lib/supabase/cookie-name");
+
+const sub = "00000000-0000-0000-0000-0000000000aa";
+const session_id = "11111111-1111-1111-1111-111111111111";
+
+function base64url(input: string): string {
+  return Buffer.from(input, "utf8")
+    .toString("base64")
+    .replace(/\+/g, "-")
+    .replace(/\//g, "_")
+    .replace(/=+$/, "");
+}
+
+async function setCookieFor(accessToken: string) {
+  const session = {
+    access_token: accessToken,
+    token_type: "bearer",
+    expires_in: 3600,
+    expires_at: Math.floor(Date.now() / 1000) + 3600,
+    refresh_token: "",
+    user: null,
+  };
+  cookieJar.set(getSupabaseCookieName(), {
+    value: `base64-${base64url(JSON.stringify(session))}`,
+  });
+}
+
+beforeEach(() => {
+  cookieJar.clear();
+  sessionRow = { revoked_at: null };
+});
+
+describe("requireUser / getCurrentUser", () => {
+  it("happy path: valid cookie + live session returns the user", async () => {
+    const iat_original = Math.floor(Date.now() / 1000) - 60;
+    const token = await mintAccessToken({ sub, session_id, iat_original });
+    await setCookieFor(token);
+
+    const user = await requireUser();
+    expect(user.id).toBe(sub);
+    expect(user.sessionId).toBe(session_id);
+    expect(user.iatOriginal).toBe(iat_original);
+  });
+
+  it("throws UnauthorizedError when no cookie is present", async () => {
+    await expect(requireUser()).rejects.toBeInstanceOf(UnauthorizedError);
+    expect(await getCurrentUser()).toBeNull();
+  });
+
+  it("throws when the JWT is signed with the wrong secret", async () => {
+    const other = "totally-different-secret-but-also-32-characters-long-yo";
+    process.env.JWT_SECRET = other;
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000),
+    });
+    process.env.JWT_SECRET = SECRET; // restore for verify path
+    await setCookieFor(token);
+
+    await expect(requireUser()).rejects.toBeInstanceOf(UnauthorizedError);
+  });
+
+  it("throws when the JWT is expired", async () => {
+    const { SignJWT } = await import("jose");
+    const past = Math.floor(Date.now() / 1000) - 7200;
+    const expired = await new SignJWT({
+      sub,
+      session_id,
+      iat_original: past,
+    })
+      .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
+      .setIssuedAt(past)
+      .setNotBefore(past - 10)
+      .setExpirationTime(past + 3600) // expired 1h ago
+      .setIssuer("moviedice")
+      .setAudience("authenticated")
+      .sign(new TextEncoder().encode(SECRET));
+    await setCookieFor(expired);
+
+    await expect(requireUser()).rejects.toBeInstanceOf(UnauthorizedError);
+  });
+
+  it("throws when the session row is revoked", async () => {
+    sessionRow = { revoked_at: new Date().toISOString() };
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000),
+    });
+    await setCookieFor(token);
+
+    await expect(requireUser()).rejects.toBeInstanceOf(UnauthorizedError);
+  });
+
+  it("throws when the session row is missing", async () => {
+    sessionRow = null;
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000),
+    });
+    await setCookieFor(token);
+
+    await expect(requireUser()).rejects.toBeInstanceOf(UnauthorizedError);
+  });
+
+  it("throws when iat_original exceeds the 30-day absolute cap", async () => {
+    const tooOld = Math.floor(Date.now() / 1000) - ABSOLUTE_SESSION_CAP_SECONDS - 60;
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: tooOld,
+    });
+    await setCookieFor(token);
+
+    await expect(requireUser()).rejects.toBeInstanceOf(UnauthorizedError);
+  });
+
+  it("UnauthorizedError carries a 401 NextResponse", async () => {
+    try {
+      await requireUser();
+      throw new Error("expected throw");
+    } catch (e) {
+      expect(e).toBeInstanceOf(UnauthorizedError);
+      expect((e as InstanceType<typeof UnauthorizedError>).response.status).toBe(401);
+    }
+  });
+});

+ 144 - 0
src/__tests__/auth/jwt.test.ts

@@ -0,0 +1,144 @@
+// @vitest-environment node
+import { describe, it, expect, beforeAll } from "vitest";
+import { SignJWT, jwtVerify, decodeProtectedHeader, decodeJwt } from "jose";
+import { mintAccessToken, verifyAccessToken } from "@/lib/auth/jwt";
+
+const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes";
+
+beforeAll(() => {
+  process.env.JWT_SECRET = SECRET;
+});
+
+const sub = "00000000-0000-0000-0000-000000000abc";
+const session_id = "11111111-1111-1111-1111-111111111111";
+
+describe("mintAccessToken / verifyAccessToken", () => {
+  it("roundtrips and preserves iat_original", async () => {
+    const iat_original = Math.floor(Date.now() / 1000) - 60;
+    const token = await mintAccessToken({ sub, session_id, iat_original });
+    const claims = await verifyAccessToken(token);
+    expect(claims).not.toBeNull();
+    expect(claims!.sub).toBe(sub);
+    expect(claims!.session_id).toBe(session_id);
+    expect(claims!.iat_original).toBe(iat_original);
+  });
+
+  it("sets the protected header (alg=HS256, typ=JWT, kid=v1)", async () => {
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000),
+    });
+    const header = decodeProtectedHeader(token);
+    expect(header.alg).toBe("HS256");
+    expect(header.typ).toBe("JWT");
+    expect(header.kid).toBe("v1");
+  });
+
+  it("sets iss=moviedice and aud=authenticated", async () => {
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000),
+    });
+    const payload = decodeJwt(token);
+    expect(payload.iss).toBe("moviedice");
+    expect(payload.aud).toBe("authenticated");
+    expect(payload.role).toBe("authenticated");
+    expect(payload.is_anonymous).toBe(true);
+  });
+
+  it("rejects an expired token", async () => {
+    const secret = new TextEncoder().encode(SECRET);
+    const past = Math.floor(Date.now() / 1000) - 7200; // 2h ago
+    const token = await new SignJWT({
+      sub,
+      session_id,
+      iat_original: past,
+      role: "authenticated",
+    })
+      .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
+      .setIssuedAt(past)
+      .setNotBefore(past - 10)
+      .setExpirationTime(past + 3600) // expired 1h ago
+      .setIssuer("moviedice")
+      .setAudience("authenticated")
+      .sign(secret);
+    expect(await verifyAccessToken(token)).toBeNull();
+  });
+
+  it("rejects a token whose nbf is in the future (beyond skew)", async () => {
+    const secret = new TextEncoder().encode(SECRET);
+    const future = Math.floor(Date.now() / 1000) + 600; // +10min
+    const token = await new SignJWT({
+      sub,
+      session_id,
+      iat_original: future,
+      role: "authenticated",
+    })
+      .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
+      .setIssuedAt(future)
+      .setNotBefore(future)
+      .setExpirationTime(future + 3600)
+      .setIssuer("moviedice")
+      .setAudience("authenticated")
+      .sign(secret);
+    expect(await verifyAccessToken(token)).toBeNull();
+  });
+
+  it("rejects a token signed with the wrong secret", async () => {
+    const wrong = new TextEncoder().encode("wrong-secret-also-32-characters-long-yes-yes!");
+    const now = Math.floor(Date.now() / 1000);
+    const token = await new SignJWT({
+      sub,
+      session_id,
+      iat_original: now,
+      role: "authenticated",
+    })
+      .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
+      .setIssuedAt()
+      .setNotBefore("-10s")
+      .setExpirationTime("1h")
+      .setIssuer("moviedice")
+      .setAudience("authenticated")
+      .sign(wrong);
+    expect(await verifyAccessToken(token)).toBeNull();
+  });
+
+  it("rejects a token with wrong issuer or audience", async () => {
+    const secret = new TextEncoder().encode(SECRET);
+    const now = Math.floor(Date.now() / 1000);
+    const wrongIss = await new SignJWT({ sub, session_id, iat_original: now })
+      .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
+      .setIssuedAt()
+      .setNotBefore("-10s")
+      .setExpirationTime("1h")
+      .setIssuer("not-moviedice")
+      .setAudience("authenticated")
+      .sign(secret);
+    expect(await verifyAccessToken(wrongIss)).toBeNull();
+
+    const wrongAud = await new SignJWT({ sub, session_id, iat_original: now })
+      .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
+      .setIssuedAt()
+      .setNotBefore("-10s")
+      .setExpirationTime("1h")
+      .setIssuer("moviedice")
+      .setAudience("not-authenticated")
+      .sign(secret);
+    expect(await verifyAccessToken(wrongAud)).toBeNull();
+  });
+
+  it("verifies a token signed seconds in the future thanks to nbf skew", async () => {
+    // mintAccessToken sets nbf = -10s; jose's jwtVerify uses clockTolerance:0
+    // by default but our nbf is already 10s in the past, so it should verify
+    // immediately.
+    const token = await mintAccessToken({
+      sub,
+      session_id,
+      iat_original: Math.floor(Date.now() / 1000),
+    });
+    const claims = await verifyAccessToken(token);
+    expect(claims).not.toBeNull();
+  });
+});

+ 34 - 0
src/__tests__/guards/no-is-anonymous-policy.test.ts

@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync, readdirSync } from "node:fs";
+import path from "node:path";
+
+const MIGRATIONS_DIR = path.resolve(__dirname, "..", "..", "..", "supabase", "migrations");
+
+// Matches `auth.jwt()->>'is_anonymous'` and `auth.jwt() ->> 'is_anonymous'`
+// (with arbitrary whitespace around `->>`). Single OR double quotes.
+const FORBIDDEN_RE = /auth\.jwt\(\)\s*->>\s*['"]is_anonymous['"]/;
+
+describe("CI guard: no policy depends on auth.jwt()->>'is_anonymous'", () => {
+  it("no migration references the is_anonymous JWT claim", () => {
+    const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
+    const violations: { file: string; line: number; text: string }[] = [];
+    for (const file of files) {
+      const content = readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
+      const lines = content.split(/\r?\n/);
+      for (let i = 0; i < lines.length; i++) {
+        if (FORBIDDEN_RE.test(lines[i])) {
+          violations.push({ file, line: i + 1, text: lines[i].trim() });
+        }
+      }
+    }
+    if (violations.length > 0) {
+      const report = violations.map((v) => `  ${v.file}:${v.line}: ${v.text}`).join("\n");
+      throw new Error(
+        `Migrations must not gate on auth.jwt()->>'is_anonymous':\n${report}\n\n` +
+          `Under the new auth design every user is is_anonymous=true; that ` +
+          `flag has no semantic meaning. See CLAUDE.md → Auth.`,
+      );
+    }
+    expect(violations).toEqual([]);
+  });
+});

+ 85 - 0
src/__tests__/guards/no-raw-getuser.test.ts

@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import path from "node:path";
+
+const SRC_DIR = path.resolve(__dirname, "..", "..");
+const ALLOWED_FILES = new Set<string>([
+  // current-user.ts is structurally allowed but does not actually call
+  // `auth.getUser()` — it does local JWT verification. Listed here for
+  // forward compatibility in case it ever needs to.
+  path.join(SRC_DIR, "lib", "auth", "current-user.ts"),
+]);
+
+const SKIP_DIRS = new Set<string>(["__tests__", "node_modules"]);
+
+function walk(dir: string, out: string[] = []): string[] {
+  for (const entry of readdirSync(dir)) {
+    const full = path.join(dir, entry);
+    const s = statSync(full);
+    if (s.isDirectory()) {
+      if (SKIP_DIRS.has(entry)) continue;
+      walk(full, out);
+    } else if (s.isFile() && (full.endsWith(".ts") || full.endsWith(".tsx"))) {
+      out.push(full);
+    }
+  }
+  return out;
+}
+
+// Detect `auth.getUser(` and `auth.getSession(` but allow the admin-API
+// `auth.admin.getUser(` form (Bearer-token validation, a different surface).
+// Inline `// CI-GUARD-EXEMPT: <reason>` on the same line opts an individual
+// call site out of the guard.
+function findOffendingLines(content: string): string[] {
+  const lines = content.split(/\r?\n/);
+  const offenses: string[] = [];
+  let inBlockComment = false;
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i];
+    const trimmed = line.trim();
+
+    // Track simple /* ... */ block comments. Conservative: a line that
+    // opens and never closes flips state; one that closes resets.
+    if (inBlockComment) {
+      if (trimmed.includes("*/")) inBlockComment = false;
+      continue;
+    }
+    if (trimmed.startsWith("/*") && !trimmed.includes("*/")) {
+      inBlockComment = true;
+      continue;
+    }
+    // Skip line comments and lone JSDoc continuation lines.
+    if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
+
+    if (line.includes("CI-GUARD-EXEMPT")) continue;
+    // Strip `auth.admin.getUser(` from consideration
+    const sanitized = line.replace(/auth\.admin\.getUser\(/g, "auth.admin.OK(");
+    if (/auth\.getUser\(|auth\.getSession\(/.test(sanitized)) {
+      offenses.push(`L${i + 1}: ${line.trim()}`);
+    }
+  }
+  return offenses;
+}
+
+describe("CI guard: no raw supabase.auth.getUser/getSession outside the wrapper", () => {
+  it("every src/ file (excluding __tests__ and current-user.ts) is clean", () => {
+    const files = walk(SRC_DIR).filter((f) => !ALLOWED_FILES.has(f));
+    const violations: { file: string; lines: string[] }[] = [];
+    for (const file of files) {
+      const content = readFileSync(file, "utf8");
+      const lines = findOffendingLines(content);
+      if (lines.length > 0) {
+        violations.push({ file: path.relative(SRC_DIR, file), lines });
+      }
+    }
+    if (violations.length > 0) {
+      const report = violations.map((v) => `\n  ${v.file}\n    ${v.lines.join("\n    ")}`).join("");
+      throw new Error(
+        `Forbidden raw auth.getUser()/getSession() call(s):${report}\n\n` +
+          `Use requireUser()/getCurrentUser() from src/lib/auth/current-user.ts ` +
+          `(server) or useCurrentUser() from src/hooks/use-current-user.ts (client).`,
+      );
+    }
+    expect(violations).toEqual([]);
+  });
+});

+ 61 - 0
src/__tests__/integration/realtime-smoke.test.ts

@@ -0,0 +1,61 @@
+// @vitest-environment node
+/**
+ * Smoke test: a hand-minted HS256 JWT (mintAccessToken) is accepted by
+ * PostgREST. RLS resolves `auth.uid()` to the JWT's `sub` and returns
+ * exactly the rows that user is allowed to see.
+ *
+ * Gated on RUN_INTEGRATION=1; skipped otherwise.
+ *
+ * We test PostgREST rather than Realtime directly because:
+ *   - Realtime accepts the same JWT via the same HS256 verification path,
+ *     so PostgREST acceptance proves the load-bearing claim.
+ *   - The Realtime ws client adds harness complexity disproportionate to
+ *     the assertion.
+ */
+import { describe, it, expect } from "vitest";
+import { createClient } from "@supabase/supabase-js";
+import { randomUUID } from "node:crypto";
+
+const RUN = process.env.RUN_INTEGRATION === "1";
+const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
+const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
+const SUPABASE_SERVICE = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
+
+(RUN ? describe : describe.skip)("integration: minted JWT accepted by PostgREST", () => {
+  it("a JWT signed with JWT_SECRET resolves auth.uid() under RLS", async () => {
+    expect(process.env.JWT_SECRET).toBeTruthy();
+
+    // Sign an anon user up (real auth.users row required for FKs / RLS).
+    const anon = createClient(SUPABASE_URL, SUPABASE_ANON, {
+      auth: { persistSession: false, autoRefreshToken: false },
+    });
+    const { data: signin } = await anon.auth.signInAnonymously();
+    const uid = signin.user!.id;
+
+    // Persist a session row so isSessionLive() would pass — not strictly
+    // required for PostgREST but matches the production token shape.
+    const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE, {
+      auth: { persistSession: false, autoRefreshToken: false },
+    });
+    await admin.from("user_sessions").insert({ user_id: uid, id: randomUUID() });
+
+    const { mintAccessToken } = await import("@/lib/auth/jwt");
+    const token = await mintAccessToken({
+      sub: uid,
+      session_id: randomUUID(),
+      iat_original: Math.floor(Date.now() / 1000),
+    });
+
+    // Construct an anon client and inject our minted token. PostgREST will
+    // verify the HS256 signature and resolve auth.uid() = uid for RLS.
+    const minted = createClient(SUPABASE_URL, SUPABASE_ANON, {
+      auth: { persistSession: false, autoRefreshToken: false },
+      global: { headers: { Authorization: `Bearer ${token}` } },
+    });
+
+    // Read the user's own row (RLS allows: auth.uid() = id).
+    const { data, error } = await minted.from("users").select("id").eq("id", uid).maybeSingle();
+    expect(error).toBeNull();
+    expect(data?.id).toBe(uid);
+  }, 30_000);
+});

+ 124 - 0
src/__tests__/integration/recovery-flow.test.ts

@@ -0,0 +1,124 @@
+// @vitest-environment node
+/**
+ * End-to-end recovery flow against the live dev stack.
+ * Gated on RUN_INTEGRATION=1; skipped otherwise.
+ *
+ *   RUN_INTEGRATION=1 npx vitest run integration
+ *
+ * Requires:
+ *   - dev stack running at NEXT_PUBLIC_SITE_URL (default http://localhost:3000)
+ *   - Supabase reachable for `signInAnonymously()`
+ *   - SUPABASE_SERVICE_ROLE_KEY for the revocation step
+ */
+import { describe, it, expect } from "vitest";
+import { createClient } from "@supabase/supabase-js";
+
+const RUN = process.env.RUN_INTEGRATION === "1";
+const SITE = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
+const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
+const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
+const SUPABASE_SERVICE = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
+
+interface Cookie {
+  name: string;
+  value: string;
+}
+
+function parseSetCookie(headers: Headers): Cookie[] {
+  // Iterate raw set-cookie entries. fetch() concatenates them with comma,
+  // which is ambiguous; this works for the simple `name=value; ...` shape
+  // our routes write.
+  const all: string[] = [];
+  const h = headers as Headers & { getSetCookie?: () => string[] };
+  if (typeof h.getSetCookie === "function") {
+    all.push(...h.getSetCookie());
+  } else {
+    const single = headers.get("set-cookie");
+    if (single) all.push(single);
+  }
+  return all
+    .map((line) => line.split(";")[0])
+    .map((kv) => {
+      const idx = kv.indexOf("=");
+      return { name: kv.slice(0, idx).trim(), value: kv.slice(idx + 1).trim() };
+    })
+    .filter((c) => c.name && c.value);
+}
+
+function cookieHeader(cookies: Cookie[]): string {
+  return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
+}
+
+(RUN ? describe : describe.skip)("integration: recovery flow", () => {
+  it("generate -> claim -> /me -> touch -> revoke -> touch fails", async () => {
+    expect(SUPABASE_URL).not.toBe("");
+    expect(SUPABASE_ANON).not.toBe("");
+    expect(SUPABASE_SERVICE).not.toBe("");
+
+    // 1. Anonymous sign-in to get the original UID + access token.
+    const orig = createClient(SUPABASE_URL, SUPABASE_ANON, {
+      auth: { persistSession: false, autoRefreshToken: false },
+    });
+    const { data: signin, error: signinErr } = await orig.auth.signInAnonymously();
+    expect(signinErr).toBeNull();
+    const originalUid = signin.user!.id;
+    const origAccess = signin.session!.access_token;
+
+    // 2. Generate a recovery code (Bearer-authenticated; no cookie yet).
+    const genRes = await fetch(`${SITE}/api/auth/recovery/generate`, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${origAccess}`,
+      },
+    });
+    expect(genRes.status).toBe(200);
+    const { code } = (await genRes.json()) as { code: string };
+    expect(code).toBeTruthy();
+
+    // 3. Claim from a fresh cookie jar.
+    const claimRes = await fetch(`${SITE}/api/auth/recovery/claim`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ code }),
+    });
+    expect(claimRes.status).toBe(200);
+    const cookies = parseSetCookie(claimRes.headers);
+    expect(cookies.length).toBeGreaterThan(0);
+
+    // 4. /api/auth/me with the new cookie returns the original uid.
+    const meRes = await fetch(`${SITE}/api/auth/me`, {
+      headers: { Cookie: cookieHeader(cookies) },
+    });
+    expect(meRes.status).toBe(200);
+    const me = (await meRes.json()) as { id: string; isAnonymous: boolean };
+    expect(me.id).toBe(originalUid);
+
+    // 5. Touch preserves the cookie + iat_original (the route refuses to
+    //    re-mint past the absolute cap, so a fresh session must succeed).
+    const touchRes = await fetch(`${SITE}/api/auth/touch`, {
+      method: "POST",
+      headers: { Cookie: cookieHeader(cookies) },
+    });
+    expect(touchRes.status).toBe(200);
+    const touchedCookies = parseSetCookie(touchRes.headers);
+    expect(touchedCookies.length).toBeGreaterThan(0);
+
+    // 6. Revoke directly via service role; touch must now 401.
+    const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE, {
+      auth: { persistSession: false, autoRefreshToken: false },
+    });
+    const { error: revokeErr } = await admin
+      .from("user_sessions")
+      .update({ revoked_at: new Date().toISOString() })
+      .eq("user_id", originalUid)
+      .is("revoked_at", null);
+    expect(revokeErr).toBeNull();
+
+    const touchAfter = await fetch(`${SITE}/api/auth/touch`, {
+      method: "POST",
+      headers: { Cookie: cookieHeader(touchedCookies) },
+    });
+    expect(touchAfter.status).toBe(401);
+  }, 30_000);
+});

+ 4 - 0
src/app/(app)/layout.tsx

@@ -1,9 +1,13 @@
 import Link from "next/link";
 import { SignOutButton } from "@/components/auth/sign-out-button";
+import { SessionKeeper } from "@/components/auth/session-keeper";
+import { AuthBootstrap } from "@/components/auth/auth-bootstrap";
 
 export default function AppLayout({ children }: { children: React.ReactNode }) {
   return (
     <div className="flex min-h-screen flex-col">
+      <AuthBootstrap />
+      <SessionKeeper />
       <header className="border-b border-foreground/10">
         <nav className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
           <Link href="/" className="text-lg font-bold text-foreground">

+ 6 - 5
src/app/(auth)/recover/page.tsx

@@ -2,11 +2,9 @@
 
 import { useState } from "react";
 import { useMutation } from "@tanstack/react-query";
-import { useRouter } from "next/navigation";
 import { RECOVERY_CODE_LENGTH } from "@/lib/constants";
 
 export default function RecoverPage() {
-  const router = useRouter();
   const [code, setCode] = useState("");
 
   const claimMutation = useMutation({
@@ -18,7 +16,7 @@ export default function RecoverPage() {
       });
 
       const data = (await res.json()) as {
-        userId?: string;
+        ok?: boolean;
         error?: string;
       };
 
@@ -26,10 +24,13 @@ export default function RecoverPage() {
         throw new Error(data.error || "Recovery failed");
       }
 
-      return data as { userId: string };
+      return data as { ok: true };
     },
     onSuccess: () => {
-      router.push("/");
+      // Full document load so the new auth cookie set by the server is sent
+      // with the next request (router.push() keeps the SPA navigation in the
+      // existing fetch context and may race the cookie write).
+      window.location.assign("/");
     },
   });
 

+ 9 - 14
src/app/(auth)/recovery/page.tsx

@@ -2,31 +2,26 @@
 
 import { useRouter } from "next/navigation";
 import { useQuery } from "@tanstack/react-query";
-import { getSupabaseBrowserClient } from "@/lib/supabase/client";
 import { RecoveryCodeDisplay } from "@/components/auth/recovery-code-display";
 
 // useQuery (not useMutation) so TanStack Query dedupes across StrictMode
-// double-mount and post-signup navigation churn. The server overwrites
-// users.recovery_code on each call, so re-firing on a true page reload is
-// fine — but within one browser session we want exactly one fire.
+// double-mount and post-signup navigation churn. Generate is idempotent
+// (returns 409 if a code already exists), so re-firing on a true page
+// reload is safe — but within one browser session we want exactly one fire.
+//
+// Auth flows entirely via the session cookie now: `requireUser()` on the
+// server reads it and validates locally. We no longer pull an access token
+// out of `getSession()` to forward as a Bearer header.
 export default function RecoveryPage() {
   const router = useRouter();
 
   const { data, isPending, error } = useQuery({
     queryKey: ["recovery-code-generate"],
     queryFn: async () => {
-      const supabase = getSupabaseBrowserClient();
-      const {
-        data: { session },
-      } = await supabase.auth.getSession();
-      if (!session) throw new Error("Not authenticated");
-
       const res = await fetch("/api/auth/recovery/generate", {
         method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-          Authorization: `Bearer ${session.access_token}`,
-        },
+        headers: { "Content-Type": "application/json" },
+        credentials: "same-origin",
       });
       const body = (await res.json()) as { code?: string; error?: string };
       if (!res.ok || !body.code) {

+ 127 - 0
src/app/api/auth/bootstrap/route.ts

@@ -0,0 +1,127 @@
+// TODO(security): add Turnstile/PoW before any non-dev deploy. Per-IP rate
+// limit + daily circuit breaker are interim mitigations only. See Stage 5
+// brief.
+import { NextResponse, type NextRequest } from "next/server";
+import { mintAccessToken, ACCESS_TOKEN_TTL_SECONDS } from "@/lib/auth/jwt";
+import { createSession } from "@/lib/auth/sessions";
+import { setSessionCookie } from "@/lib/auth/cookies";
+import { getCurrentUser } from "@/lib/auth/current-user";
+import { createClient } from "@supabase/supabase-js";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { checkRateLimit, getClientIp } from "@/lib/auth/rate-limit";
+
+const PER_IP_WINDOW_MS = 60_000;
+const PER_IP_MAX = 2;
+const DAILY_CAP_DEFAULT = 10_000;
+
+const PLACEHOLDER_DISPLAY_NAME = "New User";
+
+function dailyCap(): number {
+  const raw = process.env.BOOTSTRAP_DAILY_CAP;
+  if (!raw) return DAILY_CAP_DEFAULT;
+  const parsed = Number.parseInt(raw, 10);
+  if (!Number.isFinite(parsed) || parsed <= 0) return DAILY_CAP_DEFAULT;
+  return parsed;
+}
+
+export async function POST(request: NextRequest) {
+  // 1. Idempotency: if caller already has a valid session, return their id
+  //    rather than minting a new anonymous identity. Avoids duplicate
+  //    auth.users rows on re-mounts / strict-mode double-fires.
+  const existing = await getCurrentUser(request);
+  if (existing) {
+    return NextResponse.json({ id: existing.id, isAnonymous: true });
+  }
+
+  // 2. Per-IP rate limit (interim mitigation before Turnstile/PoW).
+  const ip = getClientIp(request);
+  const limit = checkRateLimit(`bootstrap:${ip}`, {
+    windowMs: PER_IP_WINDOW_MS,
+    maxAttempts: PER_IP_MAX,
+  });
+  if (!limit.allowed) {
+    return NextResponse.json(
+      { error: "Too many attempts. Please try again later." },
+      {
+        status: 429,
+        headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) },
+      },
+    );
+  }
+
+  const admin = getSupabaseAdminClient();
+
+  // 3. Daily circuit breaker — sessions created in the last 24h. Protects
+  //    against runaway anonymous-user creation even if rate limits are
+  //    bypassed (per security mitigation 3).
+  const cap = dailyCap();
+  const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
+  const { count: dailyCount, error: countErr } = await admin
+    .from("user_sessions")
+    .select("id", { count: "exact", head: true })
+    .gt("iat_original", since);
+  if (countErr) {
+    console.error("[bootstrap] daily-cap count failed", countErr);
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+  if ((dailyCount ?? 0) >= cap) {
+    console.warn(
+      `[bootstrap] daily circuit breaker tripped: ${dailyCount}/${cap} sessions in last 24h`,
+    );
+    return NextResponse.json({ error: "Service temporarily unavailable" }, { status: 503 });
+  }
+
+  // 4. Create the auth.users row.
+  // GoTrue's admin/users endpoint requires email-or-phone, so we cannot use
+  // it for true anonymous user creation. Instead invoke `signInAnonymously()`
+  // — same code path as the prior browser flow, executed server-side under
+  // our trust boundary. We discard the session/refresh_token GoTrue returns
+  // and mint our own JWT below; the auth.sessions row GoTrue creates is
+  // harmless (we do not reference it). Use a one-shot client (not the
+  // long-lived admin singleton) so its session state isn't mutated.
+  const oneShot = createClient(
+    process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL!,
+    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+    { auth: { autoRefreshToken: false, persistSession: false } },
+  );
+  const { data: created, error: createErr } = await oneShot.auth.signInAnonymously();
+  if (createErr || !created?.user) {
+    console.error("[bootstrap] signInAnonymously failed", createErr);
+    return NextResponse.json({ error: "Failed to create user" }, { status: 500 });
+  }
+  const uid = created.user.id;
+  console.log(
+    `[bootstrap] created auth.users uid=${uid} is_anonymous=${created.user.is_anonymous} aud=${created.user.aud} role=${created.user.role}`,
+  );
+
+  // 5–7. Insert public.users + create our session + mint JWT. On any failure
+  //      between here and cookie set, roll back auth.users (CASCADE will drop
+  //      public.users + user_sessions if they were inserted).
+  try {
+    const { error: insertErr } = await admin.from("users").insert({
+      id: uid,
+      display_name: PLACEHOLDER_DISPLAY_NAME,
+    });
+    if (insertErr) {
+      throw new Error(`public.users insert failed: ${insertErr.message}`);
+    }
+
+    const session = await createSession(uid);
+    const access = await mintAccessToken({
+      sub: uid,
+      session_id: session.id,
+      iat_original: session.iat_original,
+    });
+    const expiresAt = Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS;
+    await setSessionCookie(access, expiresAt);
+
+    return NextResponse.json({ id: uid, isAnonymous: true });
+  } catch (err) {
+    console.error("[bootstrap] post-createUser step failed, rolling back", err);
+    const { error: delErr } = await admin.auth.admin.deleteUser(uid);
+    if (delErr) {
+      console.error("[bootstrap] rollback admin.deleteUser failed", delErr);
+    }
+    return NextResponse.json({ error: "Failed to bootstrap session" }, { status: 500 });
+  }
+}

+ 16 - 0
src/app/api/auth/me/route.ts

@@ -0,0 +1,16 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { getCurrentUser } from "@/lib/auth/current-user";
+
+// GET (not POST): self-introspection is read-only and idempotent. Using GET
+// keeps the browser hook simple (TanStack `useQuery` against a plain URL with
+// no body). Origin/CSRF posture is irrelevant because this endpoint reveals
+// only the caller's own uid, which they already implicitly hold via the cookie.
+export async function GET(_request: NextRequest) {
+  // Route handlers read cookies via next/headers; only middleware (Edge
+  // runtime, no next/headers cookies()) needs to pass `request`.
+  const user = await getCurrentUser();
+  if (!user) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+  return NextResponse.json({ id: user.id, isAnonymous: true });
+}

+ 77 - 45
src/app/api/auth/recovery/claim/route.ts

@@ -1,66 +1,98 @@
-import { NextRequest, NextResponse } from "next/server";
+import { NextResponse, type NextRequest } from "next/server";
 import { z } from "zod";
 import { getSupabaseAdminClient } from "@/lib/supabase/admin";
 import { verifyRecoveryCode } from "@/lib/auth/recovery";
+import { prefixHmacWire } from "@/lib/auth/recovery-prefix";
 import { checkRateLimit, getClientIp } from "@/lib/auth/rate-limit";
+import { mintAccessToken } from "@/lib/auth/jwt";
+import { createSession } from "@/lib/auth/sessions";
+import { setSessionCookie } from "@/lib/auth/cookies";
+
+const IP_WINDOW_MS = 15 * 60 * 1000;
+const IP_MAX = 5;
+const TARGET_RESPONSE_MS = 200; // pad to defeat timing channel
 
 const claimSchema = z.object({
   code: z.string().min(1, "Recovery code is required"),
 });
 
+async function padResponse(startMs: number): Promise<void> {
+  const elapsed = Date.now() - startMs;
+  const remaining = TARGET_RESPONSE_MS - elapsed;
+  if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
+}
+
 export async function POST(request: NextRequest) {
-  try {
-    const ip = getClientIp(request);
-    const limit = checkRateLimit(ip);
+  const start = Date.now();
 
-    if (!limit.allowed) {
-      return NextResponse.json(
-        { error: "Too many attempts. Please try again later." },
-        {
-          status: 429,
-          headers: {
-            "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)),
-          },
-        },
-      );
-    }
+  const ip = getClientIp(request);
+  const limit = checkRateLimit(`recovery-claim:${ip}`, {
+    windowMs: IP_WINDOW_MS,
+    maxAttempts: IP_MAX,
+  });
+  if (!limit.allowed) {
+    return NextResponse.json(
+      { error: "Too many attempts. Please try again later." },
+      {
+        status: 429,
+        headers: { "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)) },
+      },
+    );
+  }
 
-    const body: unknown = await request.json();
-    const parsed = claimSchema.safeParse(body);
-    if (!parsed.success) {
-      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
-    }
+  const body: unknown = await request.json().catch(() => null);
+  const parsed = claimSchema.safeParse(body);
+  if (!parsed.success) {
+    return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
+  }
+  const code = parsed.data.code.trim();
+  if (!code) {
+    return NextResponse.json({ error: "Recovery code is required" }, { status: 400 });
+  }
 
-    const { code } = parsed.data;
-    const admin = getSupabaseAdminClient();
+  const admin = getSupabaseAdminClient();
+  const prefix = prefixHmacWire(code);
 
-    const { data: users, error: fetchError } = await admin
-      .from("users")
-      .select("id, recovery_code")
-      .not("recovery_code", "is", null);
+  // Prefix-narrowed lookup: typically 0–1 rows; collision possible but
+  // negligible at 8-byte HMAC. Service role bypasses RLS.
+  const { data: candidates } = await admin
+    .from("recovery_codes")
+    .select("user_id, argon2_hash")
+    .eq("prefix_hmac", prefix);
 
-    if (fetchError) {
-      return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  let matchedUid: string | null = null;
+  for (const row of candidates ?? []) {
+    if (await verifyRecoveryCode(code, row.argon2_hash)) {
+      matchedUid = row.user_id;
+      break;
     }
+  }
 
-    let matchedUserId: string | null = null;
-    for (const user of users ?? []) {
-      if (!user.recovery_code) continue;
-      const valid = await verifyRecoveryCode(code, user.recovery_code);
-      if (valid) {
-        matchedUserId = user.id;
-        break;
-      }
-    }
+  if (!matchedUid) {
+    await padResponse(start);
+    return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 });
+  }
 
-    if (!matchedUserId) {
-      return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 });
-    }
+  // Atomic single-use commit point: DELETE ... RETURNING. If two requests
+  // race on the same code, exactly one DELETE returns a row.
+  const { data: deleted, error: deleteError } = await admin
+    .from("recovery_codes")
+    .delete()
+    .eq("user_id", matchedUid)
+    .select("user_id");
+  if (deleteError || !deleted || deleted.length === 0) {
+    await padResponse(start);
+    return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 });
+  }
 
-    await admin.from("users").update({ recovery_code: null }).eq("id", matchedUserId);
+  const session = await createSession(matchedUid);
+  const access = await mintAccessToken({
+    sub: matchedUid,
+    session_id: session.id,
+    iat_original: session.iat_original,
+  });
+  await setSessionCookie(access);
 
-    return NextResponse.json({ userId: matchedUserId });
-  } catch {
-    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
-  }
+  await padResponse(start);
+  return NextResponse.json({ ok: true });
 }

+ 54 - 49
src/app/api/auth/recovery/generate/route.ts

@@ -1,56 +1,61 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { NextResponse, type NextRequest } from "next/server";
 import { getSupabaseAdminClient } from "@/lib/supabase/admin";
 import { generateRecoveryCode, hashRecoveryCode } from "@/lib/auth/recovery";
+import { prefixHmacWire } from "@/lib/auth/recovery-prefix";
+import { checkRateLimit } from "@/lib/auth/rate-limit";
+import { getCurrentUser } from "@/lib/auth/current-user";
+
+const PER_UID_WINDOW_MS = 60 * 60 * 1000; // 1 hour
+const PER_UID_MAX = 3;
 
 export async function POST(request: NextRequest) {
-  try {
-    // Prefer the Authorization: Bearer header (sent explicitly by the client)
-    // because the @supabase/ssr cookie sync after signInAnonymously() can lag
-    // behind the immediate router.push("/recovery") navigation, leaving the
-    // server with no/stale auth cookies and a spurious 401. Falling back to
-    // the cookie-based server client preserves existing behavior for any
-    // caller that doesn't send the header.
-    const authHeader = request.headers.get("authorization");
-    const bearerToken = authHeader?.toLowerCase().startsWith("bearer ")
-      ? authHeader.slice(7).trim()
-      : null;
-
-    const admin = getSupabaseAdminClient();
-    let userId: string | null = null;
-
-    if (bearerToken) {
-      const { data, error } = await admin.auth.getUser(bearerToken);
-      if (!error && data.user) {
-        userId = data.user.id;
-      }
-    }
-
-    if (!userId) {
-      const supabase = await getSupabaseServerClient();
-      const {
-        data: { user },
-      } = await supabase.auth.getUser();
-      if (user) {
-        userId = user.id;
-      }
-    }
-
-    if (!userId) {
-      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-    }
-
-    const code = generateRecoveryCode();
-    const hashed = await hashRecoveryCode(code);
-
-    const { error } = await admin.from("users").update({ recovery_code: hashed }).eq("id", userId);
-
-    if (error) {
-      return NextResponse.json({ error: "Failed to store recovery code" }, { status: 500 });
-    }
-
-    return NextResponse.json({ code });
-  } catch {
+  const user = await getCurrentUser(request);
+  if (!user) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+  const uid = user.id;
+
+  const limit = checkRateLimit(`recovery-generate:${uid}`, {
+    windowMs: PER_UID_WINDOW_MS,
+    maxAttempts: PER_UID_MAX,
+  });
+  if (!limit.allowed) {
+    return NextResponse.json(
+      { error: "Too many attempts. Please try again later." },
+      {
+        status: 429,
+        headers: {
+          "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)),
+        },
+      },
+    );
+  }
+
+  const admin = getSupabaseAdminClient();
+
+  const { data: existing, error: existingError } = await admin
+    .from("recovery_codes")
+    .select("user_id")
+    .eq("user_id", uid)
+    .maybeSingle();
+
+  if (existingError) {
     return NextResponse.json({ error: "Internal server error" }, { status: 500 });
   }
+  if (existing) {
+    return NextResponse.json({ error: "Recovery code already exists" }, { status: 409 });
+  }
+
+  const code = generateRecoveryCode();
+  const argonHash = await hashRecoveryCode(code);
+  const prefix = prefixHmacWire(code);
+
+  const { error: insertError } = await admin
+    .from("recovery_codes")
+    .insert({ user_id: uid, argon2_hash: argonHash, prefix_hmac: prefix });
+  if (insertError) {
+    return NextResponse.json({ error: "Failed to store recovery code" }, { status: 500 });
+  }
+
+  return NextResponse.json({ code });
 }

+ 37 - 0
src/app/api/auth/touch/route.ts

@@ -0,0 +1,37 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { mintAccessToken } from "@/lib/auth/jwt";
+import { setSessionCookie } from "@/lib/auth/cookies";
+import { getCurrentUser } from "@/lib/auth/current-user";
+
+/**
+ * Re-mints the access token, preserving `iat_original` (so the 30-day
+ * absolute cap is enforced). `requireUser` (via `getCurrentUser`) already
+ * validates the cookie, signature, expiry, absolute cap, and `user_sessions`
+ * revocation — re-mint is just a fresh `exp`.
+ */
+export async function POST(request: NextRequest) {
+  // CSRF posture: reject cross-origin POSTs. Same-origin and tools that
+  // omit the Origin header (curl, server-to-server) are allowed; the cookie
+  // itself is SameSite=Lax which already blocks the cross-site case in
+  // browsers, and `getCurrentUser` requires a valid cookie regardless.
+  const origin = request.headers.get("origin");
+  if (origin) {
+    const expected = process.env.NEXT_PUBLIC_SITE_URL ?? null;
+    if (expected && origin !== expected) {
+      return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+    }
+  }
+
+  const user = await getCurrentUser();
+  if (!user) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const fresh = await mintAccessToken({
+    sub: user.id,
+    session_id: user.sessionId,
+    iat_original: user.iatOriginal,
+  });
+  await setSessionCookie(fresh);
+  return NextResponse.json({ ok: true });
+}

+ 47 - 0
src/components/auth/auth-bootstrap.tsx

@@ -0,0 +1,47 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { usePathname } from "next/navigation";
+import { useQueryClient } from "@tanstack/react-query";
+import { useCurrentUser } from "@/hooks/use-current-user";
+
+/**
+ * If the user lands inside the (app) layout without a valid session cookie,
+ * silently mint an anonymous one via `POST /api/auth/bootstrap`. This covers
+ * fresh visitors who skip the /login flow (e.g., deep-linked into /home).
+ *
+ * Skipped on `/recover` — that page is for unauthenticated callers pasting
+ * a recovery code, and an auto-bootstrap there would race the claim flow.
+ *
+ * Renders nothing.
+ */
+export function AuthBootstrap() {
+  const pathname = usePathname();
+  const queryClient = useQueryClient();
+  const { data, isPending } = useCurrentUser();
+  const inFlightRef = useRef(false);
+
+  useEffect(() => {
+    if (isPending) return;
+    if (data) return; // already have a session
+    if (pathname?.startsWith("/recover")) return;
+    if (inFlightRef.current) return;
+
+    inFlightRef.current = true;
+    void (async () => {
+      try {
+        const res = await fetch("/api/auth/bootstrap", {
+          method: "POST",
+          credentials: "same-origin",
+        });
+        if (res.ok) {
+          await queryClient.invalidateQueries({ queryKey: ["current-user"] });
+        }
+      } finally {
+        inFlightRef.current = false;
+      }
+    })();
+  }, [data, isPending, pathname, queryClient]);
+
+  return null;
+}

+ 25 - 14
src/components/auth/display-name-form.tsx

@@ -1,7 +1,7 @@
 "use client";
 
 import { useState } from "react";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
 import { useRouter } from "next/navigation";
 import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/constants";
 import { getSupabaseBrowserClient } from "@/lib/supabase/client";
@@ -18,28 +18,39 @@ function validateDisplayName(name: string): string | null {
 
 export function DisplayNameForm() {
   const router = useRouter();
+  const queryClient = useQueryClient();
   const [displayName, setDisplayName] = useState("");
   const [avatarColor, setAvatarColor] = useState<string | null>(null);
   const [validationError, setValidationError] = useState<string | null>(null);
 
   const signUpMutation = useMutation({
     mutationFn: async ({ name, color }: { name: string; color: string | null }) => {
-      const supabase = getSupabaseBrowserClient();
-
-      const { data: authData, error: authError } = await supabase.auth.signInAnonymously();
-      if (authError) throw authError;
-      if (!authData.user) throw new Error("Sign-in failed: no user returned");
-
-      const { error: insertError } = await supabase.from("users").insert({
-        id: authData.user.id,
-        display_name: name,
-        avatar_color: color,
+      // Stage 5: bootstrap mints the auth.users + public.users (placeholder
+      // display_name) + user_sessions row server-side and sets our cookie.
+      // Browser no longer calls signInAnonymously().
+      const bootstrapRes = await fetch("/api/auth/bootstrap", {
+        method: "POST",
+        credentials: "same-origin",
       });
-      if (insertError) throw insertError;
+      if (!bootstrapRes.ok) {
+        const body = (await bootstrapRes.json().catch(() => null)) as { error?: string } | null;
+        throw new Error(body?.error ?? "Failed to create account");
+      }
+      const { id } = (await bootstrapRes.json()) as { id: string; isAnonymous: boolean };
+
+      // Update the placeholder row with the user-chosen display_name + color.
+      // RLS allows users to update their own row (auth.uid() = id).
+      const supabase = getSupabaseBrowserClient();
+      const { error: updateError } = await supabase
+        .from("users")
+        .update({ display_name: name, avatar_color: color })
+        .eq("id", id);
+      if (updateError) throw updateError;
 
-      return { userId: authData.user.id };
+      return { userId: id };
     },
-    onSuccess: () => {
+    onSuccess: async () => {
+      await queryClient.invalidateQueries({ queryKey: ["current-user"] });
       router.push("/recovery");
     },
   });

+ 55 - 0
src/components/auth/session-keeper.tsx

@@ -0,0 +1,55 @@
+"use client";
+
+import { useEffect } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+
+const TOUCH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
+
+/**
+ * Mounts once at the (app) layout. Periodically POSTs `/api/auth/touch`
+ * to re-mint the access token before its 1h TTL expires. The route is
+ * cheap and idempotent; we don't bother probing the cookie's `expires_at`
+ * before firing.
+ *
+ * On 401: hard-clears the TanStack cache and navigates to `/`. This
+ * covers admin-revoked sessions and absolute-cap (30d) expiry.
+ *
+ * Renders nothing.
+ */
+export function SessionKeeper() {
+  const queryClient = useQueryClient();
+
+  useEffect(() => {
+    let cancelled = false;
+
+    async function touch() {
+      try {
+        const res = await fetch("/api/auth/touch", {
+          method: "POST",
+          credentials: "same-origin",
+        });
+        if (cancelled) return;
+        if (res.status === 401) {
+          queryClient.clear();
+          window.location.assign("/");
+        }
+      } catch {
+        // Network errors are transient; let the next interval retry.
+      }
+    }
+
+    // Fire once on mount (covers tabs that have been backgrounded for hours
+    // and just regained focus, where the cached token may have expired).
+    void touch();
+    const id = window.setInterval(() => {
+      void touch();
+    }, TOUCH_INTERVAL_MS);
+
+    return () => {
+      cancelled = true;
+      window.clearInterval(id);
+    };
+  }, [queryClient]);
+
+  return null;
+}

+ 6 - 0
src/env.ts

@@ -9,6 +9,9 @@ export const env = createEnv({
     MASTER_ADMIN_USERNAME: z.string().min(1, "MASTER_ADMIN_USERNAME is required"),
     MASTER_ADMIN_TOTP_SECRET: z.string().min(1, "MASTER_ADMIN_TOTP_SECRET is required"),
     IRON_SESSION_SECRET: z.string().min(32, "IRON_SESSION_SECRET must be at least 32 characters"),
+    JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
+    RECOVERY_CODE_PEPPER: z.string().min(32, "RECOVERY_CODE_PEPPER must be at least 32 characters"),
+    BOOTSTRAP_DAILY_CAP: z.coerce.number().int().positive().default(10000),
   },
   client: {
     NEXT_PUBLIC_SUPABASE_URL: z.string().url("NEXT_PUBLIC_SUPABASE_URL must be a valid URL"),
@@ -22,6 +25,9 @@ export const env = createEnv({
     MASTER_ADMIN_USERNAME: process.env.MASTER_ADMIN_USERNAME,
     MASTER_ADMIN_TOTP_SECRET: process.env.MASTER_ADMIN_TOTP_SECRET,
     IRON_SESSION_SECRET: process.env.IRON_SESSION_SECRET,
+    JWT_SECRET: process.env.JWT_SECRET,
+    RECOVERY_CODE_PEPPER: process.env.RECOVERY_CODE_PEPPER,
+    BOOTSTRAP_DAILY_CAP: process.env.BOOTSTRAP_DAILY_CAP,
     NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
     NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
     NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,

+ 39 - 0
src/hooks/use-current-user.ts

@@ -0,0 +1,39 @@
+"use client";
+
+import { useQuery, type UseQueryResult } from "@tanstack/react-query";
+
+export interface CurrentUserData {
+  id: string;
+  isAnonymous: boolean;
+}
+
+/**
+ * The ONLY browser-side path for resolving "who am I". Backed by
+ * `GET /api/auth/me`, which uses `requireUser()` (local JWT decode +
+ * `user_sessions` lookup). Do NOT call `supabase.auth.getUser()` /
+ * `getSession()` from any other client component or hook — see
+ * `src/lib/auth/current-user.ts` and the CI guard at
+ * `src/__tests__/guards/no-raw-getuser.test.ts`.
+ *
+ * On 401: `data === null`, no thrown error (treat as "not logged in").
+ * On other errors: surfaced via `error`.
+ */
+export function useCurrentUser(): UseQueryResult<CurrentUserData | null, Error> {
+  return useQuery<CurrentUserData | null, Error>({
+    queryKey: ["current-user"],
+    queryFn: async () => {
+      const res = await fetch("/api/auth/me", {
+        method: "GET",
+        credentials: "same-origin",
+      });
+      if (res.status === 401) return null;
+      if (!res.ok) {
+        const body = (await res.json().catch(() => null)) as { error?: string } | null;
+        throw new Error(body?.error ?? `Failed to load current user (${res.status})`);
+      }
+      return (await res.json()) as CurrentUserData;
+    },
+    staleTime: 5 * 60 * 1000,
+    refetchOnWindowFocus: false,
+  });
+}

+ 52 - 0
src/lib/auth/cookies.ts

@@ -0,0 +1,52 @@
+import { cookies as nextCookies } from "next/headers";
+import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
+import { ACCESS_TOKEN_TTL_SECONDS } from "@/lib/auth/jwt";
+
+// Matches @supabase/ssr's DEFAULT_COOKIE_OPTIONS maxAge for the auth cookie
+// (~400 days). Per-token expiry is enforced by JWT `exp`, not cookie lifetime.
+const COOKIE_MAX_AGE_S = 60 * 60 * 24 * 400;
+
+function base64url(input: string): string {
+  return Buffer.from(input, "utf8")
+    .toString("base64")
+    .replace(/\+/g, "-")
+    .replace(/\//g, "_")
+    .replace(/=+$/, "");
+}
+
+/**
+ * Writes the auth cookie in the exact shape `@supabase/ssr` produces under
+ * its default `cookieEncoding: "base64url"` mode:
+ *   `"base64-" + base64url(JSON.stringify(session))`
+ * with `session` matching the modern Supabase Session shape. See
+ * `research/COOKIE-SHAPE-DECISION.md` § 3 for empirical & source-read rationale.
+ *
+ * `httpOnly: false` is intentional: the browser `@supabase/ssr` client must
+ * read this cookie via `document.cookie` for PostgREST/Realtime auth.
+ */
+export async function setSessionCookie(accessToken: string, expiresAt?: number): Promise<void> {
+  const exp = expiresAt ?? Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS;
+  const session = {
+    access_token: accessToken,
+    token_type: "bearer" as const,
+    expires_in: Math.max(0, exp - Math.floor(Date.now() / 1000)),
+    expires_at: exp,
+    // Present-but-empty: passes `_isValidSession` (`'k' in obj` check) and
+    // short-circuits browser auto-refresh (which is also disabled via
+    // `autoRefreshToken: false` on the browser client).
+    refresh_token: "",
+    user: null,
+  };
+  const value = `base64-${base64url(JSON.stringify(session))}`;
+  (await nextCookies()).set(getSupabaseCookieName(), value, {
+    path: "/",
+    sameSite: "lax",
+    httpOnly: false,
+    secure: process.env.NODE_ENV === "production",
+    maxAge: COOKIE_MAX_AGE_S,
+  });
+}
+
+export async function clearSessionCookie(): Promise<void> {
+  (await nextCookies()).delete(getSupabaseCookieName());
+}

+ 108 - 0
src/lib/auth/current-user.ts

@@ -0,0 +1,108 @@
+import { cookies as nextCookies } from "next/headers";
+import { NextResponse, type NextRequest } from "next/server";
+import { getSupabaseCookieName } from "@/lib/supabase/cookie-name";
+import { ABSOLUTE_SESSION_CAP_SECONDS, verifyAccessToken, type AccessClaims } from "@/lib/auth/jwt";
+import { isSessionLive } from "@/lib/auth/sessions";
+
+// TODO(stage-4): wire `JWT_SECRET` and `RECOVERY_CODE_PEPPER` literal values
+// into Sentry `beforeSend` so any event whose stringified payload contains
+// either secret is dropped.
+
+export interface CurrentUser {
+  id: string;
+  sessionId: string;
+  iatOriginal: number;
+}
+
+/**
+ * The ONLY module in the codebase allowed to decode the auth JWT or query
+ * `user_sessions`. Stage 4 adds a CI grep-guard. See
+ * `research/PLAN-AUTH-A.md` § "Decisions settled" — Trust boundary.
+ *
+ * `req` is accepted but ignored; reads always come from `next/headers
+ * cookies()`. The signature exists so call sites already plumbing
+ * `NextRequest` don't need to special-case.
+ */
+export class UnauthorizedError extends Error {
+  readonly response: NextResponse;
+  constructor(reason = "Unauthorized") {
+    super(reason);
+    this.name = "UnauthorizedError";
+    this.response = NextResponse.json({ error: reason }, { status: 401 });
+  }
+}
+
+function decodeCookieValue(raw: string): string | null {
+  // @supabase/ssr default cookie shape: `"base64-" + base64url(JSON.stringify(session))`.
+  // We tolerate raw JSON too (in case a future flag flip lands).
+  let json: string;
+  if (raw.startsWith("base64-")) {
+    try {
+      const b64 = raw.slice("base64-".length).replace(/-/g, "+").replace(/_/g, "/");
+      const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
+      json = Buffer.from(b64 + pad, "base64").toString("utf8");
+    } catch {
+      return null;
+    }
+  } else {
+    json = raw;
+  }
+  try {
+    const session = JSON.parse(json);
+    if (typeof session?.access_token === "string") return session.access_token;
+    return null;
+  } catch {
+    return null;
+  }
+}
+
+async function readClaims(req?: NextRequest): Promise<AccessClaims | null> {
+  // Prefer `next/headers cookies()` (route handlers + server components).
+  // Fall back to `req.cookies` for middleware (Edge runtime, where
+  // `next/headers cookies()` throws). The `req` parameter is therefore
+  // optional even in route handlers — passing it is harmless.
+  let raw: string | undefined;
+  try {
+    const store = await nextCookies();
+    raw = store.get(getSupabaseCookieName())?.value;
+  } catch {
+    if (req) {
+      raw = req.cookies.get(getSupabaseCookieName())?.value;
+    }
+  }
+  if (!raw) return null;
+  const accessToken = decodeCookieValue(raw);
+  if (!accessToken) return null;
+  return await verifyAccessToken(accessToken);
+}
+
+/**
+ * Resolve the current user from the auth cookie. Throws an `UnauthorizedError`
+ * (carrying a 401 `NextResponse`) on any failure: missing cookie, malformed
+ * cookie, expired/invalid JWT, revoked session, or absolute-cap exceeded.
+ */
+export async function requireUser(req?: NextRequest): Promise<CurrentUser> {
+  const claims = await readClaims(req);
+  if (!claims) throw new UnauthorizedError();
+
+  if (Math.floor(Date.now() / 1000) - claims.iat_original > ABSOLUTE_SESSION_CAP_SECONDS) {
+    throw new UnauthorizedError("Session expired");
+  }
+  if (!(await isSessionLive(claims.session_id, claims.iat_original))) {
+    throw new UnauthorizedError("Session revoked");
+  }
+  return {
+    id: claims.sub,
+    sessionId: claims.session_id,
+    iatOriginal: claims.iat_original,
+  };
+}
+
+/** Non-throwing variant of `requireUser`. Returns null on any failure. */
+export async function getCurrentUser(req?: NextRequest): Promise<CurrentUser | null> {
+  try {
+    return await requireUser(req);
+  } catch {
+    return null;
+  }
+}

+ 61 - 0
src/lib/auth/jwt.ts

@@ -0,0 +1,61 @@
+import { SignJWT, jwtVerify } from "jose";
+
+const ACCESS_EXP_SECONDS = 60 * 60; // 1h
+const ABSOLUTE_CAP_SECONDS = 30 * 24 * 60 * 60; // 30d
+const NBF_SKEW_SECONDS = 10;
+
+export interface AccessClaims {
+  sub: string;
+  session_id: string;
+  iat_original: number; // unix seconds, set on first mint, preserved across touches
+}
+
+function getSecret(): Uint8Array {
+  const secret = process.env.JWT_SECRET;
+  if (!secret) throw new Error("JWT_SECRET not set");
+  return new TextEncoder().encode(secret);
+}
+
+export async function mintAccessToken(claims: AccessClaims): Promise<string> {
+  return await new SignJWT({
+    sub: claims.sub,
+    role: "authenticated",
+    is_anonymous: true,
+    session_id: claims.session_id,
+    iat_original: claims.iat_original,
+  })
+    .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
+    .setSubject(claims.sub)
+    .setIssuedAt()
+    .setNotBefore(`${-NBF_SKEW_SECONDS}s`)
+    .setExpirationTime(`${ACCESS_EXP_SECONDS}s`)
+    .setIssuer("moviedice")
+    .setAudience("authenticated")
+    .sign(getSecret());
+}
+
+export async function verifyAccessToken(token: string): Promise<AccessClaims | null> {
+  try {
+    const { payload } = await jwtVerify(token, getSecret(), {
+      issuer: "moviedice",
+      audience: "authenticated",
+    });
+    if (
+      typeof payload.sub !== "string" ||
+      typeof payload.session_id !== "string" ||
+      typeof payload.iat_original !== "number"
+    ) {
+      return null;
+    }
+    return {
+      sub: payload.sub,
+      session_id: payload.session_id,
+      iat_original: payload.iat_original,
+    };
+  } catch {
+    return null;
+  }
+}
+
+export const ACCESS_TOKEN_TTL_SECONDS = ACCESS_EXP_SECONDS;
+export const ABSOLUTE_SESSION_CAP_SECONDS = ABSOLUTE_CAP_SECONDS;

+ 10 - 5
src/lib/auth/rate-limit.ts

@@ -27,21 +27,26 @@ function ensureCleanupRunning() {
   }
 }
 
-export function checkRateLimit(key: string): {
+export function checkRateLimit(
+  key: string,
+  options?: { windowMs?: number; maxAttempts?: number },
+): {
   allowed: boolean;
   remaining: number;
   retryAfterMs: number;
 } {
   ensureCleanupRunning();
+  const windowMs = options?.windowMs ?? WINDOW_MS;
+  const maxAttempts = options?.maxAttempts ?? MAX_ATTEMPTS;
   const now = Date.now();
   const entry = store.get(key);
 
   if (!entry || now >= entry.resetAt) {
-    store.set(key, { count: 1, resetAt: now + WINDOW_MS });
-    return { allowed: true, remaining: MAX_ATTEMPTS - 1, retryAfterMs: 0 };
+    store.set(key, { count: 1, resetAt: now + windowMs });
+    return { allowed: true, remaining: maxAttempts - 1, retryAfterMs: 0 };
   }
 
-  if (entry.count >= MAX_ATTEMPTS) {
+  if (entry.count >= maxAttempts) {
     return {
       allowed: false,
       remaining: 0,
@@ -52,7 +57,7 @@ export function checkRateLimit(key: string): {
   entry.count += 1;
   return {
     allowed: true,
-    remaining: MAX_ATTEMPTS - entry.count,
+    remaining: maxAttempts - entry.count,
     retryAfterMs: 0,
   };
 }

+ 24 - 0
src/lib/auth/recovery-prefix.ts

@@ -0,0 +1,24 @@
+import { createHmac } from "node:crypto";
+
+/**
+ * HMAC-SHA256(pepper, code) truncated to first 8 bytes.
+ * Used as a prefix index on `recovery_codes.prefix_hmac` for O(1)
+ * candidate-narrowing before constant-time Argon2 verification.
+ *
+ * Pepper lives only in env (`RECOVERY_CODE_PEPPER`, 32+ chars). Never client-exposed.
+ */
+export function prefixHmac(code: string): Buffer {
+  const pepper = process.env.RECOVERY_CODE_PEPPER;
+  if (!pepper) throw new Error("RECOVERY_CODE_PEPPER not set");
+  return createHmac("sha256", pepper).update(code).digest().subarray(0, 8);
+}
+
+/**
+ * PostgREST wire format for `bytea` columns: `\x<hex>`. Use this at both
+ * INSERT and equality-lookup sites so the comparison matches byte-for-byte.
+ * (Passing a raw Buffer through `supabase-js` would JSON-stringify as
+ * `{type:"Buffer",data:[...]}` and fail server-side.)
+ */
+export function prefixHmacWire(code: string): string {
+  return `\\x${prefixHmac(code).toString("hex")}`;
+}

+ 48 - 0
src/lib/auth/sessions.ts

@@ -0,0 +1,48 @@
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { ABSOLUTE_SESSION_CAP_SECONDS } from "@/lib/auth/jwt";
+
+export async function createSession(userId: string): Promise<{ id: string; iat_original: number }> {
+  const admin = getSupabaseAdminClient();
+  const { data, error } = await admin
+    .from("user_sessions")
+    .insert({ user_id: userId })
+    .select("id, iat_original")
+    .single();
+  if (error || !data) throw new Error("Failed to create session");
+  return {
+    id: data.id,
+    iat_original: Math.floor(new Date(data.iat_original).getTime() / 1000),
+  };
+}
+
+/**
+ * Returns true iff the session row exists, is not revoked, and the
+ * absolute-cap (30 days from `iat_original`) has not elapsed.
+ *
+ * Side-effect (fire-and-forget): updates `last_seen_at`.
+ */
+export async function isSessionLive(sessionId: string, iatOriginal: number): Promise<boolean> {
+  const admin = getSupabaseAdminClient();
+  const { data } = await admin
+    .from("user_sessions")
+    .select("revoked_at")
+    .eq("id", sessionId)
+    .maybeSingle();
+  if (!data || data.revoked_at) return false;
+  if (Math.floor(Date.now() / 1000) - iatOriginal > ABSOLUTE_SESSION_CAP_SECONDS) {
+    return false;
+  }
+  void admin
+    .from("user_sessions")
+    .update({ last_seen_at: new Date().toISOString() })
+    .eq("id", sessionId);
+  return true;
+}
+
+export async function revokeSession(sessionId: string): Promise<void> {
+  const admin = getSupabaseAdminClient();
+  await admin
+    .from("user_sessions")
+    .update({ revoked_at: new Date().toISOString() })
+    .eq("id", sessionId);
+}

+ 12 - 0
src/lib/supabase/client.ts

@@ -14,6 +14,18 @@ export function getSupabaseBrowserClient(): SupabaseClient<Database> {
   client = createBrowserClient<Database>(
     process.env.NEXT_PUBLIC_SUPABASE_URL!,
     process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+    {
+      // We hand-mint our own access tokens via /api/auth/recovery/claim and
+      // /api/auth/touch. The browser client must not try to refresh, but it
+      // MAY persist the cookie-derived session for PostgREST/Realtime auth.
+      // Cookie encoding stays default (base64url) — see
+      // research/COOKIE-SHAPE-DECISION.md § 3 (Option X).
+      auth: {
+        autoRefreshToken: false,
+        persistSession: true,
+        detectSessionInUrl: false,
+      },
+    },
   ) as unknown as SupabaseClient<Database>;
 
   return client;

+ 3 - 32
src/middleware.ts

@@ -1,37 +1,8 @@
-import { createServerClient, type CookieOptions } from "@supabase/ssr";
 import { NextResponse, type NextRequest } from "next/server";
+import { getCurrentUser } from "@/lib/auth/current-user";
 
 export async function middleware(request: NextRequest) {
-  let response = NextResponse.next({ request });
-
-  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
-  const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
-
-  if (!supabaseUrl || !supabaseAnonKey) {
-    return response;
-  }
-
-  const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
-    cookies: {
-      getAll() {
-        return request.cookies.getAll();
-      },
-      setAll(
-        cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>,
-      ) {
-        cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
-        response = NextResponse.next({ request });
-        cookiesToSet.forEach(({ name, value, options }) =>
-          response.cookies.set(name, value, options),
-        );
-      },
-    },
-  });
-
-  const {
-    data: { user },
-  } = await supabase.auth.getUser();
-
+  const user = await getCurrentUser(request);
   const { pathname } = request.nextUrl;
 
   // Authenticated user on landing page -> redirect to home
@@ -48,7 +19,7 @@ export async function middleware(request: NextRequest) {
     return NextResponse.redirect(url);
   }
 
-  return response;
+  return NextResponse.next({ request });
 }
 
 export const config = {

+ 45 - 3
src/types/database.ts

@@ -8,7 +8,6 @@ export interface Database {
           id: string;
           display_name: string;
           avatar_color: string | null;
-          recovery_code: string | null;
           last_active_at: string;
           created_at: string;
         };
@@ -16,7 +15,6 @@ export interface Database {
           id: string;
           display_name: string;
           avatar_color?: string | null;
-          recovery_code?: string | null;
           last_active_at?: string;
           created_at?: string;
         };
@@ -24,12 +22,56 @@ export interface Database {
           id?: string;
           display_name?: string;
           avatar_color?: string | null;
-          recovery_code?: string | null;
           last_active_at?: string;
           created_at?: string;
         };
         Relationships: [];
       };
+      recovery_codes: {
+        Row: {
+          user_id: string;
+          argon2_hash: string;
+          prefix_hmac: string; // bytea — surfaced as hex/base64 string by PostgREST
+          created_at: string;
+        };
+        Insert: {
+          user_id: string;
+          argon2_hash: string;
+          prefix_hmac: Buffer | Uint8Array | string;
+          created_at?: string;
+        };
+        Update: {
+          user_id?: string;
+          argon2_hash?: string;
+          prefix_hmac?: Buffer | Uint8Array | string;
+          created_at?: string;
+        };
+        Relationships: [];
+      };
+      user_sessions: {
+        Row: {
+          id: string;
+          user_id: string;
+          iat_original: string;
+          last_seen_at: string;
+          revoked_at: string | null;
+        };
+        Insert: {
+          id?: string;
+          user_id: string;
+          iat_original?: string;
+          last_seen_at?: string;
+          revoked_at?: string | null;
+        };
+        Update: {
+          id?: string;
+          user_id?: string;
+          iat_original?: string;
+          last_seen_at?: string;
+          revoked_at?: string | null;
+        };
+        Relationships: [];
+      };
       groups: {
         Row: {
           id: string;

+ 41 - 0
supabase/migrations/00004_recovery_and_sessions.sql

@@ -0,0 +1,41 @@
+-- 00004_recovery_and_sessions.sql
+--
+-- Auth-A v3 rewrite: replace synthetic-identity-based recovery with a custom
+-- recovery_codes table (Argon2id hashed + HMAC-SHA256 prefix index for O(1)
+-- lookup) and a user_sessions table for server-side session tracking
+-- (revocation + 30d absolute cap).
+--
+-- See: research/PLAN-AUTH-A.md (v3) and research/COOKIE-SHAPE-DECISION.md.
+
+ALTER TABLE public.users DROP COLUMN IF EXISTS recovery_code;
+
+CREATE TABLE public.recovery_codes (
+  user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+  argon2_hash text NOT NULL,
+  prefix_hmac bytea NOT NULL,  -- HMAC-SHA256(pepper, code) truncated to first 8 bytes
+  created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_recovery_codes_prefix ON public.recovery_codes(prefix_hmac);
+
+ALTER TABLE public.recovery_codes ENABLE ROW LEVEL SECURITY;
+-- No policies; service role only.
+
+COMMENT ON TABLE public.recovery_codes IS
+  'Argon2id-hashed recovery codes with HMAC prefix index. Service role only. Single-use; deleted on claim atomically via DELETE ... RETURNING.';
+
+CREATE TABLE public.user_sessions (
+  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+  iat_original timestamptz NOT NULL DEFAULT now(),
+  last_seen_at timestamptz NOT NULL DEFAULT now(),
+  revoked_at timestamptz
+);
+
+CREATE INDEX idx_user_sessions_user_active ON public.user_sessions(user_id) WHERE revoked_at IS NULL;
+
+ALTER TABLE public.user_sessions ENABLE ROW LEVEL SECURITY;
+-- No policies; service role only.
+
+COMMENT ON TABLE public.user_sessions IS
+  'Server-side session records. JWT carries session_id; touch endpoint validates revoked_at IS NULL and now() - iat_original < 30d.';