diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 04da354b..f9bfefb5 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -216,8 +216,14 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
**Goal:** BotFights (app + registry) works great on every node: users sign in with the native nostr signer, a single self-contained AI prompt sets up their bot (replacing the confusing docs page), and every node's instance talks to a shared public match endpoint on VPS2 so all fighters are visible and battle across all nodes.
**Requirements**: BOT-01 native nostr signer login; BOT-02 unified AI bot-setup prompt (one copy-paste prompt, no doc-hopping); BOT-03 public shared match/fighter endpoint hosted on VPS2, node instances federate to it by default; BOT-04 registry/manifest + signed catalog updated and republished for the new version
**Depends on:** Nothing (independent app work; parallelizable with Phases 1–8)
-**Plans:** 0 plans
+**Plans:** 7 plans
Plans:
-- [ ] TBD (run /gsd-plan-phase 9 to break down)
+- [ ] 09-01-PLAN.md — Arena reverse-proxy tracer: node instances become thin clients of one shared arena (BOT-03)
+- [ ] 09-02-PLAN.md — Finish native nostr signer login: JWT-only GET /api/auth/me, bare-pubkey path retired (BOT-01)
+- [ ] 09-03-PLAN.md — One self-contained AI bot-setup prompt served at /api/docs/prompt (BOT-02)
+- [ ] 09-04-PLAN.md — Canonical public arena on VPS2 + DNS/TLS via nginx-proxy-manager (BOT-03)
+- [ ] 09-05-PLAN.md — Build+push botfights:1.2.0, roll the arena, prove cross-instance visibility (BOT-03/BOT-04)
+- [ ] 09-06-PLAN.md — Manifest 1.2.0 with generated JWT secret + signed catalog republished (BOT-04)
+- [ ] 09-07-PLAN.md — archi-dev-box deploy + demo rehearsal: real signer login, cloud bot from the prompt (BOT-01/02/03/04)
diff --git a/.planning/phases/09-botfights-platform-upgrade/09-01-PLAN.md b/.planning/phases/09-botfights-platform-upgrade/09-01-PLAN.md
new file mode 100644
index 00000000..71b12d4e
--- /dev/null
+++ b/.planning/phases/09-botfights-platform-upgrade/09-01-PLAN.md
@@ -0,0 +1,296 @@
+---
+phase: 09-botfights-platform-upgrade
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts
+ - /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts
+ - /home/archipelago/Projects/botfight/server/src/app.ts
+ - /home/archipelago/Projects/botfight/server/src/routes/fights.ts
+ - /home/archipelago/Projects/botfight/docker-compose.yml
+autonomous: true
+requirements: [BOT-03]
+
+must_haves:
+ truths:
+ - "With ARENA_UPSTREAM_URL set, a POST to a node instance's /api/bots is served by the canonical arena, and the same bot is then returned by that node instance's GET /api/bots — this is what makes 'all nodes see all fighters' true (D-03/BOT-03)"
+ - "With ARENA_UPSTREAM_URL unset the server behaves exactly as it does today: every /api/* route is served from the local SQLite DB, no network hop"
+ - "GET /api/health is answered locally even in proxy mode, so the app manifest's health check never depends on VPS2 being reachable"
+ - "A proxied SSE fight stream delivers events incrementally while the fight runs, not buffered until the upstream stream closes"
+ - "The originating client IP reaches the canonical arena in X-Forwarded-For, so the arena's per-IP rate limiting is not collapsed into one bucket per node"
+ - "When the canonical arena is unreachable, the node instance answers 502 with a JSON error body instead of a 500 stack trace or a hung request"
+ artifacts:
+ - path: /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts
+ provides: "Hono middleware that forwards /api/* to the canonical arena when ARENA_UPSTREAM_URL is set"
+ contains: "ARENA_UPSTREAM_URL"
+ - path: /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts
+ provides: "End-to-end proxy coverage against a real upstream HTTP server (REST, SSE, health bypass, failure)"
+ contains: "ARENA_UPSTREAM_URL"
+ key_links:
+ - from: /home/archipelago/Projects/botfight/server/src/app.ts
+ to: /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts
+ via: "app.use('/api/*', arenaProxy) mounted before every app.route('/api/...') registration so proxy mode short-circuits the local routers"
+ pattern: "arenaProxy"
+---
+
+
+Build the BOT-03 architecture in one thin, production-quality vertical slice: a Hono middleware in
+the botfights server that, when `ARENA_UPSTREAM_URL` is set, forwards every `/api/*` request to the
+canonical public arena and streams the response straight back — so a node's own instance stops being
+an isolated island and becomes a thin client of one shared arena.
+
+Decision IDs used in this plan map to `09-CONTEXT.md` **Locked Decisions**:
+D-01 = BOT-01 native nostr signer login, D-02 = BOT-02 unified AI bot-setup prompt,
+D-03 = BOT-03 shared public match endpoint on VPS2, D-04 = BOT-04 registry/manifest + signed catalog.
+
+Purpose: D-03 is the only genuinely new architecture in this phase and the one that can dead-end the
+whole demo (SSE buffering, Host-header routing, gzip double-decode, health-check coupling). Proving
+it end-to-end against a real HTTP upstream before VPS2, the image build, or the catalog publish exist
+means a wrong turn costs one commit instead of ten.
+Output: `server/src/middleware/arena-proxy.ts`, its end-to-end test, the mount in `app.ts`, an
+SSE-through-nginx fix in `routes/fights.ts`, and the env var documented in `docker-compose.yml`.
+
+**Repo: `/home/archipelago/Projects/botfight`** (NOT archy). Commit target: `git push origin main`
+(= `source.archipelago-foundation.org/lfg2025/botfights`). The plan/SUMMARY files live in archy and
+are pushed with `git push gitea-ai 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 |
+|---|---|---|
+| `arenaProxy` | exported async Hono middleware `(c, next)` | `server/src/middleware/arena-proxy.ts` |
+| `HOP_BY_HOP` | module-private `Set` of headers never forwarded | same |
+| `arena-proxy.test.ts` | Vitest suite driving a real upstream via `@hono/node-server` | `server/src/middleware/arena-proxy.test.ts` |
+| `app.use('/api/*', arenaProxy)` | mount point | `server/src/app.ts` |
+
+
+
+
+ Task 1: End-to-end — one /api/* request served by the canonical arena through a node instance
+ New middleware that no-ops unless `ARENA_UPSTREAM_URL` is set;
+ reverting is deleting one file and one `app.use` line, with no schema or on-disk change.
+ /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts, /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts, /home/archipelago/Projects/botfight/server/src/app.ts
+
+ - `/home/archipelago/Projects/botfight/server/src/app.ts` lines 28-108 — the exact middleware
+ order (`logger` → `cors` → COOP/COEP → `secureHeaders` → `bodyLimit` → global `rateLimit` →
+ cache-header middleware → `app.get('/api/health')` → the eleven `app.route('/api/...')` calls).
+ The new mount goes after the cache-header middleware and before `app.get('/api/health')`.
+ - `/home/archipelago/Projects/botfight/server/src/middleware/rate-limit.ts` — the in-repo Hono
+ middleware shape (`return async (c, next) => {...}`) and `getIp()`'s `TRUSTED_PROXY` handling,
+ which is what the forwarded client IP feeds on the arena side.
+ - `/home/archipelago/Projects/botfight/server/src/routes/auth.test.ts` lines 1-90 — the Vitest
+ harness convention: build a bare `new Hono()`, mount, drive with `app.request(path, init)`,
+ set/restore `process.env` in `beforeEach`/`afterEach`.
+ - `.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md` section
+ "`server/src/middleware/arena-proxy.ts` (NEW middleware)" — mount-order and core pattern.
+ - `.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md` Pattern 2 + Pitfall 2.
+
+
+ Write these tests first in `arena-proxy.test.ts` and watch them fail before implementing.
+ The upstream is a REAL second server: build a `new Hono()` with an in-memory bot store exposing
+ `POST /api/bots` (echoes `{id, secret, name}` and records it) and `GET /api/bots` (returns the
+ recorded list), start it with `serve({ fetch: upstream.fetch, port: 0 })` from
+ `@hono/node-server`, read the assigned port from the returned server's `address()`, and point
+ `process.env.ARENA_UPSTREAM_URL` at `http://127.0.0.1:`. Close the server in `afterAll`.
+
+ - `registers a bot upstream and reads it back through the proxy`: through the proxying app,
+ `POST /api/bots` with a JSON body, assert 200 and the echoed name; then `GET /api/bots`
+ through the proxying app and assert the bot registered a moment ago is in the list. This is
+ the phase's cross-node-visibility claim reduced to its smallest honest form.
+ - `falls through to local routers when ARENA_UPSTREAM_URL is unset`: delete the env var, mount
+ `arenaProxy` plus a sentinel local route, assert the sentinel answers (proving `next()` ran).
+ - `answers /api/health locally even in proxy mode`: point the env var at a port with nothing
+ listening, assert `GET /api/health` still returns 200 from the local handler.
+ - `forwards method, query string and JSON body unchanged`: upstream echoes back
+ `{method, path, query, body}`; assert each field round-trips for a `POST /api/x?a=1&b=2`.
+ - `does not forward the inbound Host header`: upstream echoes the `host` header it received;
+ assert it equals the upstream's own `127.0.0.1:` authority, not the caller's.
+ - `strips response content-encoding and content-length`: upstream replies with those headers set;
+ assert neither is present on the response the proxy returns (Node's fetch already decoded the
+ body, so copying them corrupts the response for the browser).
+
+
+ Create `server/src/middleware/arena-proxy.ts` exporting `export async function arenaProxy(c: Context, next: Next)`.
+
+ Read `process.env.ARENA_UPSTREAM_URL` INSIDE the handler on every call, not at module scope — a
+ module-level constant is captured at import time and cannot be toggled by the tests or by a
+ container restart-free config change.
+
+ Behavior, in order:
+ 1. If the env var is empty/unset, `return next()` — standalone mode, today's code path untouched.
+ 2. If `c.req.path` is exactly `/api/health`, `return next()` — the manifest health check
+ (`apps/botfights/manifest.yml` `health_check.path`) must stay answerable while the arena is
+ unreachable, otherwise podman marks a perfectly healthy node container unhealthy and restarts
+ it in a loop. Keep this bypass list in one named module constant so it is greppable.
+ 3. Build the target URL from the upstream base plus `c.req.path` plus the original query string
+ taken from `new URL(c.req.url).search` (do not re-encode via URLSearchParams round-trip —
+ that reorders and re-escapes repeated keys).
+ 4. Copy the inbound headers into a fresh `Headers`, dropping: `host`, `connection`,
+ `keep-alive`, `transfer-encoding`, `upgrade`, `proxy-authorization`, `proxy-connection`,
+ `te`, `trailer`, and `content-length` (undici recomputes it). Define that drop list as a
+ module-level `HOP_BY_HOP` set. Then set `accept-encoding` to `identity` on the forwarded
+ request so the upstream returns an uncompressed body and no encoding bookkeeping is needed.
+ 5. `fetch(target, { method, headers, body, redirect: 'manual', duplex: 'half' })` where `body`
+ is `undefined` for GET/HEAD and `c.req.raw.body` otherwise. The `duplex` option is required
+ by Node 22's undici whenever a streaming body is sent and needs a `@ts-expect-error` since it
+ is missing from the DOM `RequestInit` type.
+ 6. Return `new Response(upstreamRes.body, { status, headers })` where `headers` is a copy of the
+ upstream response headers with `content-encoding`, `content-length` and the same hop-by-hop
+ set removed. Pass the body through as the stream it is — reading it into a string or object
+ first breaks the SSE fight stream (Task 2) and is the single most likely way to get this
+ wrong.
+
+ Mount in `app.ts`: `import { arenaProxy } from './middleware/arena-proxy.js'` and
+ `app.use('/api/*', arenaProxy)` placed immediately after the `/api/docs/*` cache-header
+ middleware (currently around line 93) and before `app.get('/api/health', ...)`. Do not move,
+ reorder or delete any existing middleware.
+
+ Keep the error-response convention of this codebase: `c.json({ error: '...' }, status)` returns,
+ never thrown exceptions inside the handler (upstream-failure handling is Task 2).
+
+
+ cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts
+
+
+ - `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.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 -c 'ARENA_UPSTREAM_URL' /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts` is at least 1.
+ - `grep -Eq "app.use\('/api/\*', arenaProxy\)" /home/archipelago/Projects/botfight/server/src/app.ts` succeeds.
+ - `awk '/app.use\(.\/api\/\*., arenaProxy\)/{p=NR} /app.route\(.\/api\/auth./{r=NR} END{exit !(p>0 && p
+ A request that enters a node instance's /api/* is provably served by a different, real HTTP arena process and returned unmodified, while standalone mode and /api/health are untouched.
+
+
+
+ Task 2: Make the proxied path survive real conditions — SSE streaming, client IP, upstream down
+ /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts, /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts, /home/archipelago/Projects/botfight/server/src/routes/fights.ts, /home/archipelago/Projects/botfight/docker-compose.yml
+
+ - `/home/archipelago/Projects/botfight/server/src/routes/fights.ts` around line 410-480 — the
+ `GET /:id/stream` handler, its `MAX_SSE_PER_IP` guard and the `streamSSE(c, async (stream) => ...)`
+ body. The header additions go at the top of that handler, before `streamSSE` is called.
+ - `/home/archipelago/Projects/botfight/server/src/middleware/rate-limit.ts` lines 36-52 —
+ `getIp()` reads `cf-connecting-ip`, then `x-real-ip`, then the first entry of
+ `x-forwarded-for`, but ONLY when `TRUSTED_PROXY` is set. The canonical arena runs with
+ `TRUSTED_PROXY=1` (plan 09-04), so the first XFF entry this proxy writes is the identity the
+ arena rate-limits on.
+ - `/home/archipelago/Projects/botfight/docker-compose.yml` — the commented env-var block style to
+ follow when documenting the new vars.
+
+
+ Extend `arena-proxy.ts`:
+
+ - Client IP forwarding: before fetching, append the caller's address to `x-forwarded-for`
+ (comma-joined with any inbound value, caller first if none exists) and set `x-real-ip` when
+ absent. Take the caller address from the Node socket the same way `rate-limit.ts` does —
+ `(c.env as Record)?.incoming?.socket?.remoteAddress` — and skip both headers when
+ it cannot be determined rather than inventing a value. Without this every request from one
+ node arrives at the arena wearing that node's single public IP and the arena's global
+ `rateLimit(60_000, 300)` throttles that node's entire user base as one client.
+ - Upstream failure: wrap the fetch in try/catch and, on any network error or timeout, log via
+ the existing `logger` module and return `c.json({ error: 'Arena unreachable.' }, 502)`. Apply
+ an `AbortSignal.timeout(...)` of 30s for non-streaming requests; do NOT apply a timeout to the
+ SSE stream path (`/api/fights/` + `/stream`), which is long-lived by design.
+
+ In `routes/fights.ts`'s stream handler, set `c.header('X-Accel-Buffering', 'no')` (and keep the
+ existing headers) before handing off to `streamSSE`. nginx-proxy-manager fronts the canonical
+ arena with the stock `proxy.conf`, which does not disable proxy buffering; that header is the
+ documented nginx opt-out and is what makes live fight events arrive during the fight instead of
+ all at once when it ends. Harmless when no nginx is in the path.
+
+ In `docker-compose.yml`, document (commented, alongside the existing commented env block) the
+ two new vars and their split: `ARENA_UPSTREAM_URL` — set on a NODE instance to make it a thin
+ client of the canonical arena, left unset on the canonical arena itself; `TRUSTED_PROXY` — set
+ to 1 only on the canonical arena, which sits behind nginx-proxy-manager. Do not set either as an
+ active value in this file; the canonical arena gets its own compose file in plan 09-04.
+
+ Add tests to `arena-proxy.test.ts`:
+ - `streams SSE incrementally through the proxy`: the upstream test app writes three SSE frames
+ with a delay between them; read the proxied response body with a `ReadableStream` reader and
+ assert the first frame arrives before the upstream has written the last one (assert on
+ elapsed-time ordering, not on the total payload). A buffering proxy fails this.
+ - `forwards the client address in x-forwarded-for`: upstream echoes the header; assert it is
+ present and non-empty when the socket address is available.
+ - `answers 502 when the arena is unreachable`: point the env var at a closed port, assert status
+ 502 and a JSON body with an `error` key.
+
+
+ cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts server/src/middleware/rate-limit.test.ts
+
+
+ - `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts server/src/middleware/rate-limit.test.ts` exits 0 with at least 9 passing tests total.
+ - `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` exits 0 (no regression in the existing server suite).
+ - `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` exits 0.
+ - `grep -q 'x-forwarded-for' /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts` succeeds.
+ - `grep -q '502' /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts` succeeds.
+ - `grep -q 'X-Accel-Buffering' /home/archipelago/Projects/botfight/server/src/routes/fights.ts` succeeds.
+ - `grep -c 'ARENA_UPSTREAM_URL' /home/archipelago/Projects/botfight/docker-compose.yml` is at least 1.
+
+ Proxy mode survives the three conditions that would break the demo: a live fight stream, per-IP rate limiting at the arena, and a temporarily unreachable arena.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| browser / bot script → node botfights instance | Untrusted client input enters the node's Hono server |
+| node instance → canonical arena over the public internet | A new outbound trust hop introduced by this plan; carries NIP-98 events, JWTs and bot secrets |
+| node instance → its own local SQLite DB | Bypassed entirely in proxy mode; still the live path in standalone mode |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-09-01 | Spoofing | `x-forwarded-for` written by the node proxy | medium | mitigate | The arena only honours forwarding headers when `TRUSTED_PROXY` is set (`rate-limit.ts` `getIp`), and nginx-proxy-manager rewrites `X-Real-IP` to the true peer, so a client-supplied XFF cannot alone impersonate an IP for rate-limit evasion |
+| T-09-02 | Information disclosure | credentials crossing node → arena | high | mitigate | `ARENA_UPSTREAM_URL` is an `https://` origin (set in the manifest, plan 09-06); the proxy forwards `Authorization` unchanged over TLS and never logs header values |
+| T-09-03 | Denial of service | proxy holding sockets for a hung arena | medium | mitigate | 30s `AbortSignal.timeout` on non-stream requests + 502 fast-fail (Task 2); SSE deliberately exempt |
+| T-09-04 | Denial of service | node container flapping when the arena is down | high | mitigate | `/api/health` is never proxied (Task 1), so the manifest health check reflects the node container's own liveness |
+| T-09-05 | Tampering | response corruption from copying stale `content-encoding`/`content-length` | medium | mitigate | Forwarded request asks for `identity`; both headers are stripped from the returned response (Task 1, asserted by test) |
+| T-09-06 | Repudiation | proxied requests losing the originating identity in arena logs | low | accept | XFF forwarding covers the operational need; full request attribution across nodes is out of this phase's scope |
+
+
+
+- `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` — green.
+- `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` — exits 0.
+- `cd /home/archipelago/Projects/botfight && pnpm lint` — no new errors in the two touched files.
+- Maps to `09-VALIDATION.md` row "BOT-03 | SSRF/leak via proxy | REST + SSE forwarding correct, no
+ header leak | integration | `pnpm vitest run server/src/middleware/arena-proxy.test.ts`", which
+ this plan converts from ❌ W0 to ✅.
+
+
+
+- A node instance in proxy mode serves `/api/*` from the canonical arena and nothing else changes.
+- Standalone mode (env unset) is byte-for-byte the behaviour shipped today.
+- `/api/health` is local-only; SSE streams incrementally; the client IP survives the hop; an
+ unreachable arena degrades to a 502 JSON error.
+- No new npm dependency was added (native `fetch` only).
+
+
+
diff --git a/.planning/phases/09-botfights-platform-upgrade/09-02-PLAN.md b/.planning/phases/09-botfights-platform-upgrade/09-02-PLAN.md
new file mode 100644
index 00000000..67d34bdb
--- /dev/null
+++ b/.planning/phases/09-botfights-platform-upgrade/09-02-PLAN.md
@@ -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"
+---
+
+
+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.
+
+
+
diff --git a/.planning/phases/09-botfights-platform-upgrade/09-03-PLAN.md b/.planning/phases/09-botfights-platform-upgrade/09-03-PLAN.md
new file mode 100644
index 00000000..4e5a6479
--- /dev/null
+++ b/.planning/phases/09-botfights-platform-upgrade/09-03-PLAN.md
@@ -0,0 +1,350 @@
+---
+phase: 09-botfights-platform-upgrade
+plan: 03
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md
+ - /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS-EASY.md
+ - /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS-POLLING.md
+ - /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS-WEBHOOK.md
+ - /home/archipelago/Projects/botfight/BOTFIGHTS.md
+ - /home/archipelago/Projects/botfight/BOT_SETUP.md
+ - /home/archipelago/Projects/botfight/server/src/routes/docs.ts
+ - /home/archipelago/Projects/botfight/server/src/routes/docs.test.ts
+ - /home/archipelago/Projects/botfight/frontend/src/pages/JoinBoutPage.vue
+ - /home/archipelago/Projects/botfight/frontend/src/pages/BotProfilePage.vue
+ - /home/archipelago/Projects/botfight/frontend/src/pages/DocsPage.vue
+ - /home/archipelago/Projects/botfight/e2e/signup-bot.spec.ts
+autonomous: true
+requirements: [BOT-02]
+
+must_haves:
+ truths:
+ - "One copy-paste prompt contains everything an AI agent needs to build a working bot — registration, credential handling, BOTH webhook and polling protocols, every endpoint it calls, and every response format — with no instruction to go read another document (D-02/BOT-02)"
+ - "A cloud-hosted AI agent with no browser can fetch the whole prompt with a single unauthenticated GET and the arena URL inside it is already correct for the arena it fetched from"
+ - "The prompt documents the registration call the old docs never mentioned: an anonymous POST /api/bots that returns the bot id and secret"
+ - "The in-app 'show my setup guide' flow still hands the user their real credentials substituted into the text, from the single consolidated doc"
+ - "No user-facing surface links to BOTFIGHTS-EASY / BOTFIGHTS-POLLING / BOTFIGHTS-WEBHOOK / BOT_SETUP any more"
+ artifacts:
+ - path: /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md
+ provides: "The single canonical AI bot-setup prompt (ships into the container at server/public/docs/BOTFIGHTS.md via the frontend build)"
+ contains: "POST /api/bots"
+ - path: /home/archipelago/Projects/botfight/server/src/routes/docs.ts
+ provides: "GET /api/docs/prompt serving the prompt as text/markdown with the arena URL resolved"
+ contains: "docsRouter.get('/prompt'"
+ key_links:
+ - from: /home/archipelago/Projects/botfight/frontend/src/pages/JoinBoutPage.vue
+ to: /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md
+ via: "setupDocPath() resolves to the single consolidated doc and the YOUR_BOT_ID / YOUR_BOT_SECRET substitution still runs on it"
+ pattern: "BOTFIGHTS.md"
+---
+
+
+Deliver D-02/BOT-02: replace the five-document maze (`BOTFIGHTS.md`, `BOTFIGHTS-EASY.md`,
+`BOTFIGHTS-POLLING.md`, `BOTFIGHTS-WEBHOOK.md`, `BOT_SETUP.md`, plus DocsPage's own duplicated copy)
+with ONE self-contained prompt that an AI agent can be handed verbatim, and serve it at a stable URL
+an agent can `curl`.
+
+Decision IDs map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 signer login,
+D-02 = BOT-02 unified AI bot-setup prompt, D-03 = BOT-03 shared arena, D-04 = BOT-04 catalog.
+
+Purpose: tomorrow's demo has a cloud-hosted "openclaw" bot register and fight using ONLY this prompt.
+That is the acceptance bar — not "the docs are tidier". Today's docs fail it twice: they never
+document the registration call (`POST /api/bots`), and the polling example defaults to a stale host
+(`BOTFIGHTS_HOST || 'botfights.io'`) that is not the arena.
+Output: one consolidated prompt file, `GET /api/docs/prompt`, updated in-app call sites with a copy
+button, and the superseded files removed.
+
+**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 |
+|---|---|---|
+| the unified prompt | canonical markdown (single source of truth) | `frontend/public/docs/BOTFIGHTS.md` |
+| `docsRouter.get('/prompt')` | Hono route returning `text/markdown` | `server/src/routes/docs.ts` |
+| `docs.test.ts` additions | Vitest cases for the prompt route | `server/src/routes/docs.test.ts` |
+| copy-the-prompt affordance | UI | `frontend/src/pages/DocsPage.vue` |
+
+Why `frontend/public/docs/BOTFIGHTS.md` is the canonical location: Vite copies `frontend/public/**`
+into `frontend/dist`, and the Dockerfile copies `frontend/dist` to `server/public` — so this file is
+the only copy that exists inside the shipped container. The repo-root `BOTFIGHTS.md` is NOT in the
+image and must not be the source the server reads.
+
+
+
+
+ Task 1: Write the one prompt that a cloud AI agent can build a working bot from
+ /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md, /home/archipelago/Projects/botfight/BOTFIGHTS.md, /home/archipelago/Projects/botfight/BOT_SETUP.md, /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS-EASY.md, /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS-POLLING.md, /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS-WEBHOOK.md
+
+ - `/home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md` — all 452 lines. It is
+ ~90% of the target already: Credentials, Choose a Mode, Option A webhook bot (full JS), Option
+ B polling bot (full JS), How Fights Work, Challenge Payload, All Challenge Types, Security
+ Notes, Tips, After Setup. Extend it in place; do not rewrite from scratch.
+ - `/home/archipelago/Projects/botfight/BOT_SETUP.md` — mine it for the unique content the public
+ doc lacks (customization API, archetype list) before deleting it.
+ - `/home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS-EASY.md`,
+ `BOTFIGHTS-POLLING.md`, `BOTFIGHTS-WEBHOOK.md` — confirm every fact in each already appears in
+ the consolidated doc before removing them.
+ - `/home/archipelago/Projects/botfight/server/src/routes/bots.ts` lines 34-110 — the real
+ registration contract: `POST /api/bots` with `{name}` (2-12 chars, alphanumeric/hyphen/
+ underscore, lowercased, unique), optional `webhook_url` (omit or empty string = poll mode),
+ rate limit 5 per hour per IP, 409 on duplicate name, and webhook mode verifying the URL by
+ calling it before accepting.
+ - `/home/archipelago/Projects/botfight/server/src/middleware/bot-auth.ts` lines 16-45 — the two
+ accepted credential forms: `Authorization: Bot :` or
+ `?bot_id=...&secret=...`.
+ - `/home/archipelago/Projects/botfight/server/src/routes/fights.ts` lines 363-410 —
+ `GET /api/fights/poll` response fields (`pending`, `fight_id`, `round`, `type`, `challenge`,
+ `constraints`, `opponent`, `arena`, `arena_modifier`, `remaining_ms`, `scoring`) and
+ `POST /api/fights/poll/respond` (`{answer, trashTalk}` → `{accepted: true}`, 404 when nothing
+ is pending), plus `POST /api/queue/join/:botId` in `routes/queue.ts`.
+ - `/home/archipelago/Projects/botfight/server/src/engine/orchestrator.ts` lines 180-195 — the
+ webhook `X-Botfights-Signature: sha256=...` HMAC scheme so the prompt can tell a webhook bot
+ how to verify inbound challenges.
+ - `/home/archipelago/Projects/botfight/server/src/routes/docs.ts` lines 6-163 — the challenge
+ types, scoring rules, failure modes and tips already curated for machine consumption; the
+ prompt must agree with them.
+
+
+ Consolidate everything into `frontend/public/docs/BOTFIGHTS.md` as the single canonical prompt.
+ Required content, in this order:
+
+ 1. A one-paragraph framing line stating this file is a complete, self-contained instruction set
+ for an AI agent to build and run a BOTFIGHTS bot, and that nothing else needs to be read.
+ 2. **Register** (new section, currently missing everywhere): the anonymous
+ `POST {{ARENA_URL}}/api/bots` call with a `curl` example and the JSON response, stating that
+ omitting `webhook_url` selects poll mode, that the returned secret is shown once, the 2-12
+ character name rule, the 409-on-duplicate behaviour and the 5-per-hour-per-IP limit.
+ 3. **Credentials**: keep the existing `BOT_ID=YOUR_BOT_ID` / `BOT_SECRET=YOUR_BOT_SECRET` block
+ verbatim — those two placeholder tokens are substituted in-app and MUST survive unchanged —
+ and add both accepted auth forms (`Authorization: Bot :` and the query-param
+ form), plus an instruction to keep the secret in an env var and never commit it.
+ 4. **Choose a mode** and the two full working bot implementations (webhook and polling), kept
+ inline as today. In the polling example, replace the stale hard-coded fallback host that
+ `BOTFIGHTS_HOST` currently defaults to with `{{ARENA_URL}}`, and make both examples use one base-URL constant so an agent
+ edits a single line. Keep them dependency-free Node scripts. The polling example currently
+ falls back to a stale public host when `BOTFIGHTS_HOST` is unset — that fallback literal must
+ be gone from the file when you are done.
+ 5. **Webhook verification**: the `X-Botfights-Signature: sha256=` header, the exact
+ derivation (HMAC-SHA256 over the request body with a key derived from the bot's secret hash),
+ and that a webhook must answer 200 with JSON within `constraints.timeout_ms`.
+ 6. **Enter a fight**: `POST {{ARENA_URL}}/api/queue/join/{BOT_ID}` and what happens when no
+ opponent shows up.
+ 7. **Endpoint reference**: a compact table of every endpoint a bot uses — `POST /api/bots`,
+ `GET /api/fights/poll`, `POST /api/fights/poll/respond`, `POST /api/queue/join/:botId`,
+ `GET /api/bots/:name`, `POST /api/bots/:name/test-challenge`, `GET /api/fights/:id` — with
+ method, auth requirement, request shape and response shape for each.
+ 8. **Challenge payload, challenge types, scoring, failure modes, tips** — keep the existing
+ sections, reconciled with `routes/docs.ts` so the two never disagree.
+ 9. **Troubleshooting**: 401 (bad credentials), 404 from poll/respond (no pending challenge), 429
+ (rate limited — poll no faster than once per second), and the five-consecutive-errors
+ auto-deactivation rule.
+
+ Use the literal token `{{ARENA_URL}}` everywhere a base URL appears. The server route in Task 2
+ substitutes it with the real arena origin; the in-app viewer substitutes it client-side.
+
+ Then remove the superseded files: delete `frontend/public/docs/BOTFIGHTS-EASY.md`,
+ `frontend/public/docs/BOTFIGHTS-POLLING.md`, `frontend/public/docs/BOTFIGHTS-WEBHOOK.md` and
+ `BOT_SETUP.md` (these four filenames appear here only as delete targets — the Task 3 gate greps
+ `frontend/src` for references to them).
+
+ Replace the repo-root `BOTFIGHTS.md` with a three-line stub pointing readers at
+ `frontend/public/docs/BOTFIGHTS.md` and at `GET /api/docs/prompt`, so the two near-identical
+ root/public copies can never drift again. Do not delete any file until Task 3's call-site sweep
+ has a plan for every reference to it (grep first — references exist in `JoinBoutPage.vue`,
+ `BotProfilePage.vue` and `DocsPage.vue`).
+
+
+ cd /home/archipelago/Projects/botfight && test ! -e frontend/public/docs/BOTFIGHTS-POLLING.md && test ! -e frontend/public/docs/BOTFIGHTS-WEBHOOK.md && test ! -e frontend/public/docs/BOTFIGHTS-EASY.md && test ! -e BOT_SETUP.md && grep -q '/api/bots' frontend/public/docs/BOTFIGHTS.md && grep -q 'YOUR_BOT_ID' frontend/public/docs/BOTFIGHTS.md && grep -q '{{ARENA_URL}}' frontend/public/docs/BOTFIGHTS.md
+
+
+ - `grep -c '{{ARENA_URL}}' /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md` is at least 5.
+ - `grep -q 'YOUR_BOT_ID' /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md` and `grep -q 'YOUR_BOT_SECRET' ...` both succeed (in-app substitution tokens preserved).
+ - `grep -q 'api/fights/poll' /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md`, `grep -q 'api/fights/poll/respond' ...`, `grep -q 'api/queue/join' ...`, `grep -q 'X-Botfights-Signature' ...` all succeed.
+ - `grep -q 'Authorization: Bot ' /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md` succeeds.
+ - `grep -c 'botfights.io' /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md` is 0.
+ - The four superseded files no longer exist and the root `BOTFIGHTS.md` is under 10 lines.
+ - `wc -l < /home/archipelago/Projects/botfight/frontend/public/docs/BOTFIGHTS.md` is at least 452 (content was added, not lost).
+
+ One document contains registration, credentials, both protocols, every endpoint and every response format; the four superseded documents are gone.
+
+
+
+ Task 2: Serve the prompt at GET /api/docs/prompt with the real arena URL baked in
+ /home/archipelago/Projects/botfight/server/src/routes/docs.ts, /home/archipelago/Projects/botfight/server/src/routes/docs.test.ts
+
+ - `/home/archipelago/Projects/botfight/server/src/routes/docs.ts` lines 1-10 and the shape of the
+ existing `GET /webhook` handler — the new handler sits alongside it on the same router.
+ - `/home/archipelago/Projects/botfight/server/src/app.ts` lines 90-93 (the `/api/docs/*` GET
+ cache header, already `public, max-age=3600` — no per-route cache handling needed) and lines
+ 110-142 (`publicDir` resolution and `serveFile`, which is how `/docs/*` static files are
+ served in production and confirms `server/public/docs/BOTFIGHTS.md` is the in-container path).
+ - `/home/archipelago/Projects/botfight/server/src/routes/docs.test.ts` — existing harness style
+ for this router.
+ - `/home/archipelago/Projects/botfight/Dockerfile` lines 20-30 — `COPY --from=build-fe /app/frontend/dist server/public`,
+ the reason the in-container path is `server/public/docs/BOTFIGHTS.md`.
+
+
+ Add `docsRouter.get('/prompt', ...)` to `server/src/routes/docs.ts`:
+
+ - Resolve the prompt file at request time by trying, in order:
+ `/public/docs/BOTFIGHTS.md` (the shipped container layout) then
+ `/frontend/public/docs/BOTFIGHTS.md` (a dev checkout where the frontend has not been
+ built). Derive both from `dirname(fileURLToPath(import.meta.url))` the same way `app.ts`
+ derives `publicDir`. Cache the resolved contents in a module-level variable keyed by path plus
+ mtime, or simply read on each request — this endpoint is cached for an hour upstream, so a
+ plain read is acceptable; do not add a file-watcher.
+ - If neither path exists, return `c.json({ error: 'Prompt not available.' }, 404)` following the
+ codebase's direct-return error convention.
+ - Substitute the `{{ARENA_URL}}` token with, in precedence order: `process.env.PUBLIC_ARENA_URL`
+ when set, otherwise the origin of the incoming request (`new URL(c.req.url).origin`). On the
+ canonical arena behind nginx-proxy-manager the inbound request carries the public host, so an
+ agent that curls the public URL gets a prompt whose examples already point back at that same
+ public arena — which is precisely what makes the prompt self-contained for a cloud bot.
+ - Respond with `c.header('Content-Type', 'text/markdown; charset=utf-8')` and the substituted
+ body. Plain text, not JSON — an agent should be able to pipe the response straight into its
+ context.
+
+ Add cases to `server/src/routes/docs.test.ts`:
+ - 200 with a `text/markdown` content type and a body containing the registration endpoint path.
+ - no `{{ARENA_URL}}` token survives in the response body.
+ - with `PUBLIC_ARENA_URL` set, the body contains that value; restore the env var afterwards.
+ - without it, the body contains the origin the request was made to.
+ - the `YOUR_BOT_ID` placeholder is still present (the in-app substitution contract).
+
+
+ cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/docs.test.ts
+
+
+ - `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/docs.test.ts` exits 0 with at least 5 new cases passing.
+ - `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` exits 0.
+ - `grep -Eq "docsRouter\.get\(['\"]/prompt['\"]" /home/archipelago/Projects/botfight/server/src/routes/docs.ts` succeeds.
+ - `grep -q 'text/markdown' /home/archipelago/Projects/botfight/server/src/routes/docs.ts` succeeds.
+ - `grep -q 'PUBLIC_ARENA_URL' /home/archipelago/Projects/botfight/server/src/routes/docs.ts` succeeds.
+ - `grep -q 'public/docs/BOTFIGHTS.md' /home/archipelago/Projects/botfight/server/src/routes/docs.ts` succeeds (the container-layout path, not the repo-root file).
+
+ `curl /api/docs/prompt` returns the complete prompt as markdown with working URLs, from both a built container and a dev checkout.
+
+
+
+ Task 3: Point every in-app surface at the one prompt (and give it a copy button)
+ /home/archipelago/Projects/botfight/frontend/src/pages/JoinBoutPage.vue, /home/archipelago/Projects/botfight/frontend/src/pages/BotProfilePage.vue, /home/archipelago/Projects/botfight/frontend/src/pages/DocsPage.vue, /home/archipelago/Projects/botfight/e2e/signup-bot.spec.ts
+
+ - `/home/archipelago/Projects/botfight/frontend/src/pages/JoinBoutPage.vue` around lines 515-560 —
+ `setupDocPath()`, `setupDocName()` and `toggleSetupContent()`, including the
+ `.replace(/YOUR_BOT_ID/g, ...)` / `.replace(/YOUR_BOT_SECRET/g, ...)` substitution that is the
+ load-bearing behaviour to preserve.
+ - `/home/archipelago/Projects/botfight/frontend/src/pages/BotProfilePage.vue` around lines 295-310
+ and 880-890 — the same mode-conditional doc path plus the filename shown in the UI.
+ - `/home/archipelago/Projects/botfight/frontend/src/pages/DocsPage.vue` lines 1-60 and 500-530 —
+ it fetches `/api/docs/webhook` (a JSON API reference) and hosts the interactive webhook tester.
+ Both stay; what changes is that the page leads with the prompt.
+ - `/home/archipelago/Projects/botfight/e2e/signup-bot.spec.ts` (43 lines) — the Playwright
+ conventions to follow: `test.describe` grouping, `page.getByText(/regex/i)` selectors, explicit
+ `{ timeout: N_000 }`.
+ - `/home/archipelago/Projects/botfight/CLAUDE.md` "Vue 3 Conventions" — `