Files
archy/.planning/phases/09-botfights-platform-upgrade/09-01-PLAN.md
T

297 lines
21 KiB
Markdown

---
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>