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

21 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
09-botfights-platform-upgrade 01 execute 1
/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
true
BOT-03
truths artifacts key_links
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
path provides contains
/home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts Hono middleware that forwards /api/* to the canonical arena when ARENA_UPSTREAM_URL is set ARENA_UPSTREAM_URL
path provides contains
/home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts End-to-end proxy coverage against a real upstream HTTP server (REST, SSE, health bypass, failure) ARENA_UPSTREAM_URL
from to via pattern
/home/archipelago/Projects/botfight/server/src/app.ts /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts app.use('/api/*', arenaProxy) mounted before every app.route('/api/...') registration so proxy mode short-circuits the local routers 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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

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
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:<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).
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<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.
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.

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

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