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:
parent
9bead8fa4c
commit
6a112797a5
@ -216,8 +216,14 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
|
||||
**Goal:** BotFights (app + registry) works great on every node: users sign in with the native nostr signer, a single self-contained AI prompt sets up their bot (replacing the confusing docs page), and every node's instance talks to a shared public match endpoint on VPS2 so all fighters are visible and battle across all nodes.
|
||||
**Requirements**: BOT-01 native nostr signer login; BOT-02 unified AI bot-setup prompt (one copy-paste prompt, no doc-hopping); BOT-03 public shared match/fighter endpoint hosted on VPS2, node instances federate to it by default; BOT-04 registry/manifest + signed catalog updated and republished for the new version
|
||||
**Depends on:** Nothing (independent app work; parallelizable with Phases 1–8)
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 7 plans
|
||||
|
||||
Plans:
|
||||
|
||||
- [ ] TBD (run /gsd-plan-phase 9 to break down)
|
||||
- [ ] 09-01-PLAN.md — Arena reverse-proxy tracer: node instances become thin clients of one shared arena (BOT-03)
|
||||
- [ ] 09-02-PLAN.md — Finish native nostr signer login: JWT-only GET /api/auth/me, bare-pubkey path retired (BOT-01)
|
||||
- [ ] 09-03-PLAN.md — One self-contained AI bot-setup prompt served at /api/docs/prompt (BOT-02)
|
||||
- [ ] 09-04-PLAN.md — Canonical public arena on VPS2 + DNS/TLS via nginx-proxy-manager (BOT-03)
|
||||
- [ ] 09-05-PLAN.md — Build+push botfights:1.2.0, roll the arena, prove cross-instance visibility (BOT-03/BOT-04)
|
||||
- [ ] 09-06-PLAN.md — Manifest 1.2.0 with generated JWT secret + signed catalog republished (BOT-04)
|
||||
- [ ] 09-07-PLAN.md — archi-dev-box deploy + demo rehearsal: real signer login, cloud bot from the prompt (BOT-01/02/03/04)
|
||||
|
||||
296
.planning/phases/09-botfights-platform-upgrade/09-01-PLAN.md
Normal file
296
.planning/phases/09-botfights-platform-upgrade/09-01-PLAN.md
Normal 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>
|
||||
260
.planning/phases/09-botfights-platform-upgrade/09-02-PLAN.md
Normal file
260
.planning/phases/09-botfights-platform-upgrade/09-02-PLAN.md
Normal 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>
|
||||
350
.planning/phases/09-botfights-platform-upgrade/09-03-PLAN.md
Normal file
350
.planning/phases/09-botfights-platform-upgrade/09-03-PLAN.md
Normal 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>
|
||||
325
.planning/phases/09-botfights-platform-upgrade/09-04-PLAN.md
Normal file
325
.planning/phases/09-botfights-platform-upgrade/09-04-PLAN.md
Normal 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>
|
||||
278
.planning/phases/09-botfights-platform-upgrade/09-05-PLAN.md
Normal file
278
.planning/phases/09-botfights-platform-upgrade/09-05-PLAN.md
Normal 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>
|
||||
319
.planning/phases/09-botfights-platform-upgrade/09-06-PLAN.md
Normal file
319
.planning/phases/09-botfights-platform-upgrade/09-06-PLAN.md
Normal 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>
|
||||
238
.planning/phases/09-botfights-platform-upgrade/09-07-PLAN.md
Normal file
238
.planning/phases/09-botfights-platform-upgrade/09-07-PLAN.md
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user