297 lines
21 KiB
Markdown
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>
|