docs(09): plan BotFights platform upgrade — 7 plans across 4 waves

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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 21:03:09 -04:00
parent 9bead8fa4c
commit 6a112797a5
8 changed files with 2074 additions and 2 deletions

View File

@ -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 18)
**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)

View File

@ -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"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md
@.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md
@.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md
@.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md
</context>
## Artifacts this plan produces
| Symbol | Kind | File |
|---|---|---|
| `arenaProxy` | exported async Hono middleware `(c, next)` | `server/src/middleware/arena-proxy.ts` |
| `HOP_BY_HOP` | module-private `Set<string>` 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` |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — one /api/* request served by the canonical arena through a node instance</name>
<reversibility rating="reversible">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.</reversibility>
<files>/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</files>
<read_first>
- `/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.
</read_first>
<behavior>
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:<port>`. 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:<port>` 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).
</behavior>
<action>
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).
</action>
<verify>
<automated>cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts</automated>
</verify>
<acceptance_criteria>
- `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<r)}' /home/archipelago/Projects/botfight/server/src/app.ts` succeeds (the proxy is mounted before the routers).
- `grep -q 'HOP_BY_HOP' /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts` succeeds.
- `grep -v '^\s*//' /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts | grep -Eqv 'upstreamRes\.(text|json|arrayBuffer)\(' ` — confirm by inspection that the upstream body is never buffered before returning; the SSE test in Task 2 is the enforcing gate.
- The SUMMARY records the captured pre-implementation failure output of at least the round-trip test.
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Make the proxied path survive real conditions — SSE streaming, client IP, upstream down</name>
<files>/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</files>
<read_first>
- `/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.
</read_first>
<action>
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<string, any>)?.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.
</action>
<verify>
<automated>cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts server/src/middleware/rate-limit.test.ts</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
- `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 ✅.
</verification>
<success_criteria>
- 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).
</success_criteria>
<output>
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`.
</output>

View File

@ -0,0 +1,260 @@
---
phase: 09-botfights-platform-upgrade
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- /home/archipelago/Projects/botfight/server/src/routes/auth.ts
- /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts
- /home/archipelago/Projects/botfight/server/src/routes/auth.test.ts
- /home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts
- /home/archipelago/Projects/botfight/e2e/helpers/auth.ts
autonomous: true
requirements: [BOT-01]
must_haves:
truths:
- "A signed-in user's session is restored from their JWT alone — the client never sends a bare pubkey to claim an identity (D-01/BOT-01)"
- "GET /api/auth/me returns the caller's own bot only when a valid, unexpired, non-blacklisted JWT is presented; anything else is 401"
- "POST /api/auth/login can no longer mutate the database — the creator auto-create/auto-upgrade side effects only run behind NIP-98 verification in POST /api/auth/nostr/session"
- "The NIP-07 / NIP-55 signer flow (POST /api/auth/nostr/session with a kind-27235 event, JWT returned) remains the only way to obtain a session"
artifacts:
- path: /home/archipelago/Projects/botfight/server/src/routes/auth.ts
provides: "JWT-gated GET /me route; POST /login reduced to a read-only deprecated lookup"
contains: "authRouter.get('/me'"
- path: /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts
provides: "Coverage for the JWT-gated identity route (missing / invalid / expired / blacklisted / valid)"
contains: "/api/auth/me"
key_links:
- from: /home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts
to: /home/archipelago/Projects/botfight/server/src/routes/auth.ts
via: "auto-restore calls authFetch('/api/auth/me') with the stored Bearer JWT instead of POSTing a bare pubkey"
pattern: "/api/auth/me"
---
<objective>
Finish D-01/BOT-01: the app already ships a complete NIP-07 + NIP-98 + NIP-55 signer login with JWT
sessions (commit `3ba05a6` and follow-ups on `main`) — what remains is closing the last bare-pubkey
trust path and giving the client a JWT-only way to restore its session.
Decision IDs map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 native nostr signer login,
D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared arena, D-04 = BOT-04 registry/catalog.
Purpose: today `useNostr.ts` restores a session by POSTing `{pubkey}` to `/api/auth/login`, and that
endpoint will happily auto-create and auto-upgrade the creator's bot row for whoever asks — an
unauthenticated request causing a database mutation is exactly the "trust the pubkey" model D-01
says must go. The identical creator logic already exists, correctly gated, inside
`POST /api/auth/nostr/session`.
Output: a JWT-gated `GET /api/auth/me`, its test file, a read-only deprecated `POST /login`, and a
client that restores sessions from the token it already holds.
**Repo: `/home/archipelago/Projects/botfight`.** Commit target: `git push origin main`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md
@.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md
@.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md
@.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md
</context>
## Artifacts this plan produces
| Symbol | Kind | File |
|---|---|---|
| `authRouter.get('/me')` | JWT-gated Hono route | `server/src/routes/auth.ts` |
| `auth-me.test.ts` | Vitest suite | `server/src/routes/auth-me.test.ts` |
| auto-restore via `GET /api/auth/me` | client change | `frontend/src/composables/useNostr.ts` |
The response shape of `GET /me` is deliberately identical to the existing `POST /login` 200 body
(`{ exists: true, bot: {...} }` / `{ exists: false }`) so `normalizeBotData` on the client is
unchanged.
<tasks>
<task type="auto" tdd="true">
<name>Task 1: GET /api/auth/me — identity from the JWT, never from a claimed pubkey</name>
<files>/home/archipelago/Projects/botfight/server/src/routes/auth.ts, /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts</files>
<read_first>
- `/home/archipelago/Projects/botfight/server/src/routes/auth.ts` lines 1-140 — the imports
block, `POST /login`'s exact `db.select({...})` projection (lines 36-52) and its 200 response
body (lines 113-135). `GET /me` reuses both verbatim.
- `/home/archipelago/Projects/botfight/server/src/routes/auth.ts` lines 375-505 —
`POST /nostr/session`: it already performs the creator auto-upgrade and auto-create AFTER
`verifyNip98Token` succeeds, which is why the duplicates in `/login` are removable in Task 2.
- `/home/archipelago/Projects/botfight/server/src/middleware/jwt.ts``createJwt`,
`verifyJwt`, `blacklistJwt`, and `extractPubkeyFromAuth` (lines ~93-98), the helper the new
route uses. Note the module throws at import time when `JWT_SECRET` is unset and
`NODE_ENV=production` — tests must not set `NODE_ENV=production` without also setting `JWT_SECRET`.
- `/home/archipelago/Projects/botfight/server/src/routes/auth.test.ts` lines 1-90 — harness
convention to copy for the new test file.
- `/home/archipelago/Projects/botfight/server/src/middleware/jwt.test.ts` — how a token is minted
and blacklisted in tests.
</read_first>
<behavior>
Write `server/src/routes/auth-me.test.ts` first and confirm it fails before adding the route.
Mount `authRouter` on a bare `new Hono()` at `/api/auth`, mint tokens with `createJwt`.
- no `Authorization` header → 401 with a JSON `error` key.
- malformed / garbage Bearer value → 401.
- a token whose signature does not verify (tamper one character of the signature segment) → 401.
- a token passed to `blacklistJwt` before the call → 401.
- a valid token for a pubkey with no bot row → 200 `{ exists: false }`.
- a valid token for a pubkey that owns a bot row → 200, `exists: true`, and `bot.id`/`bot.name`
matching the seeded row, with the same key set the `POST /login` 200 body returns.
Seed rows through the same `db`/`schema` import the other route tests use rather than mocking
drizzle, matching the in-repo convention.
</behavior>
<action>
Add to `server/src/routes/auth.ts`:
`import { extractPubkeyFromAuth } from '../middleware/jwt.js'` to the existing import block, then
`authRouter.get('/me', async (c) => { ... })` which:
- resolves the caller's pubkey with `extractPubkeyFromAuth(c.req.header('Authorization'))` and
returns `c.json({ error: 'Authentication required.' }, 401)` when it is null — that single
helper already covers the missing-header, bad-format, bad-signature, expired and blacklisted
cases because it delegates to `verifyJwt`;
- selects from `schema.bots` with the exact same projection as `POST /login` (lines 36-52),
filtered by `eq(schema.bots.publicKey, pubkey)`, limit 1;
- returns `c.json({ exists: false })` when there is no row, else the same
`{ exists: true, bot: {...} }` object `POST /login` builds (including the derived
`isHuman` boolean, parsed `customization`, and `hasWallet: false`);
- performs NO writes of any kind. This route is a read of the caller's own identity.
Do not add a per-route `rateLimit` — the global `/api/*` limiter in `app.ts` (line 71) already
covers it, and a session-restore call on every page load must not compete with a tight budget.
Place the handler next to the other read routes near the top of the file (after
`GET /check-name/:name`) so the router reads read-then-write like the rest of the codebase.
</action>
<verify>
<automated>cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth-me.test.ts</automated>
</verify>
<acceptance_criteria>
- `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth-me.test.ts` exits 0 with at least 6 passing tests.
- `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` exits 0.
- `grep -Eq "authRouter\.get\(['\"]/me['\"]" /home/archipelago/Projects/botfight/server/src/routes/auth.ts` succeeds.
- `grep -q 'extractPubkeyFromAuth' /home/archipelago/Projects/botfight/server/src/routes/auth.ts` succeeds.
- `grep -c '/api/auth/me' /home/archipelago/Projects/botfight/server/src/routes/auth-me.test.ts` is at least 6.
- The SUMMARY records the captured pre-implementation failure output of the new test file.
</acceptance_criteria>
<done>A caller can retrieve their own bot only by presenting a valid JWT, proven by tests covering the missing, malformed, forged, blacklisted, unregistered and valid cases.</done>
</task>
<task type="auto">
<name>Task 2: Retire the bare-pubkey session path (client + server side effects)</name>
<files>/home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts, /home/archipelago/Projects/botfight/server/src/routes/auth.ts, /home/archipelago/Projects/botfight/e2e/helpers/auth.ts</files>
<read_first>
- `/home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts` lines 105-140 — the
auto-restore block guarded by `!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()`,
and the sibling `else if` branch that clears a stale pre-JWT pubkey. Only the fetch inside the
first branch changes.
- `/home/archipelago/Projects/botfight/frontend/src/lib/nostr-auth.ts` lines 99-110 — `authFetch`
already attaches the Bearer token and clears it on 401; no change is needed there.
- `/home/archipelago/Projects/botfight/server/src/routes/auth.ts` lines 29-137 — `POST /login`,
specifically the creator auto-create branch (`rows.length === 0 && isCreatorPubkey(pubkey)`)
and the creator auto-upgrade block (`db.update(...)` around lines 100-110). Both are duplicated
inside `POST /nostr/session` behind NIP-98 verification.
- `/home/archipelago/Projects/botfight/server/src/routes/auth.test.ts` lines 30-200 — the
existing `/login` cases that must keep passing (adjust only assertions that depended on the
removed mutations).
- `/home/archipelago/Projects/botfight/e2e/helpers/auth.ts``loginWithPubkey` calls the legacy
endpoint; it stays working as a read-only lookup and its doc comment must say so.
</read_first>
<action>
Client (`useNostr.ts`): replace the auto-restore request with
`authFetch('/api/auth/me')` — a GET with no headers argument and no body — keeping the exact same
`.then(r => r.json()).then(data => { if (data.exists) { bot.value = normalizeBotData(data.bot); store('bf_bot', bot.value) } })`
continuation and the existing `.catch` warning. Leave the guard condition, the `autoRestoreRan`
flags and the `else if` stale-pubkey branch untouched. Search the whole `frontend/src` tree for
any other request that sends a pubkey in a request body to claim an identity and convert or
remove it; the signer flow (`buildNip98Token``POST /api/auth/nostr/session`) is the only
sanctioned way to establish a session.
Server (`auth.ts`, `POST /login`): reduce it to a pure read.
- Delete the creator auto-create branch and the creator auto-upgrade `db.update` block from this
handler. The equivalents in `POST /nostr/session` already run after `verifyNip98Token` and are
the retained implementations — a creator who signs in with a real signer still gets the same
row created/upgraded.
- Keep the lookup, the zod `loginSchema` validation, the `rateLimit(60_000, 10)` and the response
shape so leaderboard-style lookups and `e2e/helpers/auth.ts` keep working.
- Add a handler doc comment recording that this endpoint is a deprecated read-only lookup kept
for compatibility, that it establishes no session and issues no token, and that session
establishment lives in `POST /nostr/session` (D-01).
`e2e/helpers/auth.ts`: update the file/function doc comments so they describe `loginWithPubkey`
as a read-only lookup helper used by tests, not a login. Do not change its request or signature.
Update `server/src/routes/auth.test.ts` only where a case asserted a mutation that has moved
(e.g. a creator row being created by `/login`); re-point such an assertion at
`POST /nostr/session` or drop it, and add one case asserting that a `/login` call for an
unregistered creator pubkey now returns `exists: false` and leaves the table row count unchanged.
</action>
<verify>
<automated>cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth.test.ts server/src/routes/auth-edge.test.ts server/src/routes/auth-audit.test.ts server/src/routes/auth-me.test.ts</automated>
</verify>
<acceptance_criteria>
- `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/auth.test.ts server/src/routes/auth-edge.test.ts server/src/routes/auth-audit.test.ts server/src/routes/auth-me.test.ts` exits 0.
- `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` exits 0.
- `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` exits 0 and `pnpm exec vue-tsc --noEmit -p frontend/tsconfig.json` exits 0.
- `grep -q "authFetch('/api/auth/me')" /home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts` succeeds.
- `test -z "$(grep -rl 'auth/login' /home/archipelago/Projects/botfight/frontend/src)"` succeeds (no client code targets the legacy endpoint any more).
- `awk "/authRouter.post\\('\\/login'/,/^authRouter.post\\('\\/register'/" /home/archipelago/Projects/botfight/server/src/routes/auth.ts | grep -c 'db.insert\|db.update'` equals 0 (the login handler performs no writes).
- `grep -c 'db.insert\|db.update' /home/archipelago/Projects/botfight/server/src/routes/auth.ts` is at least 2 (the creator paths still exist elsewhere in the file, i.e. they were moved-from-login, not deleted wholesale).
</acceptance_criteria>
<done>No client path and no unauthenticated request can create, upgrade or restore an identity from a bare pubkey; session establishment is signer-only.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser signer (NIP-07 extension / Amber) → app | The private key stays in the signer; only signed events cross |
| unauthenticated HTTP client → `/api/auth/*` | Anyone on the network can call these routes |
| JWT bearer → bot-owning identity | The token is the sole proof of "this pubkey is me" after login |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-07 | Spoofing | identity claimed by posting someone else's pubkey | high | mitigate | `GET /me` derives the pubkey from a verified JWT only (Task 1); the client stops sending bare pubkeys (Task 2) |
| T-09-08 | Tampering | unauthenticated request mutating the creator's bot row via `POST /login` | high | mitigate | Creator auto-create/auto-upgrade removed from `/login`; the NIP-98-gated `/nostr/session` copies remain (Task 2), asserted by the row-count test |
| T-09-09 | Elevation of privilege | forged or replayed JWT | high | mitigate | Unchanged, already-tested `verifyJwt` (HMAC-SHA256 + `timingSafeEqual` + blacklist); `GET /me` adds no new verification path, and blacklisted-token rejection is covered by a new test |
| T-09-10 | Information disclosure | `POST /login` remaining an anonymous profile lookup | low | accept | It returns the same fields the public leaderboard already exposes (name, elo, W/L, tier); it is documented as deprecated and issues no token |
| T-09-11 | Spoofing | NIP-98 event replay inside the 120s freshness window | medium | accept | Pre-existing, out of this phase's scope (no jti/nonce store); mitigated in practice by HTTPS-only transport to the arena. Named explicitly rather than left silent |
</threat_model>
<verification>
- `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` — green.
- `cd /home/archipelago/Projects/botfight && pnpm exec vue-tsc --noEmit -p frontend/tsconfig.json` — exits 0.
- Maps to `09-VALIDATION.md` rows "BOT-01 | session theft | JWT issue/verify/blacklist" and
"BOT-01 | legacy bypass | no bare-pubkey login path remains"; the new `auth-me.test.ts` closes the
Wave 0 gap listed for `server/src/routes/auth-me.test.ts`.
- Real-signer verification (NIP-07 extension, Amber NIP-55) is deliberately NOT claimed here — it is
a human checkpoint in plan 09-07, per `09-RESEARCH.md` Pitfall 5.
</verification>
<success_criteria>
- `GET /api/auth/me` exists, is JWT-only, is read-only, and is covered by tests.
- The client restores sessions with its JWT and never posts a bare pubkey.
- `POST /api/auth/login` performs no database writes and is documented as deprecated.
- The whole existing server suite still passes.
</success_criteria>
<output>
Create `.planning/phases/09-botfights-platform-upgrade/09-02-SUMMARY.md` when done.
Commit the botfight changes with `git add` by explicit path and `git push origin main`.
Commit the SUMMARY in archy and `git push gitea-ai main`.
</output>

View File

@ -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"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md
@.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md
@.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md
@.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md
</context>
## Artifacts this plan produces
| Symbol | Kind | File |
|---|---|---|
| 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.
<tasks>
<task type="auto">
<name>Task 1: Write the one prompt that a cloud AI agent can build a working bot from</name>
<files>/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</files>
<read_first>
- `/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 <bot_id>:<secret>` 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.
</read_first>
<action>
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 <bot_id>:<secret>` 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=<hmac>` 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).
<!-- planner-discipline-allow: BOTFIGHTS-POLLING, BOTFIGHTS-WEBHOOK, BOTFIGHTS-EASY, BOT_SETUP -->
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`).
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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).
</acceptance_criteria>
<done>One document contains registration, credentials, both protocols, every endpoint and every response format; the four superseded documents are gone.</done>
</task>
<task type="auto">
<name>Task 2: Serve the prompt at GET /api/docs/prompt with the real arena URL baked in</name>
<files>/home/archipelago/Projects/botfight/server/src/routes/docs.ts, /home/archipelago/Projects/botfight/server/src/routes/docs.test.ts</files>
<read_first>
- `/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`.
</read_first>
<action>
Add `docsRouter.get('/prompt', ...)` to `server/src/routes/docs.ts`:
- Resolve the prompt file at request time by trying, in order:
`<serverRoot>/public/docs/BOTFIGHTS.md` (the shipped container layout) then
`<repoRoot>/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).
</action>
<verify>
<automated>cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/routes/docs.test.ts</automated>
</verify>
<acceptance_criteria>
- `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).
</acceptance_criteria>
<done>`curl <arena>/api/docs/prompt` returns the complete prompt as markdown with working URLs, from both a built container and a dev checkout.</done>
</task>
<task type="auto">
<name>Task 3: Point every in-app surface at the one prompt (and give it a copy button)</name>
<files>/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</files>
<read_first>
- `/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" — `<script setup lang="ts">`,
script section ordering, naming rules.
</read_first>
<action>
`JoinBoutPage.vue`: collapse `setupDocPath()` and `setupDocName()` to the single consolidated doc
(`/docs/BOTFIGHTS.md`, displayed as `BOTFIGHTS.md`) regardless of the selected connection mode.
Keep `toggleSetupContent()` and the two placeholder substitutions exactly as they are, and add a
third substitution replacing the `{{ARENA_URL}}` token with `window.location.origin` so the
user's copied text has a working base URL. Keep the mode selector itself — it still drives what
gets registered — it just no longer selects a different document.
`BotProfilePage.vue`: same change at its own call site, including the filename shown in the UI.
`DocsPage.vue`: add a prominent panel at the top of the page — "Give this to your AI" — with a
copy-to-clipboard button that fetches `/api/docs/prompt`, copies the response text, and shows a
transient confirmation; plus the literal URL `/api/docs/prompt` displayed so a user can hand the
URL itself to an agent. Keep the existing API reference and webhook tester below it, and remove
any link, tab or inline copy that sends the reader to one of the deleted documents. Follow the
repo's Vue conventions (`<script setup lang="ts">`, `ref` for primitives, kebab-case emits).
Sweep `frontend/src` for every reference to the four documents deleted in Task 1 and confirm
zero remaining hits when finished.
<!-- planner-discipline-allow: BOTFIGHTS-POLLING, BOTFIGHTS-WEBHOOK, BOTFIGHTS-EASY, BOT_SETUP -->
`e2e/signup-bot.spec.ts`: add one test that navigates to the docs page and asserts the copy
affordance is visible, and one that fetches `/api/docs/prompt` through the page's `request`
fixture and asserts a 200 with a body containing the registration endpoint path — that is the
automated stand-in for "an agent could actually consume this".
</action>
<verify>
<automated>cd /home/archipelago/Projects/botfight && pnpm exec vue-tsc --noEmit -p frontend/tsconfig.json && test -z "$(grep -rl 'BOTFIGHTS-POLLING\|BOTFIGHTS-WEBHOOK\|BOTFIGHTS-EASY\|BOT_SETUP' frontend/src || true)"</automated>
</verify>
<acceptance_criteria>
- `cd /home/archipelago/Projects/botfight && pnpm exec vue-tsc --noEmit -p frontend/tsconfig.json` exits 0.
- `grep -rl 'BOTFIGHTS-POLLING\|BOTFIGHTS-WEBHOOK\|BOTFIGHTS-EASY\|BOT_SETUP' /home/archipelago/Projects/botfight/frontend/src` returns nothing.
- `grep -q 'YOUR_BOT_SECRET' /home/archipelago/Projects/botfight/frontend/src/pages/JoinBoutPage.vue` succeeds (credential substitution retained).
- `grep -q 'api/docs/prompt' /home/archipelago/Projects/botfight/frontend/src/pages/DocsPage.vue` succeeds.
- `grep -q 'api/docs/prompt' /home/archipelago/Projects/botfight/e2e/signup-bot.spec.ts` succeeds.
- `cd /home/archipelago/Projects/botfight && pnpm --filter frontend build` exits 0 and `test -f frontend/dist/docs/BOTFIGHTS.md` succeeds (the prompt ships in the bundle that becomes `server/public`).
- `cd /home/archipelago/Projects/botfight && pnpm lint` reports no new errors in the three touched Vue files.
</acceptance_criteria>
<done>Every in-app path to setup instructions leads to the one prompt, credentials still substitute, and the prompt is one click or one curl away.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| anonymous internet client → `GET /api/docs/prompt` | Unauthenticated read of a public document |
| server filesystem → HTTP response | A file path is resolved and its contents returned |
| prompt content → a third-party AI agent's execution context | Whatever this file says, an agent will do |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-12 | Information disclosure | path traversal via the prompt route | medium | mitigate | The route resolves two hard-coded constant paths derived from `import.meta.url`; no request input reaches the filesystem call (Task 2) |
| T-09-13 | Tampering | the prompt instructing agents to leak the bot secret | high | mitigate | The credentials section tells agents to hold the secret in an env var, never to commit it and never to send it anywhere but the arena host; the endpoint table marks exactly which calls take credentials (Task 1) |
| T-09-14 | Spoofing | a prompt served from an attacker-controlled origin pointing bots at a fake arena | medium | mitigate | `{{ARENA_URL}}` resolves to `PUBLIC_ARENA_URL` or the origin the prompt was fetched from, so it can never silently name a third host; the arena is HTTPS-only (plan 09-04) |
| T-09-15 | Denial of service | unauthenticated repeated reads of the prompt | low | accept | Covered by the global `/api/*` rate limit plus the existing one-hour cache header on `/api/docs/*` |
</threat_model>
<verification>
- `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` — green.
- `cd /home/archipelago/Projects/botfight && pnpm --filter frontend build` — exits 0 and emits `frontend/dist/docs/BOTFIGHTS.md`.
- `cd /home/archipelago/Projects/botfight && pnpm test:e2e -- e2e/signup-bot.spec.ts` — green (runs against a local dev server per `e2e/playwright.config.ts`).
- Maps to `09-VALIDATION.md` row "BOT-02 | prompt-documented flow works exactly as written | e2e |
`pnpm test:e2e -- e2e/signup-bot.spec.ts` (extended)". The end-to-end proof that a real cloud agent
can build a bot from this prompt alone is the human checkpoint in plan 09-07.
</verification>
<success_criteria>
- Exactly one setup document exists and it contains registration, credentials, both protocols, every
endpoint and every response format.
- `GET /api/docs/prompt` returns it as markdown with a working arena base URL.
- No UI surface or repo file points at the deleted documents.
- The in-app personalised-credentials flow still works.
</success_criteria>
<output>
Create `.planning/phases/09-botfights-platform-upgrade/09-03-SUMMARY.md` when done.
Commit the botfight changes with `git add` by explicit path and `git push origin main`.
Commit the SUMMARY in archy and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,325 @@
---
phase: 09-botfights-platform-upgrade
plan: 04
type: execute
wave: 1
depends_on: []
files_modified:
- /home/archipelago/Projects/botfight/docker-compose.arena.yml
- /home/archipelago/Projects/botfight/docs/arena-deployment.md
autonomous: false
requirements: [BOT-03]
user_setup:
- service: godaddy-dns
why: "The canonical arena needs a public hostname; archipelago-foundation.org is on GoDaddy nameservers (ns29/ns30.domaincontrol.com) with no wildcard record, so a new A record must be created by the domain owner"
dashboard_config:
- task: "Create an A record for the arena subdomain pointing at 146.59.87.168"
location: "GoDaddy DNS management for archipelago-foundation.org"
- service: nginx-proxy-manager
why: "TLS termination and public routing on VPS2 live in NPM's own database, not in any git repo"
dashboard_config:
- task: "Add a Proxy Host: the arena subdomain -> http 146.59.87.168:9100, websockets enabled, SSL forced, request a new Let's Encrypt certificate"
location: "http://146.59.87.168:81 (admin lfg2025@proton.me)"
must_haves:
truths:
- "One canonical BotFights arena runs on VPS2 (146.59.87.168) in standalone mode — it owns the only real match/fighter database (D-03/BOT-03)"
- "The arena answers /api/health on the VPS2 host before any DNS or TLS work begins, so a later public failure is unambiguously a routing problem and not an app problem"
- "The arena's JWT signing secret is generated on the VPS2 host, stored 0600 outside git, and never appears in a tracked file or in a log"
- "The arena trusts the forwarding headers nginx-proxy-manager sets, so per-IP rate limiting sees real client IPs and not one bucket for the whole internet"
- "Lightning/cashu payment features are left unconfigured on the public arena — they are explicitly out of this phase's scope"
- "How to redeploy this arena from scratch is written down in the repo, because NPM's routing and the host .env are not git-tracked artifacts"
artifacts:
- path: /home/archipelago/Projects/botfight/docker-compose.arena.yml
provides: "Reproducible compose definition for the canonical VPS2 arena (registry image, no build, no payment env)"
contains: "TRUSTED_PROXY"
- path: /home/archipelago/Projects/botfight/docs/arena-deployment.md
provides: "The runbook: host paths, port, NPM proxy-host values, DNS record, secret handling, rollback"
contains: "146.59.87.168"
key_links:
- from: nginx-proxy-manager on VPS2 (:80/:443)
to: the canonical arena container on 146.59.87.168:9100
via: "an NPM proxy host forwarding http to 146.59.87.168:9100 with websockets on and SSL forced — the same shape as the existing demo/source/fips/companion hosts"
pattern: "9100"
---
<objective>
Stand up the one canonical BotFights arena on VPS2 and give it a public, TLS-terminated hostname —
the shared endpoint every node's instance will proxy to (D-03/BOT-03).
Decision IDs map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 signer login,
D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared public match endpoint on VPS2,
D-04 = BOT-04 registry/catalog.
Purpose: this is the longest-latency item on the demo path because it needs a DNS record at the
registrar and a certificate issuance, neither of which Claude can perform. Starting it in wave 1,
in parallel with the app work, is deliberate — if DNS stalls, the fallback (plain HTTP on the public
IP and port) is still enough for a cloud bot, and the plan says so explicitly rather than leaving
the demo to discover it.
Output: a running canonical arena on VPS2, a committed compose file and runbook, a public hostname
with a valid certificate, and a recorded decision about what data the arena starts with.
**Repos/hosts:** compose + runbook are committed in `/home/archipelago/Projects/botfight`
(`git push origin main`); the arena itself runs on VPS2 (`debian@146.59.87.168`, key
`~/.ssh/id_ed25519_vps168`, docker not podman, passwordless sudo). VPS2 is host infrastructure —
the rootless-podman invariant applies to Archipelago nodes, not to this host.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md
@.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md
@.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md
@.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md
</context>
## Environment facts verified during planning (do not re-derive, do re-check)
| Fact | Value |
|---|---|
| VPS2 access | `ssh debian@146.59.87.168` with `~/.ssh/id_ed25519_vps168`, passwordless sudo, docker |
| Ports already bound on VPS2 | 22, 80, 81, 443, 2100, 2101, 2222, 3000, 3009, 5355, 7788, 8000, 8092, 8123, 8443, 8444, 9443 — **9100 is free** |
| NPM install | container `nginx-proxy-manager-app-1`, compose dir `/home/debian/nginx-proxy-manager`, data `/home/debian/nginx-proxy-manager/data`, admin UI on :81, admin user `lfg2025@proton.me` |
| Existing NPM proxy-host shape | `forward_scheme=http`, `forward_host=146.59.87.168`, `forward_port=<port>`, `ssl_forced=1`, `allow_websocket_upgrade=1`, Let's Encrypt cert (e.g. demo→2100, source→3000, fips→8444) |
| DNS | `archipelago-foundation.org` on `ns29/ns30.domaincontrol.com` (GoDaddy). `demo.`/`source.`/`fips.` resolve to 146.59.87.168. **No wildcard** — a new subdomain needs a new A record |
| Registry | `146.59.87.168:3000/lfg2025` (Gitea on the same host, so the arena can pull from `localhost:3000`) |
| Existing arena data on archi-dev-box | `/var/lib/archipelago/botfights/botfights.db` — 115 bots, 102,440 fights, 351 MB, `payments`/`bets` empty |
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Decide the arena hostname and what data the public arena starts with</name>
<decision>What hostname does the canonical arena use, and what data does it start with?</decision>
<context>
Two choices must be made before the arena is deployed, because both are baked into the compose
file, the NPM proxy host, the DNS record, and (later) the app manifest every node reads.
(1) **Hostname.** Planning recommends `arena.archipelago-foundation.org` — it matches the
established `<name>.archipelago-foundation.org` pattern already used by demo/source/fips/
companion, and it is what plan 09-06 will write into `apps/botfights/manifest.yml` as
`ARENA_UPSTREAM_URL`. Changing it later means re-signing and re-publishing the catalog.
(2) **Starting data.** archi-dev-box's existing BotFights instance holds 115 registered bots and
102,440 fights (351 MB). Once nodes switch to proxy mode, that local database stops being read —
it is preserved on disk, but those 115 fighters vanish from the UI unless the arena starts with
them.
Option A — fresh empty arena. Cleanest and most private; the demo starts with an empty
leaderboard and only bots registered from now on (including the cloud openclaw bot).
Option B — seed the `bots` table only (recommended). Copies the 115 fighter rows (name, ELO,
W/L, avatar, `secret_hash`, `public_key`) into a fresh arena DB and drops the 102k-row fight
history, so the arena is small, the leaderboard looks alive for the demo, and every bot script
that already holds credentials keeps working. It does publish those bot names and nostr pubkeys
on a public server.
Option C — copy the whole 351 MB database, fight history included. Most continuity, largest
surface, and carries `payments`/`bets` tables (both currently empty) onto a public host.
Reversibility: the arena DB is brand new and can be re-seeded or wiped until real users register
against it — so this is reversible today and progressively less so after the demo.
</context>
<options>
<option id="option-a">
<name>arena.archipelago-foundation.org + fresh empty database</name>
<pros>Nothing private leaves archi-dev-box; smallest attack surface; fastest deploy</pros>
<cons>Empty leaderboard on demo day; existing 115 bots are invisible until re-registered</cons>
</option>
<option id="option-b">
<name>arena.archipelago-foundation.org + seed the bots table only (recommended)</name>
<pros>Demo shows a populated roster; existing bot credentials keep working; arena stays small; no fight/payment history exported</pros>
<cons>115 bot names and their nostr pubkeys become publicly visible on the internet</cons>
</option>
<option id="option-c">
<name>arena.archipelago-foundation.org + full database copy</name>
<pros>Complete continuity including fight history and stats</pros>
<cons>351 MB transfer; exports fight history and empty-but-present payment tables to a public host</cons>
</option>
<option id="option-d">
<name>A different hostname (state it) with one of the data options above</name>
<pros>Whatever naming the domain owner prefers</pros>
<cons>Must be decided now — it is written into the signed catalog in plan 09-06</cons>
</option>
</options>
<resume-signal>Reply with the chosen option id (and the hostname if option-d), e.g. "option-b"</resume-signal>
</task>
<task type="auto">
<name>Task 2: Deploy the canonical arena on VPS2 and prove it healthy on the host</name>
<precondition>The hostname and data-seed decision from Task 1 has been given, and `ssh debian@146.59.87.168` succeeds with the pinned key.</precondition>
<reversibility rating="reversible">A single new docker compose project in its own directory on
VPS2 with its own named volume; `docker compose down -v` plus deleting the directory removes it
with no effect on any other service on that host.</reversibility>
<files>/home/archipelago/Projects/botfight/docker-compose.arena.yml, /home/archipelago/Projects/botfight/docs/arena-deployment.md</files>
<read_first>
- `/home/archipelago/Projects/botfight/docker-compose.yml` — the existing service definition to
derive from: container name, port 9100, named volume `botfights-data` mounted at
`/app/server/data`, and the full env list including the payment vars that must NOT be set here.
- `/home/archipelago/Projects/botfight/Dockerfile` — confirms `PORT=9100`, the non-root
`botfights` user, and the built-in HEALTHCHECK hitting `/api/health`.
- `/home/archipelago/Projects/botfight/server/src/middleware/jwt.ts` lines 1-8 — the module
throws at import when `JWT_SECRET` is unset and `NODE_ENV=production`, which is why the host
`.env` is mandatory, not optional.
- `/home/archipelago/Projects/botfight/server/src/middleware/rate-limit.ts` lines 36-52 — why
`TRUSTED_PROXY` must be set on this instance specifically (it sits behind NPM).
- `.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md` "Open Questions" #2 — payments
env vars deliberately left unset.
</read_first>
<action>
Add `docker-compose.arena.yml` to the botfight repo — the canonical-arena counterpart of the
existing dev compose file. It must:
- use `image: localhost:3000/lfg2025/botfights:<tag>` (the Gitea registry runs on this same
host, so no cross-host pull) with **no** `build:` section — the arena runs published images
only, so what it runs is exactly what nodes run;
- start at the currently published tag `1.1.0` (plan 09-05 rolls it to 1.2.0 once that image is
built and pushed) and keep the tag in one place so the roll is a one-line edit;
- bind `9100:9100`, `restart: unless-stopped`, container name `botfights-arena`, named volume
`botfights-arena-data:/app/server/data`;
- set `NODE_ENV=production`, `PORT=9100`, `TRUSTED_PROXY=1`, `FIGHT_LOOP_ENABLED=true`,
`PUBLIC_ARENA_URL=https://<chosen hostname>`, `JWT_SECRET=${JWT_SECRET}` and
`BOTFIGHTS_CREATOR_PUBKEYS=${BOTFIGHTS_CREATOR_PUBKEYS:-da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39}`;
- deliberately omit `ARENA_UPSTREAM_URL` (this instance IS the upstream) and omit every
`BOTFIGHTS_WALLET_ENCRYPTION_KEY` / `BOTFIGHTS_NWC_URL` / `BOTFIGHTS_CASHU_MINT_URL` /
`BOTFIGHTS_DEV_PAYOUT_LNADDRESS` variable, with a comment saying payments are out of scope for
this deployment;
- contain no secret values whatsoever — `JWT_SECRET` comes from a host `.env` file.
On VPS2 (`ssh debian@146.59.87.168`):
1. `sudo ss -tlnp` and confirm 9100 is still free before binding it.
2. Create `/opt/botfights-arena/`, copy `docker-compose.arena.yml` there as
`docker-compose.yml`, and write `/opt/botfights-arena/.env` containing
`JWT_SECRET=$(openssl rand -hex 32)` with mode 0600. Never print that value into the
transcript, a log, or any file under a git repo.
3. `docker compose up -d`, wait for the container's healthcheck, then verify on the host:
`curl -fsS http://127.0.0.1:9100/api/health` returns `{"status":"ok","name":"botfights"}`.
4. Apply the Task 1 data decision. For the seed options, export from archi-dev-box with a
read-only sqlite connection (the node's app is live — never write to its database), copy the
result to VPS2 over ssh, stop the arena container, place the file as the arena volume's
`botfights.db`, restart, and verify the bot count through `GET /api/bots`. For option A do
nothing beyond letting the app create its own database.
5. Confirm the arena is reachable from off-host on the raw port before any DNS exists:
from archi-dev-box, `curl -fsS http://146.59.87.168:9100/api/health`. If that fails, an OVH
or ufw firewall rule is blocking 9100 — record it in the runbook and note that the plain-HTTP
contingency in Task 3 depends on it.
Write `docs/arena-deployment.md` in the botfight repo covering: the host and directory, the
port, how the secret is generated and where it lives, the exact NPM proxy-host field values, the
DNS record, the seed decision that was taken, how to roll the image tag, and how to tear it
down. NPM routing and the host `.env` are not git-tracked — this file is the only record.
</action>
<verify>
<automated>ssh -o BatchMode=yes debian@146.59.87.168 'curl -fsS http://127.0.0.1:9100/api/health' | grep -q '"status":"ok"' && curl -fsS --max-time 10 http://146.59.87.168:9100/api/health | grep -q '"status":"ok"'</automated>
</verify>
<acceptance_criteria>
- `ssh debian@146.59.87.168 'curl -fsS http://127.0.0.1:9100/api/health'` returns the ok payload.
- `ssh debian@146.59.87.168 'sudo docker ps --filter name=botfights-arena --format "{{.Status}}"'` shows `Up` and healthy.
- `ssh debian@146.59.87.168 'stat -c %a /opt/botfights-arena/.env'` prints `600`.
- `ssh debian@146.59.87.168 'sudo docker inspect botfights-arena --format "{{json .Config.Env}}"' | grep -q 'TRUSTED_PROXY=1'` succeeds and the same output contains no `ARENA_UPSTREAM_URL` entry.
- `grep -c 'BOTFIGHTS_WALLET_ENCRYPTION_KEY\|BOTFIGHTS_NWC_URL\|BOTFIGHTS_CASHU_MINT_URL' /home/archipelago/Projects/botfight/docker-compose.arena.yml` counts only commented lines, and no such variable is present in the container's env output.
- `git -C /home/archipelago/Projects/botfight grep -c 'JWT_SECRET=' -- docker-compose.arena.yml` shows only the `${JWT_SECRET}` indirection, never a literal value.
- `test -f /home/archipelago/Projects/botfight/docs/arena-deployment.md` and it contains the chosen hostname, `9100`, and the NPM field values.
- For seed options B/C: `curl -fsS http://146.59.87.168:9100/api/bots` returns the expected number of fighters; the source database on archi-dev-box is unmodified (`mtime` unchanged).
</acceptance_criteria>
<done>A canonical arena container is running and healthy on VPS2 with a host-generated secret, correct proxy-trust settings, no payment configuration, and the agreed starting data.</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 3: Create the arena DNS record and the nginx-proxy-manager host with a Let's Encrypt certificate</name>
<what-built>
The canonical BotFights arena is live on VPS2 at `http://146.59.87.168:9100` and answering
`/api/health`. Two things remain that have no CLI or API path available to Claude: the DNS
record at the registrar, and the nginx-proxy-manager routing + certificate.
</what-built>
<action>
Human-only: create the DNS A record for the arena subdomain at the registrar, and create the
nginx-proxy-manager proxy host + Let's Encrypt certificate that fronts `146.59.87.168:9100`.
Neither has a CLI or credentialed API path available to Claude — the domain is on GoDaddy
nameservers and the NPM admin password is not held by the agent.
</action>
<instructions>
Please do these two things, then reply.
**1. DNS (GoDaddy — `archipelago-foundation.org` uses ns29/ns30.domaincontrol.com).**
Add an `A` record: host = the chosen arena subdomain label (e.g. `arena`), value =
`146.59.87.168`, TTL 600. There is no wildcard record on this domain, so this step cannot be
skipped.
**2. nginx-proxy-manager (http://146.59.87.168:81, admin `lfg2025@proton.me`).**
Hosts → Proxy Hosts → Add Proxy Host, matching the existing entries exactly:
- Domain Names: the full arena hostname
- Scheme: `http`; Forward Hostname/IP: `146.59.87.168`; Forward Port: `9100`
- Enable **Websockets Support** (the live fight stream needs it) and Block Common Exploits
- SSL tab: request a new Let's Encrypt certificate, enable **Force SSL** and HTTP/2, agree to the ToS
If you can instead give Claude the NPM admin password, it can create the proxy host through the
NPM API and only the DNS record needs doing by hand.
**If the DNS record is not possible before the demo**, say so — the fallback is to run the demo
against `http://146.59.87.168:9100` directly. A cloud bot works fine over plain HTTP; the cost
is that credentials cross the internet unencrypted, so it is a demo-only contingency and the
manifest in plan 09-06 would carry the plain-HTTP URL until DNS lands.
</instructions>
<verification>
Once you reply, Claude confirms it automatically:
`dig +short <arena-host> A` returns `146.59.87.168`, and
`curl -fsSI https://<arena-host>/api/health` returns 200 over a valid certificate chain (no
`--insecure`). Both are re-asserted as the precondition of plan 09-05 Task 1, which will not
proceed until they pass.
</verification>
<resume-signal>Reply "dns+proxy done" (or "fallback: plain http", or paste the NPM admin password for Claude to automate the proxy host)</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| public internet → nginx-proxy-manager :443 | TLS termination; the only intended public entrance |
| public internet → 146.59.87.168:9100 | The raw container port, reachable while it is bound to all interfaces |
| VPS2 host filesystem → arena container | `/opt/botfights-arena/.env` carries the JWT signing key |
| arena container → its SQLite volume | The single source of truth for every node's fighters |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-16 | Information disclosure | credentials over plain HTTP on the raw port | high | mitigate | NPM terminates TLS with Force SSL on; the raw port exists for host-level verification and as an explicit, user-acknowledged demo fallback recorded in the runbook |
| T-09-17 | Information disclosure | `JWT_SECRET` leaking into git, logs or `docker inspect` transcripts | high | mitigate | Generated on the host into a 0600 `.env` outside any repo; the compose file only carries `${JWT_SECRET}`; the value is never echoed |
| T-09-18 | Spoofing | forged `X-Forwarded-For` from a direct caller to :9100 bypassing per-IP limits | medium | accept | `TRUSTED_PROXY` is required for the arena to honour those headers and NPM overwrites `X-Real-IP` for traffic through 443; a direct caller can already choose its own source IP cheaply, so this adds no meaningful capability |
| T-09-19 | Denial of service | anonymous bot-registration flooding the public arena | medium | mitigate | Existing `rateLimit(3600_000, 5)` on `POST /api/bots`, now meaningful per real client IP thanks to `TRUSTED_PROXY` |
| T-09-20 | Information disclosure | seeded bot rows publishing nostr pubkeys to the internet | medium | transfer | Surfaced as an explicit decision (Task 1) with the fresh-database option available; the user owns this choice |
| T-09-21 | Tampering | writing to archi-dev-box's live database while exporting a seed | high | mitigate | Export uses a read-only sqlite URI; the acceptance criteria assert the source file is unmodified |
| T-09-22 | Denial of service | binding a port already in use on a shared host | low | mitigate | 9100 verified free during planning and re-checked before binding |
</threat_model>
<verification>
- `ssh debian@146.59.87.168 'curl -fsS http://127.0.0.1:9100/api/health'` — ok payload.
- `curl -fsS http://146.59.87.168:9100/api/health` from archi-dev-box — ok payload (or a recorded
firewall finding).
- After the checkpoint: `curl -fsSI https://<arena-host>/api/health` returns 200 with a valid
certificate chain — this is asserted in plan 09-05 Task 1, which gates the rest of the demo path.
- Supports `09-VALIDATION.md`'s manual-only item "Cross-node fighter visibility" by providing the
shared endpoint that item depends on.
</verification>
<success_criteria>
- One canonical arena container runs on VPS2 with a host-generated secret and no payment config.
- Its compose definition and full runbook are committed to the botfight repo.
- The hostname and starting-data decisions are recorded in the SUMMARY.
- DNS + NPM proxy host + certificate are done, or the plain-HTTP contingency is explicitly accepted.
</success_criteria>
<output>
Create `.planning/phases/09-botfights-platform-upgrade/09-04-SUMMARY.md` when done, recording the
chosen hostname, the seed decision, and whether TLS or the fallback is in effect — plans 09-05,
09-06 and 09-07 all read that hostname from here.
Commit the botfight changes with `git add` by explicit path and `git push origin main`.
Commit the SUMMARY in archy and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,278 @@
---
phase: 09-botfights-platform-upgrade
plan: 05
type: execute
wave: 2
depends_on: [09-01, 09-02, 09-03, 09-04]
files_modified:
- /home/archipelago/Projects/botfight/docker-compose.arena.yml
- /home/archipelago/Projects/botfight/docs/arena-deployment.md
autonomous: true
requirements: [BOT-03, BOT-04]
must_haves:
truths:
- "A botfights:1.2.0 image built from main — carrying the signer-login fix, the unified prompt and the arena proxy — exists in the registry at 146.59.87.168:3000/lfg2025 and is the image the canonical arena runs (D-04/BOT-04)"
- "The public arena serves the unified prompt over its public URL with the arena's own hostname already substituted into it, so a cloud agent needs nothing but that one URL (D-02/BOT-02)"
- "A bot can be registered against the public arena from outside the LAN with a single anonymous POST, and it then appears in the public fighter list"
- "A second botfights instance pointed at the public arena shows that same bot — cross-instance fighter visibility is demonstrated on real hosts before anything is published to the fleet (D-03/BOT-03)"
- "A live fight's SSE event stream arrives incrementally through the public HTTPS path, not buffered by nginx until the fight ends"
artifacts:
- path: /home/archipelago/Projects/botfight/docs/arena-deployment.md
provides: "Runbook updated with the 1.2.0 rollout, the image build/push recipe and the verified public URLs"
contains: "1.2.0"
key_links:
- from: the temporary proxy-mode instance on archi-dev-box
to: the canonical arena on VPS2
via: "ARENA_UPSTREAM_URL pointing at the public arena URL, proving the whole BOT-03 path on real hosts"
pattern: "ARENA_UPSTREAM_URL"
---
<objective>
Build and publish `botfights:1.2.0`, roll the canonical arena onto it, and then prove the entire
BOT-03 claim on real hosts — a bot registered against the public arena is visible through a
*different* instance that is only proxying to it.
Decision IDs map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 signer login,
D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared public arena, D-04 = BOT-04 registry/manifest +
signed catalog (its "build + push new image to the vps2 registry" clause is executed here).
Purpose: everything after this plan — the signed catalog, the node update, the demo — assumes a real
1.2.0 image exists and that proxy mode works across the public internet. This plan is where that
assumption becomes a fact, using a throwaway container rather than the node's installed app, so a
failure costs nothing.
Output: the 1.2.0 image in the registry, the arena running it, and a recorded live cross-instance
proof.
**Repo: `/home/archipelago/Projects/botfight`** for the build; the arena runs on VPS2
(`debian@146.59.87.168`). Commit target: `git push origin main`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md
@.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md
@.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md
@.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md
@.planning/phases/09-botfights-platform-upgrade/09-04-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Confirm the public arena is reachable, then build and push botfights:1.2.0</name>
<precondition>Plan 09-04's human-action checkpoint is complete: either the arena hostname resolves to 146.59.87.168 with a valid certificate, or the plain-HTTP contingency was explicitly accepted and recorded in 09-04-SUMMARY.md.</precondition>
<files>/home/archipelago/Projects/botfight/docs/arena-deployment.md</files>
<read_first>
- `.planning/phases/09-botfights-platform-upgrade/09-04-SUMMARY.md` — the chosen arena hostname
and whether TLS or the plain-HTTP contingency is in effect. Every URL in this plan comes from
there; do not assume a hostname.
- `/home/archipelago/Projects/botfight/Dockerfile` — the four-stage build, the `CACHE_BUST` arg
that forces frontend/server rebuild stages, and the final `node:22-slim` image.
- `/home/archipelago/Projects/archy/scripts/build-bitcoin-image.sh` — the in-repo precedent for
building and pushing to this registry (`podman push --tls-verify=false` against
`146.59.87.168:3000/lfg2025`), including how it handles credentials.
- `~/.claude/projects/-home-archipelago-Projects-archy/memory/reference_ovh_168_mirror.md` — the
Gitea admin account and API token for `146.59.87.168:3000`. Use it for `podman login`; never
copy any token into a tracked file, a plan, a SUMMARY or a commit message.
</read_first>
<action>
First verify the public entrance, because everything downstream depends on it:
`curl -fsSI https://<arena-host>/api/health` (or the plain-HTTP contingency URL) must return 200,
and for the TLS case `curl -fsS https://<arena-host>/api/health` must succeed without
`--insecure`. If it fails, stop and report — do not build on top of a broken public path.
Then build the image from the current `main` of the botfight repo, which now contains the arena
proxy (09-01), the signer-login fix (09-02) and the unified prompt (09-03):
- `cd /home/archipelago/Projects/botfight && git pull --ff-only origin main` first, so the image
contains all three wave-1 plans and not a stale tree; abort if any of the three are missing
(check `server/src/middleware/arena-proxy.ts`, the `/me` route and the `/prompt` route exist).
- `podman build --build-arg CACHE_BUST=$(date +%s) -t 146.59.87.168:3000/lfg2025/botfights:1.2.0 .`
- `podman login 146.59.87.168:3000` with the Gitea credentials from the memory note, then
`podman push --tls-verify=false 146.59.87.168:3000/lfg2025/botfights:1.2.0`.
- Verify the pushed image is real and complete by inspecting it from the registry side:
`skopeo inspect --tls-verify=false docker://146.59.87.168:3000/lfg2025/botfights:1.2.0`
returns a manifest, and record its digest in the SUMMARY.
A build gotcha to expect: the frontend build stage can silently reuse cache and ship a stale
bundle. Confirm the new bundle really contains this phase's work before pushing — run the
container locally on a spare port with no upstream configured and assert that
`/api/docs/prompt` returns 200 and `/api/auth/me` without a token returns 401. Only push after
both hold.
Update `docs/arena-deployment.md` with the exact build+push commands and the recorded digest.
</action>
<verify>
<automated>skopeo inspect --tls-verify=false docker://146.59.87.168:3000/lfg2025/botfights:1.2.0 >/dev/null && echo pushed</automated>
</verify>
<acceptance_criteria>
- `curl -fsS <public arena base>/api/health` (URL taken from 09-04-SUMMARY.md) returns the ok payload; for the TLS case it succeeds without `--insecure`.
- `skopeo inspect --tls-verify=false docker://146.59.87.168:3000/lfg2025/botfights:1.2.0` exits 0 and its digest is recorded in the SUMMARY.
- A local throwaway run of the pushed tag answers 200 on `/api/docs/prompt` and 401 on `/api/auth/me` with no Authorization header.
- `git -C /home/archipelago/Projects/botfight log --oneline -1` shows the tree the image was built from, and that commit is on `origin/main`.
- No registry token, password or JWT secret appears anywhere in `docs/arena-deployment.md` (`grep -Eq '[0-9a-f]{40}' docs/arena-deployment.md` finds nothing).
</acceptance_criteria>
<done>botfights:1.2.0 exists in the registry, is verifiably built from the tree carrying all three wave-1 plans, and the public arena entrance is confirmed working.</done>
</task>
<task type="auto">
<name>Task 2: Roll the canonical arena to 1.2.0 and verify the public contract end-to-end</name>
<reversibility rating="reversible">A compose image-tag change on one host; rolling back is
re-pointing the tag at 1.1.0 and recreating the container — the data volume is untouched.</reversibility>
<files>/home/archipelago/Projects/botfight/docker-compose.arena.yml, /home/archipelago/Projects/botfight/docs/arena-deployment.md</files>
<read_first>
- `/home/archipelago/Projects/botfight/docker-compose.arena.yml` as created by plan 09-04 — the
single place the image tag is written.
- `/home/archipelago/Projects/botfight/server/src/routes/bots.ts` lines 34-110 — the exact
registration request/response so the curl checks assert real fields.
- `/home/archipelago/Projects/botfight/server/src/routes/fights.ts` around lines 410-480 — the
SSE stream route and the `X-Accel-Buffering` header added in 09-01, which is what makes the
stream survive nginx.
</read_first>
<action>
Bump the image tag in `docker-compose.arena.yml` to `1.2.0`, copy the file to
`/opt/botfights-arena/docker-compose.yml` on VPS2, then
`docker compose pull && docker compose up -d` there. The named volume is unchanged, so whatever
starting data was chosen in 09-04 survives the roll.
Then verify the public contract, all through the public URL (never through 127.0.0.1), because
the demo's cloud bot will only ever see this path:
1. `GET /api/health` → ok.
2. `GET /api/docs/prompt` → 200, `text/markdown`, and the body contains the arena's own public
base URL with no unsubstituted template token left in it. This is the single URL a cloud
agent will be handed.
3. `POST /api/bots` with a unique test name → 200 with an id and secret. Record them only in
the working transcript, not in any committed file.
4. `GET /api/bots` → the test bot is present.
5. `GET /api/fights/poll` with that bot's credentials in the `Authorization: Bot id:secret`
form → 200 with `pending:false` (proves bot auth works through the public path).
6. `POST /api/queue/join/<botId>` then `curl -N <base>/api/fights/<fightId>/stream` on the
resulting fight → events must appear progressively while the fight runs. If the whole
payload only lands when the connection closes, nginx is buffering: confirm the response
carries `X-Accel-Buffering: no`, and if it still buffers add `proxy_buffering off;` to the
proxy host's Custom Nginx Configuration in NPM and record that in the runbook.
7. Confirm the JWT secret survived the roll — a container restart with a different secret would
invalidate every existing session: `docker inspect` shows the env var is still sourced from
the same host `.env` (do not print the value).
Update `docs/arena-deployment.md` with the verified public URLs and any NPM custom-config change.
</action>
<verify>
<automated>ARENA=$(grep -Eo 'https?://[a-z0-9.:-]+' /home/archipelago/Projects/botfight/docs/arena-deployment.md | grep -v '146.59.87.168:3000' | head -1); curl -fsS "$ARENA/api/health" | grep -q '"status":"ok"' && curl -fsS "$ARENA/api/docs/prompt" | grep -q '/api/bots' && curl -fsS "$ARENA/api/docs/prompt" | grep -qv '{{ARENA_URL}}'</automated>
</verify>
<acceptance_criteria>
- `ssh debian@146.59.87.168 'sudo docker inspect botfights-arena --format "{{.Config.Image}}"'` ends in `:1.2.0` and the container is healthy.
- `curl -fsS <arena>/api/docs/prompt` returns markdown containing the arena's own public base URL and no unsubstituted `{{ARENA_URL}}` token.
- A test bot registered through the public URL appears in `GET /api/bots` and authenticates against `GET /api/fights/poll`.
- An SSE stream on a real fight delivers at least two events more than one second apart (evidence of incremental delivery) — the timing observation is recorded in the SUMMARY.
- `GET /api/auth/me` with no token returns 401 through the public URL.
- The arena's data from 09-04 is still present after the roll (fighter count unchanged or grown).
</acceptance_criteria>
<done>The public arena runs 1.2.0 and demonstrably serves the prompt, registration, bot auth and live streaming over its public URL.</done>
</task>
<task type="auto">
<name>Task 3: Prove cross-instance fighter visibility with a real second instance</name>
<reversibility rating="reversible">A throwaway podman container on a spare port that touches
nothing the installed BotFights app owns; removing it is `podman rm -f`.</reversibility>
<files>/home/archipelago/Projects/botfight/docs/arena-deployment.md</files>
<read_first>
- `/home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts` as built in 09-01 —
in particular which paths bypass the proxy and how the upstream base URL is read.
- `.planning/phases/09-botfights-platform-upgrade/09-01-SUMMARY.md` — any deviation from the
planned proxy behaviour that this live test should account for.
- The output of `podman ps --filter name=botfights` on archi-dev-box — the installed app runs on
host port 9100; the throwaway instance must use a different port and must not be given the
installed app's volume.
</read_first>
<action>
On archi-dev-box, run a temporary second instance in proxy mode:
`podman run --rm -d --name botfights-proxytest -p 9101:9100 -e NODE_ENV=production
-e JWT_SECRET=$(openssl rand -hex 32) -e ARENA_UPSTREAM_URL=<public arena base>
146.59.87.168:3000/lfg2025/botfights:1.2.0`
with no volume mount at all — it must have no local database worth reading, which is exactly the
point: everything it shows has to come from the arena.
Then assert the BOT-03 claim:
1. `curl -fsS http://127.0.0.1:9101/api/health` → ok, and confirm from the arena's logs that this
request did NOT reach it (health is answered locally by design).
2. `curl -fsS http://127.0.0.1:9101/api/bots` → contains the test bot registered against the
public arena in Task 2. A fighter registered on one host is visible through another instance
that never stored it.
3. Register a second bot through the proxy instance (`POST http://127.0.0.1:9101/api/bots`) and
assert it appears in `GET <public arena>/api/bots` — the reverse direction.
4. Open an SSE stream through the proxy instance on a live fight and confirm incremental events.
5. Stop the arena container briefly and confirm the proxy instance answers 502 with a JSON error
on `/api/bots` while `/api/health` still returns ok; restart the arena and confirm recovery.
Keep the outage to seconds and do it before any human is testing against the arena.
6. `podman rm -f botfights-proxytest` when finished, and confirm the installed BotFights app on
port 9100 was untouched throughout (still running its original container id).
Record the observed evidence for each step in `docs/arena-deployment.md` under a
"verified cross-instance behaviour" heading, and in the SUMMARY.
</action>
<verify>
<automated>podman run --rm -d --name botfights-proxytest -p 9101:9100 -e NODE_ENV=production -e JWT_SECRET=$(openssl rand -hex 32) -e ARENA_UPSTREAM_URL="$(grep -Eo 'https?://[a-z0-9.:-]+' /home/archipelago/Projects/botfight/docs/arena-deployment.md | grep -v '146.59.87.168:3000' | head -1)" 146.59.87.168:3000/lfg2025/botfights:1.2.0 >/dev/null && sleep 12 && curl -fsS http://127.0.0.1:9101/api/health | grep -q '"status":"ok"' && curl -fsS http://127.0.0.1:9101/api/bots | grep -q '"name"' && podman rm -f botfights-proxytest >/dev/null && echo federation-proof-ok</automated>
</verify>
<acceptance_criteria>
- The throwaway proxy-mode instance, started with no volume, lists the fighters that live in the arena's database.
- A bot registered through the proxy instance is visible in the arena's own `GET /api/bots`.
- `/api/health` on the proxy instance returns ok while the arena is stopped, and `/api/bots` returns 502 with a JSON `error` key during that window.
- An SSE stream through the proxy instance delivers events incrementally.
- `podman ps --filter name=botfights --format "{{.Names}} {{.Status}}"` shows the installed `botfights` app still up with its original container id, and no `botfights-proxytest` container remains.
- `docs/arena-deployment.md` contains a "verified cross-instance behaviour" section with the observed evidence.
</acceptance_criteria>
<done>The BOT-03 architecture is proven on real hosts across the public internet, before anything is published to the fleet.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| build host → container registry | A pushed image becomes what every node will run |
| public internet → the canonical arena | Anonymous registration, bot auth and streaming |
| throwaway proxy instance → arena | The same node→arena hop nodes will use, exercised deliberately |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-23 | Tampering | a stale or partial image published as 1.2.0 | high | mitigate | The image is smoke-tested locally for the new routes before push, its digest is recorded, and the build commit must be on `origin/main` (Task 1) |
| T-09-24 | Information disclosure | registry credentials leaking into the repo or a SUMMARY | high | mitigate | Credentials are read from the agent's infra memory note and used only for `podman login`; an acceptance criterion greps the runbook for token-shaped strings |
| T-09-25 | Denial of service | the deliberate arena outage in Task 3 hitting real users | low | mitigate | Seconds-long, performed before human testing begins, and the recovery is asserted |
| T-09-26 | Tampering | the throwaway test container disturbing the installed app's data | high | mitigate | It runs on a different port with no volume mount; an acceptance criterion asserts the installed container is untouched |
| T-09-27 | Information disclosure | test bot secrets committed to the repo | medium | mitigate | Test credentials stay in the transcript; nothing generated in this plan is written to a tracked file |
</threat_model>
<verification>
- `curl -fsS <arena>/api/health`, `/api/docs/prompt`, `/api/bots` over the public URL — all pass.
- A registered test bot round-trips through both the arena directly and a proxy-mode instance.
- SSE delivers incrementally through the public HTTPS path and through the proxy.
- Directly satisfies the `09-VALIDATION.md` manual-only item "Cross-node fighter visibility: bot
registered via VPS2 public arena appears on another instance" ahead of the human demo rehearsal.
</verification>
<success_criteria>
- `146.59.87.168:3000/lfg2025/botfights:1.2.0` exists, is verified, and its digest is recorded.
- The canonical arena runs 1.2.0 and serves the full public contract.
- Cross-instance fighter visibility is demonstrated on real hosts, in both directions, with a
documented degradation path when the arena is unreachable.
</success_criteria>
<output>
Create `.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md` when done, recording the
image digest, the verified public URLs and the cross-instance evidence — plan 09-06 writes that
image tag and arena URL into the app manifest.
Commit the botfight changes with `git add` by explicit path and `git push origin main`.
Commit the SUMMARY in archy and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,319 @@
---
phase: 09-botfights-platform-upgrade
plan: 06
type: execute
wave: 3
depends_on: [09-05]
files_modified:
- apps/botfights/manifest.yml
- app-catalog/catalog.json
- scripts/image-versions.sh
- releases/app-catalog.json
autonomous: false
requirements: [BOT-04]
must_haves:
truths:
- "A fresh BotFights install on any node starts successfully because the manifest declares JWT_SECRET as a generated secret — without it the 1.2.0 image throws at import and crash-loops (D-04/BOT-04)"
- "Each node gets its own JWT signing secret, materialised 0600 by the orchestrator, never hardcoded and never shared between nodes"
- "Node instances point at the shared public arena by default via ARENA_UPSTREAM_URL in the manifest, with a documented way for an operator to unset it and run standalone (D-03/BOT-03)"
- "Both catalog files and the image-version map name the same 1.2.0 image, so the drift checker is clean"
- "The regenerated releases/app-catalog.json embeds the new manifest and is signed by the release-root key before it is published"
artifacts:
- path: apps/botfights/manifest.yml
provides: "BotFights 1.2.0 manifest with generated JWT secret, secret_env injection and default-on arena federation"
contains: "botfights-jwt-secret"
- path: releases/app-catalog.json
provides: "Regenerated, release-root-signed catalog carrying the embedded 1.2.0 manifest"
contains: "botfights"
key_links:
- from: apps/botfights/manifest.yml
to: the orchestrator's secrets provider
via: "generated_secrets (kind hex32) materialises /var/lib/archipelago/secrets/botfights-jwt-secret and secret_env injects it as JWT_SECRET"
pattern: "secret_env"
- from: releases/app-catalog.json
to: every node's container::app_catalog fetch
via: "the catalog manifest overlay wins over the on-disk manifest, so this file is what actually takes effect on nodes"
pattern: "manifest"
---
<objective>
Deliver D-04/BOT-04 in the archy repo: bump the BotFights app manifest to 1.2.0, give it the
generated `JWT_SECRET` it now cannot start without, turn on shared-arena federation by default,
regenerate the signed catalog, have the release key sign it, and publish.
Decision IDs map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 signer login,
D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared public arena (its default-on clause is
implemented here), D-04 = BOT-04 registry/manifest + signed catalog updated and republished.
Purpose: the catalog manifest overlay is authoritative on nodes — editing a manifest on disk does
nothing for a catalog-covered app. So this plan is the only way the fleet ever sees 1.2.0. It also
carries a blocking correctness fix: `server/src/middleware/jwt.ts` throws at module import when
`JWT_SECRET` is unset and `NODE_ENV=production`, and today's manifest sets `NODE_ENV=production`
with no `JWT_SECRET` — publishing 1.2.0 without the generated secret would crash-loop every fresh
install.
**Repo: `/home/archipelago/Projects/archy`.** Commit target: `git push gitea-ai main` (main is
protected; the `ai` account is the push path), plus the vps2 mirror that serves the catalog.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md
@.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md
@.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md
@.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md
@.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md
@apps/botfights/manifest.yml
@apps/netbird-server/manifest.yml
</context>
<tasks>
<task type="auto">
<name>Task 1: Manifest 1.2.0 — generated JWT secret, default-on arena, both catalogs in lockstep</name>
<reversibility rating="costly">Publishing this manifest switches every node's BotFights to the
shared arena by default, and node-local fighter data stops being read (it is preserved on disk,
never deleted). Mechanically revertible by republishing a catalog without `ARENA_UPSTREAM_URL`,
but any bots users register on the public arena in the meantime stay there. The blocking human
gate in Task 2 is where that trade is accepted or refused.</reversibility>
<files>apps/botfights/manifest.yml, app-catalog/catalog.json, scripts/image-versions.sh, releases/app-catalog.json</files>
<read_first>
- `apps/botfights/manifest.yml` — all 76 lines. Only `app.version`, `container.image`,
`container.generated_secrets`, `container.secret_env` and `environment` change; the security,
ports, volumes, health_check, interfaces and metadata blocks stay exactly as they are.
- `apps/netbird-server/manifest.yml` lines 17-32 — the established `generated_secrets` shape
(`- name: <file>` / `kind: <kind>`), with its comment explaining why netbird needs `base64`.
- `apps/fedimint-clientd/manifest.yml` lines 24-27 and `apps/barkd/manifest.yml` lines 24-26 —
the `secret_env` shape (`- key: <ENV>` / `secret_file: <file>`).
- `core/container/src/manifest.rs` lines 358-400 — `SecretGenKind` (`hex16`, `hex32`, `base64`,
`bcrypt`) and `GeneratedSecret::target_files`. `hex32` is 32 random bytes as 64 lowercase hex
chars, which is exactly what the app's own docs tell an operator to generate.
- `app-catalog/catalog.json` lines 109-133 — the legacy hand-maintained `botfights` entry
(`version`, `dockerImage`, `containerConfig.env`). This file is separate from
`releases/app-catalog.json` and is what `scripts/check-app-catalog-drift.py` compares against.
- `scripts/image-versions.sh` line 93 — `BOTFIGHTS_IMAGE`.
- `scripts/generate-app-catalog.sh` — the `EMBED_MANIFESTS` loop that reads every
`apps/*/manifest.yml` and embeds the whole document under each entry's `manifest` key;
`releases/app-catalog.json` is a build output and is never hand-edited.
- `.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md` — the verified public arena
URL and the 1.2.0 image digest.
</read_first>
<action>
Edit `apps/botfights/manifest.yml` (the current values are in the file — read it first):
- `app.version`: set to `1.2.0`.
- `container.image`: set the tag to `1.2.0`, same registry path. No occurrence of the previous
tag may survive anywhere in this file.
- Add under `container`, following the netbird/fedimint-clientd shapes:
`generated_secrets` with one entry named `botfights-jwt-secret` of kind `hex32`, and
`secret_env` with one entry mapping key `JWT_SECRET` to secret_file `botfights-jwt-secret`.
Use `hex32`, not `base64` — the app uses the value directly as an HMAC key with no decode
step, so copying netbird's kind verbatim would be wrong. Add a comment naming the concrete
failure this prevents: the auth module throws at import when this variable is absent under
`NODE_ENV=production`, so a fresh install without it never starts.
- Extend `environment` (keeping `NODE_ENV=production`) with:
`PORT=9100`, `ARENA_UPSTREAM_URL=<the arena base URL from 09-05-SUMMARY.md>`, and a comment
recording that an operator may remove `ARENA_UPSTREAM_URL` to run a standalone, node-local
arena. Do not add any wallet/payments variable and do not add `FIGHT_LOOP_ENABLED` — in proxy
mode a node-local fight loop would write to a database nothing reads.
- Leave every other block byte-identical.
Edit `app-catalog/catalog.json`'s `botfights` entry: `version``1.2.0`, `dockerImage` tag →
`1.2.0`. Leave its `containerConfig` block otherwise as-is — this legacy file is not what nodes
install from, but drift here trips the checker.
Edit `scripts/image-versions.sh`: `BOTFIGHTS_IMAGE` tag → `1.2.0`.
Regenerate the signed catalog input: `scripts/generate-app-catalog.sh` (EMBED_MANIFESTS defaults
on). Then confirm by reading `releases/app-catalog.json` that the `botfights` entry's `version`
is `1.2.0` and its embedded `manifest` carries the image tag, the generated secret, the
secret_env mapping and the arena URL.
Build the signer binary now so the human ceremony in Task 2 is not blocked waiting on a compile:
`scripts/sign-catalog.sh` refuses to compile anything itself and needs
`/tmp/archy-sign-bin/release/archipelago` to exist. Build the release binary and place it there
(background the build; if it hits `rust-lld: undefined hidden symbol`, that is incremental-cache
corruption — rebuild with `CARGO_INCREMENTAL=0`).
Finally, prepare the impact briefing the human gate needs, and put it in the SUMMARY: the number
of bots and fights currently held in each reachable node's local BotFights database (on
archi-dev-box, read `/var/lib/archipelago/botfights/botfights.db` with a read-only sqlite
connection — planning measured 115 bots and 102,440 fights), what happens to them under proxy
mode (preserved on disk, no longer displayed), and how an operator reverts a single node.
</action>
<verify>
<automated>cd /home/archipelago/Projects/archy && python3 scripts/check-app-catalog-drift.py && cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests</automated>
</verify>
<acceptance_criteria>
- `python3 scripts/check-app-catalog-drift.py` reports no drift for `botfights`.
- `cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests` exits 0 (the new manifest deserializes and validates as an overlay).
- `cd core && cargo test -p container manifest` exits 0.
- `grep -q 'botfights-jwt-secret' apps/botfights/manifest.yml` and `grep -q 'kind: hex32' apps/botfights/manifest.yml` both succeed.
- `grep -q 'JWT_SECRET' apps/botfights/manifest.yml` succeeds and no line in that file assigns it a literal value (`grep -Eq 'JWT_SECRET=' apps/botfights/manifest.yml` finds nothing).
- `grep -c '1.2.0' apps/botfights/manifest.yml` is at least 2; `grep -c '1.1.0' apps/botfights/manifest.yml` is 0.
- `grep -q 'ARENA_UPSTREAM_URL' apps/botfights/manifest.yml` succeeds and the value matches the arena URL recorded in `09-05-SUMMARY.md`.
- `python3 -c "import json;d=json.load(open('releases/app-catalog.json'));b=d['apps']['botfights'];m=b['manifest']['app'];assert b['version']=='1.2.0';assert m['container']['image'].endswith(':1.2.0');assert any(s['name']=='botfights-jwt-secret' for s in m['container']['generated_secrets']);assert any(e['key']=='JWT_SECRET' for e in m['container']['secret_env']);assert any('ARENA_UPSTREAM_URL' in e for e in m['environment']);print('catalog ok')"` prints `catalog ok`.
- `test -x /tmp/archy-sign-bin/release/archipelago` succeeds.
- `git -C /home/archipelago/Projects/archy status --short` shows only the four intended files changed (another agent shares this tree — stage by explicit path).
</acceptance_criteria>
<done>The manifest, both catalog files and the image map all describe BotFights 1.2.0 with a per-install JWT secret and default-on arena federation, and the regenerated catalog proves it.</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 2: Sign the regenerated catalog with the release master mnemonic (fleet-impact gate)</name>
<what-built>
`releases/app-catalog.json` has been regenerated with BotFights 1.2.0 embedded: a per-install
generated `JWT_SECRET`, and `ARENA_UPSTREAM_URL` pointing at the public arena. The signer binary
is built and waiting. Nothing has been pushed yet.
**What signing and publishing this changes, fleet-wide:**
- Every node that refreshes its catalog sees BotFights 1.2.0 and can update to it.
- Updated nodes become thin clients of the shared public arena: all nodes see all fighters, and
fights cross nodes. That is the point of this phase (D-03).
- Each node's own BotFights database stops being read. Nothing is deleted — the file stays at
`/var/lib/archipelago/botfights/` — but locally-registered fighters no longer appear in the
UI. On archi-dev-box that is 115 bots and 102,440 fights (see the SUMMARY for the current
count on every reachable node, and whether they were seeded into the arena in plan 09-04).
- Each node generates its own JWT secret on next install/update, so existing browser sessions on
that node are signed out once.
- Reverting means regenerating and re-signing a catalog without `ARENA_UPSTREAM_URL`; bots that
users register on the public arena in the meantime stay on the public arena.
A single node can opt out at any time by removing `ARENA_UPSTREAM_URL` from its container env.
</what-built>
<action>
Human-only: run the signing ceremony and enter the 24-word release master mnemonic at your own
terminal. The mnemonic never passes through Claude, is never stored, and is never scripted
around. This is also the approval gate for switching the fleet's BotFights to the shared arena.
</action>
<instructions>
If you are happy to publish, run the ceremony:
```
bash /home/archipelago/Projects/archy/scripts/sign-catalog.sh
```
Paste your 24-word release master mnemonic, press Enter, then Ctrl-D. The script signs
`releases/app-catalog.json` in place and checks the signature was made by the expected
release-root key. Your mnemonic is read from the terminal only — never stored, never passed to
Claude. The script prints either a success line or a clear failure; if it fails, do not commit —
say so and Claude will investigate.
If you would rather not switch the fleet to the shared arena yet, say "hold federation" and
Claude will regenerate the catalog with `ARENA_UPSTREAM_URL` omitted (nodes stay standalone,
opt-in per node) before you sign anything.
</instructions>
<verification>
The script prints `✅ SUCCESS — catalog signed by the correct release-root key` on success.
Claude then re-checks independently in Task 3: `releases/app-catalog.json` carries a non-empty
`signature` and `signed_by` equal to `did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur`,
and the `botfights` entry is still 1.2.0 with its embedded manifest intact.
</verification>
<resume-signal>Reply "signed" once the script reports success, or "hold federation", or paste the failure output</resume-signal>
</task>
<task type="auto">
<name>Task 3: Verify the signature, commit both repos' work and publish the catalog</name>
<precondition>The signing ceremony in Task 2 reported success and `releases/app-catalog.json` now carries a `signature` and the expected `signed_by` DID.</precondition>
<files>releases/app-catalog.json, apps/botfights/manifest.yml, app-catalog/catalog.json, scripts/image-versions.sh</files>
<read_first>
- `scripts/sign-catalog.sh` lines 30-40 — the exact success condition it checks
(`"signed_by": "did:key:z6Mkkid…"` present alongside a `signature` key); re-assert it
independently rather than trusting the reply.
- `core/archipelago/src/container/app_catalog.rs` lines 403-445 — how a node verifies the
fetched catalog: an absent signature is accepted during the migration window, but a present
signature that fails verification is a hard reject. A malformed publish would take BotFights
(and every other app entry) off the fleet's catalog, so publishing a broken signature is worse
than publishing none.
- `core/archipelago/src/container/app_catalog.rs` line ~572 — the raw URL nodes fetch:
`http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json`. That is
the vps2 Gitea, so the publish is not complete until `main` is on that remote.
- `CLAUDE.md` "Commit & push every unit of work" — stage by explicit path (another agent shares
this tree), never `git add -A`, and `main` is protected so pushes go through `gitea-ai`.
</read_first>
<action>
Verify the signature independently of the script's own report: confirm
`releases/app-catalog.json` contains a non-empty `signature` and a `signed_by` equal to the
expected release-root DID, that the file is still valid JSON, and that the `botfights` entry is
still 1.2.0 with its embedded manifest intact (signing must not have altered the payload).
Commit the four archy files in one focused commit, staged by explicit path, with a message
describing the BotFights 1.2.0 catalog publish and the generated-secret fix, ending with the
`Co-Authored-By: Claude …` trailer. Push with `git push gitea-ai main`.
Publish: ensure the same `main` lands on the vps2 Gitea that nodes fetch the catalog from, then
confirm from off-host that the published bytes are the signed ones —
`curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json`
must return JSON whose `botfights` version is `1.2.0` and whose `signature` matches the local
file's. If a remote's token is stale (a known recurring issue with the vps2/local-origin
remotes), report it rather than improvising a workaround, and do not leave the catalog published
to some mirrors and not others without saying so explicitly in the SUMMARY.
Do not commit any secret: no registry token, no mnemonic, no JWT value. Verify the diff before
committing.
</action>
<verify>
<automated>cd /home/archipelago/Projects/archy && python3 -c "import json;d=json.load(open('releases/app-catalog.json'));assert d.get('signature');assert d.get('signed_by')=='did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur';assert d['apps']['botfights']['version']=='1.2.0';print('signed ok')" && curl -fsS --max-time 20 http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json | python3 -c "import json,sys;d=json.load(sys.stdin);assert d['apps']['botfights']['version']=='1.2.0';assert d.get('signature');print('published ok')"</automated>
</verify>
<acceptance_criteria>
- The local `releases/app-catalog.json` has a non-empty `signature` and the expected `signed_by` DID, and its `botfights` entry is 1.2.0 with the embedded manifest intact.
- `curl` of the vps2 raw catalog URL returns the same signed content with `botfights` at 1.2.0.
- `git -C /home/archipelago/Projects/archy log --oneline -1` shows the publish commit, and `git status --short` shows no leftover staged changes from this plan.
- `git -C /home/archipelago/Projects/archy show --stat HEAD` lists exactly the four intended files.
- `git -C /home/archipelago/Projects/archy show HEAD | grep -Eic '(mnemonic|BEGIN [A-Z ]*PRIVATE KEY)'` is 0.
</acceptance_criteria>
<done>The signed 1.2.0 catalog is live at the URL nodes fetch, verified from off-host.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| release-root key (human, offline) → catalog | The only authority that makes a catalog trustworthy to nodes |
| published catalog → every node's orchestrator | A catalog entry decides which image a node runs and with what env |
| orchestrator secrets provider → container env | `JWT_SECRET` is materialised and injected without ever passing through a manifest literal |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-28 | Denial of service | fresh 1.2.0 installs crash-looping on a missing `JWT_SECRET` | critical | mitigate | `generated_secrets` (`hex32`) + `secret_env` in the manifest, asserted by both the catalog JSON check and the manifest-overlay test (Task 1) |
| T-09-29 | Spoofing | a tampering mirror serving an altered catalog | high | mitigate | Release-root signature over the raw JSON; nodes hard-reject a present-but-invalid signature. The signature is verified locally and again from the published URL (Tasks 2-3) |
| T-09-30 | Information disclosure | a shared or hardcoded JWT secret across the fleet | high | mitigate | Per-install generation by the orchestrator, written 0600 and owned by the rootless service user; no literal value anywhere in git |
| T-09-31 | Elevation of privilege | the mnemonic passing through the agent | critical | mitigate | The ceremony is a human-only TTY step in `scripts/sign-catalog.sh`; the plan never scripts around it and the commit is grepped for key material |
| T-09-32 | Tampering | publishing a catalog whose signature does not match its payload | high | mitigate | Independent post-ceremony verification plus a published-bytes check from off-host (Task 3) |
| T-09-33 | Repudiation | node-local fighter data becoming invisible without the operator knowing | medium | mitigate | The blocking gate states the exact counts and the revert path; no data is deleted |
</threat_model>
<verification>
- `python3 scripts/check-app-catalog-drift.py` — clean.
- `cd core && cargo test -p archipelago catalog_overlay_accepts_all_real_image_manifests && cargo test -p container manifest` — green.
- The published catalog at the vps2 raw URL carries a valid release-root signature and BotFights 1.2.0.
- Maps to `09-VALIDATION.md` row "BOT-04 | crash-loop | manifest declares JWT_SECRET via
generated_secrets | unit (archy) | `cd core && cargo test -p container manifest`", upgrading it
from "partial" with the added catalog-content assertion.
</verification>
<success_criteria>
- BotFights 1.2.0 is described identically by the manifest, both catalog files and the image map.
- A fresh install cannot crash-loop for want of a JWT secret, and no two nodes share one.
- Node instances default to the shared public arena, with a documented per-node opt-out.
- The catalog is signed by the release-root key and published where nodes fetch it.
</success_criteria>
<output>
Create `.planning/phases/09-botfights-platform-upgrade/09-06-SUMMARY.md` when done, recording the
per-node local bot/fight counts that were surfaced at the gate, the decision taken there, and the
published catalog URL.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>

View File

@ -0,0 +1,238 @@
---
phase: 09-botfights-platform-upgrade
plan: 07
type: execute
wave: 4
depends_on: [09-06]
files_modified:
- .planning/phases/09-botfights-platform-upgrade/09-DEMO-CHECKLIST.md
autonomous: false
requirements: [BOT-01, BOT-02, BOT-03, BOT-04]
must_haves:
truths:
- "archi-dev-box runs BotFights 1.2.0 installed through the normal signed-catalog path — not hand-placed — with its JWT secret injected from the orchestrator's secret store rather than a plaintext env value (D-04/BOT-04)"
- "The BotFights UI on archi-dev-box shows fighters that live in the VPS2 arena, and a fight started there is visible from the arena (D-03/BOT-03)"
- "A real nostr signer (NIP-07 browser extension) logs a human in on archi-dev-box, and the session survives a page reload without any bare-pubkey request (D-01/BOT-01)"
- "A cloud-hosted AI agent builds a working bot from the single prompt URL alone, registers, and fights — with no other document (D-02/BOT-02)"
- "The demo path is written down as a checklist so it can be re-run on demo day without re-deriving anything"
artifacts:
- path: .planning/phases/09-botfights-platform-upgrade/09-DEMO-CHECKLIST.md
provides: "The exact demo-day run sheet: URLs, click path, expected results, and the fallback if the arena is unreachable"
contains: "arena"
key_links:
- from: archi-dev-box's installed botfights container
to: the canonical VPS2 arena
via: "ARENA_UPSTREAM_URL delivered by the signed catalog's embedded manifest, verified in the running container's env"
pattern: "ARENA_UPSTREAM_URL"
---
<objective>
Land the phase where it has to land: BotFights 1.2.0 installed on archi-dev-box through the real
signed-catalog path, then the three things only a human can confirm — a real signer login, real
cross-node fighter visibility, and a cloud bot built from the prompt alone.
Decision IDs map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 signer login,
D-02 = BOT-02 unified prompt, D-03 = BOT-03 shared public arena, D-04 = BOT-04 signed catalog.
The hard deadline in `09-CONTEXT.md` (demo on 2026-07-31 from archi-dev-box with a cloud "openclaw"
bot) is what this plan exists to satisfy.
Purpose: every prior plan proved a component. This one proves the product, on the machine the demo
runs from, through the path a real user takes. `09-RESEARCH.md` Pitfall 5 is explicit that the
Playwright suite never drives a real `window.nostr`, so green tests are not evidence here — a human
with an extension is.
Output: a verified archi-dev-box install, three human-confirmed checks, and a demo run sheet.
**Host: archi-dev-box (this machine).** `x250-dev` was offline during planning — if it is up, repeat
Task 1 there to satisfy the standing dev-pair rule and record the result.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md
@.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md
@.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md
@.planning/phases/09-botfights-platform-upgrade/09-04-SUMMARY.md
@.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md
@.planning/phases/09-botfights-platform-upgrade/09-06-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Update archi-dev-box to 1.2.0 through the signed catalog and verify the real install</name>
<precondition>The signed catalog carrying BotFights 1.2.0 is published and reachable at the vps2 raw URL (plan 09-06 Task 3 verified this), and the archipelago service on archi-dev-box can be running — it was inactive at planning time.</precondition>
<reversibility rating="costly">Updating the installed app switches this node's BotFights to proxy
mode; its local database (115 bots, 102,440 fights) is preserved on disk but no longer displayed.
Reverting a single node means removing `ARENA_UPSTREAM_URL` from the container env; reverting the
fleet means republishing the catalog (plan 09-06).</reversibility>
<files>.planning/phases/09-botfights-platform-upgrade/09-DEMO-CHECKLIST.md</files>
<read_first>
- `.planning/phases/09-botfights-platform-upgrade/09-06-SUMMARY.md` — the published catalog URL
and the gate decision (federated by default, or held).
- `.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md` — the verified arena URL and
the 1.2.0 image digest to compare the node's pulled image against.
- `core/archipelago/src/container/app_catalog.rs` lines 326-400 — `refresh_catalog` and how
catalog URLs are derived from the configured update mirrors, plus the on-disk cache at
`<data_dir>/app-catalog.json`; this is what must be refreshed before the node can see 1.2.0.
- `core/archipelago/src/container/prod_orchestrator.rs` lines 1385-1470 — `load_manifests` and
the catalog overlay: the catalog's embedded manifest wins over the on-disk one, which is why
the update must come from the catalog and not from editing `/opt/archipelago/apps/botfights/`.
- `CLAUDE.md` "Don't deploy via systemctl until Quadlet" and the deploy-to-dev-pair rule.
- The current state to compare against: `podman inspect botfights` today shows a plaintext
`JWT_SECRET` env value, `ARCHY_EMBEDDED=1` and `FIGHT_LOOP_ENABLED=true` from the pre-1.2.0
install — after the update the secret must come from the orchestrator's secret store instead.
</read_first>
<action>
Bring archi-dev-box's node to 1.2.0 through the supported path:
1. Ensure the archipelago service is running (it was inactive at planning time). Start it and
confirm it is healthy before touching apps.
2. Force a catalog refresh so the node picks up the newly published signed catalog, then confirm
`/var/lib/archipelago/app-catalog.json` now shows `botfights` at 1.2.0 with a valid
`signed_by`, and that the log line recording release-root signature verification appears.
3. Update the BotFights app through the orchestrator's normal app-update path (the same one the
UI's per-app Update button drives). Do not hand-create the container and do not edit the
on-disk manifest — a catalog-covered app ignores disk edits, and hand-created containers are
exactly what this phase is moving away from.
4. Verify the resulting container:
- image tag `1.2.0` and a digest matching the one recorded in `09-05-SUMMARY.md`;
- `ARENA_UPSTREAM_URL` present and equal to the arena URL;
- `JWT_SECRET` delivered as a podman secret reference rather than a plaintext env value, and
`/var/lib/archipelago/secrets/botfights-jwt-secret` existing with mode 0600 and 64 hex
characters of content (check the mode and length; do not print the value);
- container healthy, `curl -fsS http://127.0.0.1:9100/api/health` ok;
- `curl -fsS http://127.0.0.1:9100/api/bots` returns the arena's fighters (proxy mode live);
- `curl -fsS http://127.0.0.1:9100/api/docs/prompt` returns the unified prompt.
5. Confirm the node's own database file is intact and untouched at
`/var/lib/archipelago/botfights/botfights.db` (same size/mtime class as before — data is
preserved, just not read).
6. If `x250-dev` is reachable, repeat steps 1-4 there and record the outcome; if it is still
offline, record that the dev-pair rule was satisfied on one node only and why.
Write `.planning/phases/09-botfights-platform-upgrade/09-DEMO-CHECKLIST.md`: the demo-day run
sheet. It must contain the arena URL, the node UI URL, the prompt URL to hand the cloud agent,
the exact click path for the signer login, the expected visible results at each step, and the
fallback if the arena is unreachable on the day (a node can be switched back to standalone by
removing one env var — state the command).
</action>
<verify>
<automated>podman inspect botfights --format '{{.Config.Image}}' | grep -q ':1.2.0' && podman inspect botfights --format '{{json .Config.Env}}' | grep -q 'ARENA_UPSTREAM_URL' && curl -fsS http://127.0.0.1:9100/api/health | grep -q '"status":"ok"' && curl -fsS http://127.0.0.1:9100/api/docs/prompt | grep -q '/api/bots' && test -f /var/lib/archipelago/botfights/botfights.db</automated>
</verify>
<acceptance_criteria>
- `podman inspect botfights --format '{{.Config.Image}}'` ends in `:1.2.0` and the container reports healthy.
- `podman inspect botfights --format '{{json .Config.Env}}'` contains `ARENA_UPSTREAM_URL` and does NOT contain a plaintext `JWT_SECRET=` assignment; `stat -c %a /var/lib/archipelago/secrets/botfights-jwt-secret` prints `600` and its content length is 64.
- `curl -fsS http://127.0.0.1:9100/api/bots` returns the same fighter set as `curl -fsS <arena>/api/bots`.
- `curl -fsS http://127.0.0.1:9100/api/docs/prompt` returns the unified prompt with the arena URL substituted.
- `/var/lib/archipelago/app-catalog.json` shows `botfights` at 1.2.0 with the expected `signed_by`.
- `/var/lib/archipelago/botfights/botfights.db` still exists with its pre-update row counts (verified read-only).
- `09-DEMO-CHECKLIST.md` exists and names the arena URL, the prompt URL and the standalone-fallback command.
</acceptance_criteria>
<done>The demo machine runs 1.2.0, installed the way every user installs it, proxying to the shared arena with a per-install secret.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Real nostr signer login and live arena fighters on archi-dev-box</name>
<what-built>
BotFights 1.2.0 is installed on archi-dev-box and is a thin client of the public arena. Two
things need a human: a real nostr signer (no automated harness exists — there is no
`window.nostr` mock anywhere in the Playwright suite), and a visual confirmation that the
fighters shown here really are the arena's.
</what-built>
<how-to-verify>
Open the BotFights UI on archi-dev-box (the URL is in `09-DEMO-CHECKLIST.md`).
**1. Signer login (D-01).** With a NIP-07 extension installed (nos2x or Alby), click Sign In and
approve the signature request in the extension. Expect: you are signed in, your bot/profile
appears, and the extension prompted you to sign an event — you were never asked for a private
key. Then reload the page: you should still be signed in without a second signature prompt.
Optional but valuable: repeat from an Android phone using Amber (NIP-55) against the same URL.
**2. Shared arena (D-03).** The fighter list / leaderboard here should show the same fighters as
the public arena URL open in a second tab. Pick any fighter visible only on the arena and confirm
it is listed here too. Start a fight and confirm the rounds appear live (not all at once at the
end).
Report anything that looks wrong — a spinner that never resolves, an empty list, a login that
silently fails, rounds that appear only when the fight finishes.
</how-to-verify>
<resume-signal>Type "approved" or describe what you saw</resume-signal>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Cloud openclaw bot registers and fights using only the unified prompt URL</name>
<what-built>
The unified AI bot-setup prompt is live at the public arena's `/api/docs/prompt` and contains
everything an agent needs: registration, credentials, both the webhook and polling protocols,
every endpoint and every response format. This is the D-02 acceptance test and the demo's
centrepiece.
</what-built>
<how-to-verify>
Hand your cloud "openclaw" agent nothing but the prompt URL (in `09-DEMO-CHECKLIST.md`) —
no other document, no extra instructions beyond "set yourself up as a BotFights bot using this".
Expect the agent to be able to, from that alone:
1. register itself (`POST /api/bots`) and receive an id and secret,
2. authenticate and poll for challenges (or stand up a webhook, if it chooses that mode),
3. join the queue and fight,
4. have that fight appear in the BotFights UI on archi-dev-box.
If the agent gets stuck, the exact question it could not answer from the prompt is the finding —
please quote it. That tells us precisely what the prompt is still missing, which is more useful
than "it didn't work".
</how-to-verify>
<resume-signal>Type "approved" or paste the point where the agent got stuck</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| signed catalog → this node's orchestrator | Decides the image and env this node runs |
| browser + NIP-07 extension → node UI → arena | The human's private key stays in the signer throughout |
| cloud AI agent → public arena | An anonymous internet client registering and fighting |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-34 | Tampering | a hand-placed container diverging from what the catalog describes | high | mitigate | The update goes through the orchestrator's catalog path only; the acceptance criteria assert the image digest matches the published one (Task 1) |
| T-09-35 | Information disclosure | the JWT secret remaining a plaintext container env value after the update | high | mitigate | Asserted absent from the container env and present as a 0600 secret file (Task 1) |
| T-09-36 | Denial of service | the node's app becoming unusable if the arena is down on demo day | medium | mitigate | `/api/health` stays local, the UI degrades to a 502 on data calls, and the checklist documents the one-env-var switch back to standalone |
| T-09-37 | Spoofing | a signer flow that silently falls back to a locally generated key | high | mitigate | The human check explicitly requires the extension's signature prompt and that no private key was ever requested (Task 2) |
| T-09-38 | Repudiation | claiming BOT-01/BOT-02 done on green automated tests alone | medium | mitigate | Both are gated behind blocking human checkpoints, per `09-RESEARCH.md` Pitfall 5 and the CLAUDE.md "test before claiming fixed" rule |
</threat_model>
<verification>
- `podman inspect botfights` shows 1.2.0, the arena URL, and no plaintext secret.
- The node's fighter list matches the arena's.
- Human-confirmed: real NIP-07 login (and Amber if tested), live cross-node fighter visibility,
cloud agent bootstrapped from the prompt alone.
- Closes the `09-VALIDATION.md` "Manual-Only Verifications" list in full: real NIP-07 login, Amber
login, cross-node fighter visibility, and the cloud openclaw bot using only the unified prompt.
- Dev-pair rule: recorded for `x250-dev` (repeat Task 1 there if it is online, otherwise state why not).
</verification>
<success_criteria>
- archi-dev-box runs BotFights 1.2.0 from the signed catalog, proxying to the shared arena, with a
per-install secret and its local data preserved.
- A human has logged in with a real nostr signer and seen arena fighters on the node.
- A cloud agent has built a working bot from the single prompt URL.
- A demo run sheet exists for tomorrow.
</success_criteria>
<output>
Create `.planning/phases/09-botfights-platform-upgrade/09-07-SUMMARY.md` when done, recording the
human verdicts verbatim (including anything the cloud agent could not answer from the prompt — that
is the highest-value finding this phase can produce).
Commit the checklist and SUMMARY in archy, staged by explicit path, and `git push gitea-ai main`.
</output>