Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 1e5d0cd313
1931 changed files with 440577 additions and 0 deletions
@@ -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>