--- phase: 09-botfights-platform-upgrade plan: 02 type: execute wave: 1 depends_on: [] files_modified: - /home/archipelago/Projects/botfight/server/src/routes/auth.ts - /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts - /home/archipelago/Projects/botfight/server/src/routes/auth.test.ts - /home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts - /home/archipelago/Projects/botfight/e2e/helpers/auth.ts autonomous: true requirements: [BOT-01] must_haves: truths: - "A signed-in user's session is restored from their JWT alone — the client never sends a bare pubkey to claim an identity (D-01/BOT-01)" - "GET /api/auth/me returns the caller's own bot only when a valid, unexpired, non-blacklisted JWT is presented; anything else is 401" - "POST /api/auth/login can no longer mutate the database — the creator auto-create/auto-upgrade side effects only run behind NIP-98 verification in POST /api/auth/nostr/session" - "The NIP-07 / NIP-55 signer flow (POST /api/auth/nostr/session with a kind-27235 event, JWT returned) remains the only way to obtain a session" artifacts: - path: /home/archipelago/Projects/botfight/server/src/routes/auth.ts provides: "JWT-gated GET /me route; POST /login reduced to a read-only deprecated lookup" contains: "authRouter.get('/me'" - path: /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts provides: "Coverage for the JWT-gated identity route (missing / invalid / expired / blacklisted / valid)" contains: "/api/auth/me" key_links: - from: /home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts to: /home/archipelago/Projects/botfight/server/src/routes/auth.ts via: "auto-restore calls authFetch('/api/auth/me') with the stored Bearer JWT instead of POSTing a bare pubkey" pattern: "/api/auth/me" --- Finish D-01/BOT-01: the app already ships a complete NIP-07 + NIP-98 + NIP-55 signer login with JWT sessions (commit `3ba05a6` and follow-ups on `main`) — what remains is closing the last bare-pubkey trust path and giving the client a JWT-only way to restore its session. Decision IDs map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 native nostr signer login, D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared arena, D-04 = BOT-04 registry/catalog. Purpose: today `useNostr.ts` restores a session by POSTing `{pubkey}` to `/api/auth/login`, and that endpoint will happily auto-create and auto-upgrade the creator's bot row for whoever asks — an unauthenticated request causing a database mutation is exactly the "trust the pubkey" model D-01 says must go. The identical creator logic already exists, correctly gated, inside `POST /api/auth/nostr/session`. Output: a JWT-gated `GET /api/auth/me`, its test file, a read-only deprecated `POST /login`, and a client that restores sessions from the token it already holds. **Repo: `/home/archipelago/Projects/botfight`.** Commit target: `git push origin main`. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md @.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md @.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md @.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md ## Artifacts this plan produces | Symbol | Kind | File | |---|---|---| | `authRouter.get('/me')` | JWT-gated Hono route | `server/src/routes/auth.ts` | | `auth-me.test.ts` | Vitest suite | `server/src/routes/auth-me.test.ts` | | auto-restore via `GET /api/auth/me` | client change | `frontend/src/composables/useNostr.ts` | The response shape of `GET /me` is deliberately identical to the existing `POST /login` 200 body (`{ exists: true, bot: {...} }` / `{ exists: false }`) so `normalizeBotData` on the client is unchanged. Task 1: GET /api/auth/me — identity from the JWT, never from a claimed pubkey /home/archipelago/Projects/botfight/server/src/routes/auth.ts, /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts - `/home/archipelago/Projects/botfight/server/src/routes/auth.ts` lines 1-140 — the imports block, `POST /login`'s exact `db.select({...})` projection (lines 36-52) and its 200 response body (lines 113-135). `GET /me` reuses both verbatim. - `/home/archipelago/Projects/botfight/server/src/routes/auth.ts` lines 375-505 — `POST /nostr/session`: it already performs the creator auto-upgrade and auto-create AFTER `verifyNip98Token` succeeds, which is why the duplicates in `/login` are removable in Task 2. - `/home/archipelago/Projects/botfight/server/src/middleware/jwt.ts` — `createJwt`, `verifyJwt`, `blacklistJwt`, and `extractPubkeyFromAuth` (lines ~93-98), the helper the new route uses. Note the module throws at import time when `JWT_SECRET` is unset and `NODE_ENV=production` — tests must not set `NODE_ENV=production` without also setting `JWT_SECRET`. - `/home/archipelago/Projects/botfight/server/src/routes/auth.test.ts` lines 1-90 — harness convention to copy for the new test file. - `/home/archipelago/Projects/botfight/server/src/middleware/jwt.test.ts` — how a token is minted and blacklisted in tests. Write `server/src/routes/auth-me.test.ts` first and confirm it fails before adding the route. Mount `authRouter` on a bare `new Hono()` at `/api/auth`, mint tokens with `createJwt`. - no `Authorization` header → 401 with a JSON `error` key. - malformed / garbage Bearer value → 401. - a token whose signature does not verify (tamper one character of the signature segment) → 401. - a token passed to `blacklistJwt` before the call → 401. - a valid token for a pubkey with no bot row → 200 `{ exists: false }`. - a valid token for a pubkey that owns a bot row → 200, `exists: true`, and `bot.id`/`bot.name` matching the seeded row, with the same key set the `POST /login` 200 body returns. Seed rows through the same `db`/`schema` import the other route tests use rather than mocking drizzle, matching the in-repo convention. Add to `server/src/routes/auth.ts`: `import { extractPubkeyFromAuth } from '../middleware/jwt.js'` to the existing import block, then `authRouter.get('/me', async (c) => { ... })` which: - resolves the caller's pubkey with `extractPubkeyFromAuth(c.req.header('Authorization'))` and returns `c.json({ error: 'Authentication required.' }, 401)` when it is null — that single helper already covers the missing-header, bad-format, bad-signature, expired and blacklisted cases because it delegates to `verifyJwt`; - selects from `schema.bots` with the exact same projection as `POST /login` (lines 36-52), filtered by `eq(schema.bots.publicKey, pubkey)`, limit 1; - returns `c.json({ exists: false })` when there is no row, else the same `{ exists: true, bot: {...} }` object `POST /login` builds (including the derived `isHuman` boolean, parsed `customization`, and `hasWallet: false`); - performs NO writes of any kind. This route is a read of the caller's own identity. Do not add a per-route `rateLimit` — the global `/api/*` limiter in `app.ts` (line 71) already covers it, and a session-restore call on every page load must not compete with a tight budget. Place the handler next to the other read routes near the top of the file (after `GET /check-name/:name`) so the router reads read-then-write like the rest of the codebase. cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth-me.test.ts - `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth-me.test.ts` exits 0 with at least 6 passing tests. - `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` exits 0. - `grep -Eq "authRouter\.get\(['\"]/me['\"]" /home/archipelago/Projects/botfight/server/src/routes/auth.ts` succeeds. - `grep -q 'extractPubkeyFromAuth' /home/archipelago/Projects/botfight/server/src/routes/auth.ts` succeeds. - `grep -c '/api/auth/me' /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts` is at least 6. - The SUMMARY records the captured pre-implementation failure output of the new test file. A caller can retrieve their own bot only by presenting a valid JWT, proven by tests covering the missing, malformed, forged, blacklisted, unregistered and valid cases. Task 2: Retire the bare-pubkey session path (client + server side effects) /home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts, /home/archipelago/Projects/botfight/server/src/routes/auth.ts, /home/archipelago/Projects/botfight/e2e/helpers/auth.ts - `/home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts` lines 105-140 — the auto-restore block guarded by `!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()`, and the sibling `else if` branch that clears a stale pre-JWT pubkey. Only the fetch inside the first branch changes. - `/home/archipelago/Projects/botfight/frontend/src/lib/nostr-auth.ts` lines 99-110 — `authFetch` already attaches the Bearer token and clears it on 401; no change is needed there. - `/home/archipelago/Projects/botfight/server/src/routes/auth.ts` lines 29-137 — `POST /login`, specifically the creator auto-create branch (`rows.length === 0 && isCreatorPubkey(pubkey)`) and the creator auto-upgrade block (`db.update(...)` around lines 100-110). Both are duplicated inside `POST /nostr/session` behind NIP-98 verification. - `/home/archipelago/Projects/botfight/server/src/routes/auth.test.ts` lines 30-200 — the existing `/login` cases that must keep passing (adjust only assertions that depended on the removed mutations). - `/home/archipelago/Projects/botfight/e2e/helpers/auth.ts` — `loginWithPubkey` calls the legacy endpoint; it stays working as a read-only lookup and its doc comment must say so. Client (`useNostr.ts`): replace the auto-restore request with `authFetch('/api/auth/me')` — a GET with no headers argument and no body — keeping the exact same `.then(r => r.json()).then(data => { if (data.exists) { bot.value = normalizeBotData(data.bot); store('bf_bot', bot.value) } })` continuation and the existing `.catch` warning. Leave the guard condition, the `autoRestoreRan` flags and the `else if` stale-pubkey branch untouched. Search the whole `frontend/src` tree for any other request that sends a pubkey in a request body to claim an identity and convert or remove it; the signer flow (`buildNip98Token` → `POST /api/auth/nostr/session`) is the only sanctioned way to establish a session. Server (`auth.ts`, `POST /login`): reduce it to a pure read. - Delete the creator auto-create branch and the creator auto-upgrade `db.update` block from this handler. The equivalents in `POST /nostr/session` already run after `verifyNip98Token` and are the retained implementations — a creator who signs in with a real signer still gets the same row created/upgraded. - Keep the lookup, the zod `loginSchema` validation, the `rateLimit(60_000, 10)` and the response shape so leaderboard-style lookups and `e2e/helpers/auth.ts` keep working. - Add a handler doc comment recording that this endpoint is a deprecated read-only lookup kept for compatibility, that it establishes no session and issues no token, and that session establishment lives in `POST /nostr/session` (D-01). `e2e/helpers/auth.ts`: update the file/function doc comments so they describe `loginWithPubkey` as a read-only lookup helper used by tests, not a login. Do not change its request or signature. Update `server/src/routes/auth.test.ts` only where a case asserted a mutation that has moved (e.g. a creator row being created by `/login`); re-point such an assertion at `POST /nostr/session` or drop it, and add one case asserting that a `/login` call for an unregistered creator pubkey now returns `exists: false` and leaves the table row count unchanged. cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth.test.ts server/src/routes/auth-edge.test.ts server/src/routes/auth-audit.test.ts server/src/routes/auth-me.test.ts - `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth.test.ts server/src/routes/auth-edge.test.ts server/src/routes/auth-audit.test.ts server/src/routes/auth-me.test.ts` exits 0. - `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` exits 0. - `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` exits 0 and `pnpm exec vue-tsc --noEmit -p frontend/tsconfig.json` exits 0. - `grep -q "authFetch('/api/auth/me')" /home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts` succeeds. - `test -z "$(grep -rl 'auth/login' /home/archipelago/Projects/botfight/frontend/src)"` succeeds (no client code targets the legacy endpoint any more). - `awk "/authRouter.post\\('\\/login'/,/^authRouter.post\\('\\/register'/" /home/archipelago/Projects/botfight/server/src/routes/auth.ts | grep -c 'db.insert\|db.update'` equals 0 (the login handler performs no writes). - `grep -c 'db.insert\|db.update' /home/archipelago/Projects/botfight/server/src/routes/auth.ts` is at least 2 (the creator paths still exist elsewhere in the file, i.e. they were moved-from-login, not deleted wholesale). No client path and no unauthenticated request can create, upgrade or restore an identity from a bare pubkey; session establishment is signer-only. ## Trust Boundaries | Boundary | Description | |----------|-------------| | browser signer (NIP-07 extension / Amber) → app | The private key stays in the signer; only signed events cross | | unauthenticated HTTP client → `/api/auth/*` | Anyone on the network can call these routes | | JWT bearer → bot-owning identity | The token is the sole proof of "this pubkey is me" after login | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-09-07 | Spoofing | identity claimed by posting someone else's pubkey | high | mitigate | `GET /me` derives the pubkey from a verified JWT only (Task 1); the client stops sending bare pubkeys (Task 2) | | T-09-08 | Tampering | unauthenticated request mutating the creator's bot row via `POST /login` | high | mitigate | Creator auto-create/auto-upgrade removed from `/login`; the NIP-98-gated `/nostr/session` copies remain (Task 2), asserted by the row-count test | | T-09-09 | Elevation of privilege | forged or replayed JWT | high | mitigate | Unchanged, already-tested `verifyJwt` (HMAC-SHA256 + `timingSafeEqual` + blacklist); `GET /me` adds no new verification path, and blacklisted-token rejection is covered by a new test | | T-09-10 | Information disclosure | `POST /login` remaining an anonymous profile lookup | low | accept | It returns the same fields the public leaderboard already exposes (name, elo, W/L, tier); it is documented as deprecated and issues no token | | T-09-11 | Spoofing | NIP-98 event replay inside the 120s freshness window | medium | accept | Pre-existing, out of this phase's scope (no jti/nonce store); mitigated in practice by HTTPS-only transport to the arena. Named explicitly rather than left silent | - `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` — green. - `cd /home/archipelago/Projects/botfight && pnpm exec vue-tsc --noEmit -p frontend/tsconfig.json` — exits 0. - Maps to `09-VALIDATION.md` rows "BOT-01 | session theft | JWT issue/verify/blacklist" and "BOT-01 | legacy bypass | no bare-pubkey login path remains"; the new `auth-me.test.ts` closes the Wave 0 gap listed for `server/src/routes/auth-me.test.ts`. - Real-signer verification (NIP-07 extension, Amber NIP-55) is deliberately NOT claimed here — it is a human checkpoint in plan 09-07, per `09-RESEARCH.md` Pitfall 5. - `GET /api/auth/me` exists, is JWT-only, is read-only, and is covered by tests. - The client restores sessions with its JWT and never posts a bare pubkey. - `POST /api/auth/login` performs no database writes and is documented as deprecated. - The whole existing server suite still passes. Create `.planning/phases/09-botfights-platform-upgrade/09-02-SUMMARY.md` when done. Commit the botfight changes with `git add` by explicit path and `git push origin main`. Commit the SUMMARY in archy and `git push gitea-ai main`.