Files
archy/.planning/phases/09-botfights-platform-upgrade/09-02-PLAN.md
T

17 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
09-botfights-platform-upgrade 02 execute 1
/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
true
BOT-01
truths artifacts key_links
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
path provides contains
/home/archipelago/Projects/botfight/server/src/routes/auth.ts JWT-gated GET /me route; POST /login reduced to a read-only deprecated lookup authRouter.get('/me'
path provides contains
/home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts Coverage for the JWT-gated identity route (missing / invalid / expired / blacklisted / valid) /api/auth/me
from to via pattern
/home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts /home/archipelago/Projects/botfight/server/src/routes/auth.ts auto-restore calls authFetch('/api/auth/me') with the stored Bearer JWT instead of POSTing a bare pubkey /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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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.

<threat_model>

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
</threat_model>
- `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.

<success_criteria>

  • 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. </success_criteria>
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`.