--- phase: 09-botfights-platform-upgrade plan: 01 type: execute wave: 1 depends_on: [] files_modified: - /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts - /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts - /home/archipelago/Projects/botfight/server/src/app.ts - /home/archipelago/Projects/botfight/server/src/routes/fights.ts - /home/archipelago/Projects/botfight/docker-compose.yml autonomous: true requirements: [BOT-03] must_haves: truths: - "With ARENA_UPSTREAM_URL set, a POST to a node instance's /api/bots is served by the canonical arena, and the same bot is then returned by that node instance's GET /api/bots — this is what makes 'all nodes see all fighters' true (D-03/BOT-03)" - "With ARENA_UPSTREAM_URL unset the server behaves exactly as it does today: every /api/* route is served from the local SQLite DB, no network hop" - "GET /api/health is answered locally even in proxy mode, so the app manifest's health check never depends on VPS2 being reachable" - "A proxied SSE fight stream delivers events incrementally while the fight runs, not buffered until the upstream stream closes" - "The originating client IP reaches the canonical arena in X-Forwarded-For, so the arena's per-IP rate limiting is not collapsed into one bucket per node" - "When the canonical arena is unreachable, the node instance answers 502 with a JSON error body instead of a 500 stack trace or a hung request" artifacts: - path: /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts provides: "Hono middleware that forwards /api/* to the canonical arena when ARENA_UPSTREAM_URL is set" contains: "ARENA_UPSTREAM_URL" - path: /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts provides: "End-to-end proxy coverage against a real upstream HTTP server (REST, SSE, health bypass, failure)" contains: "ARENA_UPSTREAM_URL" key_links: - from: /home/archipelago/Projects/botfight/server/src/app.ts to: /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts via: "app.use('/api/*', arenaProxy) mounted before every app.route('/api/...') registration so proxy mode short-circuits the local routers" pattern: "arenaProxy" --- Build the BOT-03 architecture in one thin, production-quality vertical slice: a Hono middleware in the botfights server that, when `ARENA_UPSTREAM_URL` is set, forwards every `/api/*` request to the canonical public arena and streams the response straight back — so a node's own instance stops being an isolated island and becomes a thin client of one shared arena. Decision IDs used in this plan map to `09-CONTEXT.md` **Locked Decisions**: D-01 = BOT-01 native nostr signer login, D-02 = BOT-02 unified AI bot-setup prompt, D-03 = BOT-03 shared public match endpoint on VPS2, D-04 = BOT-04 registry/manifest + signed catalog. Purpose: D-03 is the only genuinely new architecture in this phase and the one that can dead-end the whole demo (SSE buffering, Host-header routing, gzip double-decode, health-check coupling). Proving it end-to-end against a real HTTP upstream before VPS2, the image build, or the catalog publish exist means a wrong turn costs one commit instead of ten. Output: `server/src/middleware/arena-proxy.ts`, its end-to-end test, the mount in `app.ts`, an SSE-through-nginx fix in `routes/fights.ts`, and the env var documented in `docker-compose.yml`. **Repo: `/home/archipelago/Projects/botfight`** (NOT archy). Commit target: `git push origin main` (= `source.archipelago-foundation.org/lfg2025/botfights`). The plan/SUMMARY files live in archy and are pushed with `git push gitea-ai main`. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md @.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md @.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md @.planning/phases/09-botfights-platform-upgrade/09-VALIDATION.md ## Artifacts this plan produces | Symbol | Kind | File | |---|---|---| | `arenaProxy` | exported async Hono middleware `(c, next)` | `server/src/middleware/arena-proxy.ts` | | `HOP_BY_HOP` | module-private `Set` of headers never forwarded | same | | `arena-proxy.test.ts` | Vitest suite driving a real upstream via `@hono/node-server` | `server/src/middleware/arena-proxy.test.ts` | | `app.use('/api/*', arenaProxy)` | mount point | `server/src/app.ts` | Task 1: End-to-end — one /api/* request served by the canonical arena through a node instance New middleware that no-ops unless `ARENA_UPSTREAM_URL` is set; reverting is deleting one file and one `app.use` line, with no schema or on-disk change. /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts, /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts, /home/archipelago/Projects/botfight/server/src/app.ts - `/home/archipelago/Projects/botfight/server/src/app.ts` lines 28-108 — the exact middleware order (`logger` → `cors` → COOP/COEP → `secureHeaders` → `bodyLimit` → global `rateLimit` → cache-header middleware → `app.get('/api/health')` → the eleven `app.route('/api/...')` calls). The new mount goes after the cache-header middleware and before `app.get('/api/health')`. - `/home/archipelago/Projects/botfight/server/src/middleware/rate-limit.ts` — the in-repo Hono middleware shape (`return async (c, next) => {...}`) and `getIp()`'s `TRUSTED_PROXY` handling, which is what the forwarded client IP feeds on the arena side. - `/home/archipelago/Projects/botfight/server/src/routes/auth.test.ts` lines 1-90 — the Vitest harness convention: build a bare `new Hono()`, mount, drive with `app.request(path, init)`, set/restore `process.env` in `beforeEach`/`afterEach`. - `.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md` section "`server/src/middleware/arena-proxy.ts` (NEW middleware)" — mount-order and core pattern. - `.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md` Pattern 2 + Pitfall 2. Write these tests first in `arena-proxy.test.ts` and watch them fail before implementing. The upstream is a REAL second server: build a `new Hono()` with an in-memory bot store exposing `POST /api/bots` (echoes `{id, secret, name}` and records it) and `GET /api/bots` (returns the recorded list), start it with `serve({ fetch: upstream.fetch, port: 0 })` from `@hono/node-server`, read the assigned port from the returned server's `address()`, and point `process.env.ARENA_UPSTREAM_URL` at `http://127.0.0.1:`. Close the server in `afterAll`. - `registers a bot upstream and reads it back through the proxy`: through the proxying app, `POST /api/bots` with a JSON body, assert 200 and the echoed name; then `GET /api/bots` through the proxying app and assert the bot registered a moment ago is in the list. This is the phase's cross-node-visibility claim reduced to its smallest honest form. - `falls through to local routers when ARENA_UPSTREAM_URL is unset`: delete the env var, mount `arenaProxy` plus a sentinel local route, assert the sentinel answers (proving `next()` ran). - `answers /api/health locally even in proxy mode`: point the env var at a port with nothing listening, assert `GET /api/health` still returns 200 from the local handler. - `forwards method, query string and JSON body unchanged`: upstream echoes back `{method, path, query, body}`; assert each field round-trips for a `POST /api/x?a=1&b=2`. - `does not forward the inbound Host header`: upstream echoes the `host` header it received; assert it equals the upstream's own `127.0.0.1:` authority, not the caller's. - `strips response content-encoding and content-length`: upstream replies with those headers set; assert neither is present on the response the proxy returns (Node's fetch already decoded the body, so copying them corrupts the response for the browser). Create `server/src/middleware/arena-proxy.ts` exporting `export async function arenaProxy(c: Context, next: Next)`. Read `process.env.ARENA_UPSTREAM_URL` INSIDE the handler on every call, not at module scope — a module-level constant is captured at import time and cannot be toggled by the tests or by a container restart-free config change. Behavior, in order: 1. If the env var is empty/unset, `return next()` — standalone mode, today's code path untouched. 2. If `c.req.path` is exactly `/api/health`, `return next()` — the manifest health check (`apps/botfights/manifest.yml` `health_check.path`) must stay answerable while the arena is unreachable, otherwise podman marks a perfectly healthy node container unhealthy and restarts it in a loop. Keep this bypass list in one named module constant so it is greppable. 3. Build the target URL from the upstream base plus `c.req.path` plus the original query string taken from `new URL(c.req.url).search` (do not re-encode via URLSearchParams round-trip — that reorders and re-escapes repeated keys). 4. Copy the inbound headers into a fresh `Headers`, dropping: `host`, `connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `proxy-authorization`, `proxy-connection`, `te`, `trailer`, and `content-length` (undici recomputes it). Define that drop list as a module-level `HOP_BY_HOP` set. Then set `accept-encoding` to `identity` on the forwarded request so the upstream returns an uncompressed body and no encoding bookkeeping is needed. 5. `fetch(target, { method, headers, body, redirect: 'manual', duplex: 'half' })` where `body` is `undefined` for GET/HEAD and `c.req.raw.body` otherwise. The `duplex` option is required by Node 22's undici whenever a streaming body is sent and needs a `@ts-expect-error` since it is missing from the DOM `RequestInit` type. 6. Return `new Response(upstreamRes.body, { status, headers })` where `headers` is a copy of the upstream response headers with `content-encoding`, `content-length` and the same hop-by-hop set removed. Pass the body through as the stream it is — reading it into a string or object first breaks the SSE fight stream (Task 2) and is the single most likely way to get this wrong. Mount in `app.ts`: `import { arenaProxy } from './middleware/arena-proxy.js'` and `app.use('/api/*', arenaProxy)` placed immediately after the `/api/docs/*` cache-header middleware (currently around line 93) and before `app.get('/api/health', ...)`. Do not move, reorder or delete any existing middleware. Keep the error-response convention of this codebase: `c.json({ error: '...' }, status)` returns, never thrown exceptions inside the handler (upstream-failure handling is Task 2). cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts - `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts` exits 0 with at least 6 passing tests. - `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` exits 0. - `grep -c 'ARENA_UPSTREAM_URL' /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts` is at least 1. - `grep -Eq "app.use\('/api/\*', arenaProxy\)" /home/archipelago/Projects/botfight/server/src/app.ts` succeeds. - `awk '/app.use\(.\/api\/\*., arenaProxy\)/{p=NR} /app.route\(.\/api\/auth./{r=NR} END{exit !(p>0 && p A request that enters a node instance's /api/* is provably served by a different, real HTTP arena process and returned unmodified, while standalone mode and /api/health are untouched. Task 2: Make the proxied path survive real conditions — SSE streaming, client IP, upstream down /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts, /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts, /home/archipelago/Projects/botfight/server/src/routes/fights.ts, /home/archipelago/Projects/botfight/docker-compose.yml - `/home/archipelago/Projects/botfight/server/src/routes/fights.ts` around line 410-480 — the `GET /:id/stream` handler, its `MAX_SSE_PER_IP` guard and the `streamSSE(c, async (stream) => ...)` body. The header additions go at the top of that handler, before `streamSSE` is called. - `/home/archipelago/Projects/botfight/server/src/middleware/rate-limit.ts` lines 36-52 — `getIp()` reads `cf-connecting-ip`, then `x-real-ip`, then the first entry of `x-forwarded-for`, but ONLY when `TRUSTED_PROXY` is set. The canonical arena runs with `TRUSTED_PROXY=1` (plan 09-04), so the first XFF entry this proxy writes is the identity the arena rate-limits on. - `/home/archipelago/Projects/botfight/docker-compose.yml` — the commented env-var block style to follow when documenting the new vars. Extend `arena-proxy.ts`: - Client IP forwarding: before fetching, append the caller's address to `x-forwarded-for` (comma-joined with any inbound value, caller first if none exists) and set `x-real-ip` when absent. Take the caller address from the Node socket the same way `rate-limit.ts` does — `(c.env as Record)?.incoming?.socket?.remoteAddress` — and skip both headers when it cannot be determined rather than inventing a value. Without this every request from one node arrives at the arena wearing that node's single public IP and the arena's global `rateLimit(60_000, 300)` throttles that node's entire user base as one client. - Upstream failure: wrap the fetch in try/catch and, on any network error or timeout, log via the existing `logger` module and return `c.json({ error: 'Arena unreachable.' }, 502)`. Apply an `AbortSignal.timeout(...)` of 30s for non-streaming requests; do NOT apply a timeout to the SSE stream path (`/api/fights/` + `/stream`), which is long-lived by design. In `routes/fights.ts`'s stream handler, set `c.header('X-Accel-Buffering', 'no')` (and keep the existing headers) before handing off to `streamSSE`. nginx-proxy-manager fronts the canonical arena with the stock `proxy.conf`, which does not disable proxy buffering; that header is the documented nginx opt-out and is what makes live fight events arrive during the fight instead of all at once when it ends. Harmless when no nginx is in the path. In `docker-compose.yml`, document (commented, alongside the existing commented env block) the two new vars and their split: `ARENA_UPSTREAM_URL` — set on a NODE instance to make it a thin client of the canonical arena, left unset on the canonical arena itself; `TRUSTED_PROXY` — set to 1 only on the canonical arena, which sits behind nginx-proxy-manager. Do not set either as an active value in this file; the canonical arena gets its own compose file in plan 09-04. Add tests to `arena-proxy.test.ts`: - `streams SSE incrementally through the proxy`: the upstream test app writes three SSE frames with a delay between them; read the proxied response body with a `ReadableStream` reader and assert the first frame arrives before the upstream has written the last one (assert on elapsed-time ordering, not on the total payload). A buffering proxy fails this. - `forwards the client address in x-forwarded-for`: upstream echoes the header; assert it is present and non-empty when the socket address is available. - `answers 502 when the arena is unreachable`: point the env var at a closed port, assert status 502 and a JSON body with an `error` key. cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts server/src/middleware/rate-limit.test.ts - `cd /home/archipelago/Projects/botfight && pnpm vitest run server/src/middleware/arena-proxy.test.ts server/src/middleware/rate-limit.test.ts` exits 0 with at least 9 passing tests total. - `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` exits 0 (no regression in the existing server suite). - `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` exits 0. - `grep -q 'x-forwarded-for' /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts` succeeds. - `grep -q '502' /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts` succeeds. - `grep -q 'X-Accel-Buffering' /home/archipelago/Projects/botfight/server/src/routes/fights.ts` succeeds. - `grep -c 'ARENA_UPSTREAM_URL' /home/archipelago/Projects/botfight/docker-compose.yml` is at least 1. Proxy mode survives the three conditions that would break the demo: a live fight stream, per-IP rate limiting at the arena, and a temporarily unreachable arena. ## Trust Boundaries | Boundary | Description | |----------|-------------| | browser / bot script → node botfights instance | Untrusted client input enters the node's Hono server | | node instance → canonical arena over the public internet | A new outbound trust hop introduced by this plan; carries NIP-98 events, JWTs and bot secrets | | node instance → its own local SQLite DB | Bypassed entirely in proxy mode; still the live path in standalone mode | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-09-01 | Spoofing | `x-forwarded-for` written by the node proxy | medium | mitigate | The arena only honours forwarding headers when `TRUSTED_PROXY` is set (`rate-limit.ts` `getIp`), and nginx-proxy-manager rewrites `X-Real-IP` to the true peer, so a client-supplied XFF cannot alone impersonate an IP for rate-limit evasion | | T-09-02 | Information disclosure | credentials crossing node → arena | high | mitigate | `ARENA_UPSTREAM_URL` is an `https://` origin (set in the manifest, plan 09-06); the proxy forwards `Authorization` unchanged over TLS and never logs header values | | T-09-03 | Denial of service | proxy holding sockets for a hung arena | medium | mitigate | 30s `AbortSignal.timeout` on non-stream requests + 502 fast-fail (Task 2); SSE deliberately exempt | | T-09-04 | Denial of service | node container flapping when the arena is down | high | mitigate | `/api/health` is never proxied (Task 1), so the manifest health check reflects the node container's own liveness | | T-09-05 | Tampering | response corruption from copying stale `content-encoding`/`content-length` | medium | mitigate | Forwarded request asks for `identity`; both headers are stripped from the returned response (Task 1, asserted by test) | | T-09-06 | Repudiation | proxied requests losing the originating identity in arena logs | low | accept | XFF forwarding covers the operational need; full request attribution across nodes is out of this phase's scope | - `cd /home/archipelago/Projects/botfight && pnpm vitest run --project server` — green. - `cd /home/archipelago/Projects/botfight && pnpm exec tsc --noEmit -p server/tsconfig.json` — exits 0. - `cd /home/archipelago/Projects/botfight && pnpm lint` — no new errors in the two touched files. - Maps to `09-VALIDATION.md` row "BOT-03 | SSRF/leak via proxy | REST + SSE forwarding correct, no header leak | integration | `pnpm vitest run server/src/middleware/arena-proxy.test.ts`", which this plan converts from ❌ W0 to ✅. - A node instance in proxy mode serves `/api/*` from the canonical arena and nothing else changes. - Standalone mode (env unset) is byte-for-byte the behaviour shipped today. - `/api/health` is local-only; SSE streams incrementally; the client IP survives the hop; an unreachable arena degrades to a 502 JSON error. - No new npm dependency was added (native `fetch` only). Create `.planning/phases/09-botfights-platform-upgrade/09-01-SUMMARY.md` when done. Commit the botfight changes in `/home/archipelago/Projects/botfight` with `git add` by explicit path and `git push origin main`. Commit the SUMMARY in archy and `git push gitea-ai main`.