From 6a112797a54b60ddc465b0c74fa16a6943b1131d Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 21:03:09 -0400 Subject: [PATCH] =?UTF-8?q?docs(09):=20plan=20BotFights=20platform=20upgra?= =?UTF-8?q?de=20=E2=80=94=207=20plans=20across=204=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BOT-01 native nostr signer login (GET /api/auth/me, bare-pubkey path retired), BOT-02 one self-contained AI bot-setup prompt at /api/docs/prompt, BOT-03 shared public arena on VPS2 with a node-side reverse-proxy tracer, BOT-04 manifest 1.2.0 with a generated JWT secret + republished signed catalog. Wave 1 runs 09-01..09-04 in parallel (app work plus the DNS/TLS human gate, started early for the 2026-07-31 demo deadline); waves 2-4 build the image, publish the catalog, and land the demo rehearsal on archi-dev-box. Co-Authored-By: Claude Fable 5 --- .planning/ROADMAP.md | 10 +- .../09-01-PLAN.md | 296 +++++++++++++++ .../09-02-PLAN.md | 260 +++++++++++++ .../09-03-PLAN.md | 350 ++++++++++++++++++ .../09-04-PLAN.md | 325 ++++++++++++++++ .../09-05-PLAN.md | 278 ++++++++++++++ .../09-06-PLAN.md | 319 ++++++++++++++++ .../09-07-PLAN.md | 238 ++++++++++++ 8 files changed, 2074 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-01-PLAN.md create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-02-PLAN.md create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-03-PLAN.md create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-04-PLAN.md create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-05-PLAN.md create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-06-PLAN.md create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-07-PLAN.md 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). + + + +Create `.planning/phases/09-botfights-platform-upgrade/09-01-SUMMARY.md` when done. +Commit the botfight changes in `/home/archipelago/Projects/botfight` with `git add` by explicit path +and `git push origin main`. Commit the SUMMARY in archy and `git push gitea-ai main`. + 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. + + + +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`. + 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" — `