Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
---
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
## 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.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: GET /api/auth/me — identity from the JWT, never from a claimed pubkey</name>
|
||||
<files>/home/archipelago/Projects/botfight/server/src/routes/auth.ts, /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts</files>
|
||||
<read_first>
|
||||
- `/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.
|
||||
</read_first>
|
||||
<behavior>
|
||||
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.
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth-me.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Retire the bare-pubkey session path (client + server side effects)</name>
|
||||
<files>/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</files>
|
||||
<read_first>
|
||||
- `/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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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).
|
||||
</acceptance_criteria>
|
||||
<done>No client path and no unauthenticated request can create, upgrade or restore an identity from a bare pubkey; session establishment is signer-only.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
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`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user