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