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

16 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status
09-botfights-platform-upgrade 01 api
hono
reverse-proxy
sse
nodejs-fetch
arena-federation
ARENA_UPSTREAM_URL reverse-proxy middleware (server/src/middleware/arena-proxy.ts) mounted on /api/* in server/src/app.ts
SSE-through-nginx opt-out header (X-Accel-Buffering: no) on GET /api/fights/:id/stream
Documented env-var contract (ARENA_UPSTREAM_URL, TRUSTED_PROXY) in docker-compose.yml
09-04 canonical VPS2 arena deploy
09-06 apps/botfights/manifest.yml ARENA_UPSTREAM_URL default
added patterns
Reverse-proxy middleware reads process.env on every call (not module scope) so it can be toggled by tests/config without a restart
Response(upstreamRes.body, ...) passthrough — never buffer with .text()/.json() before returning, or SSE breaks
Hop-by-hop header set (HOP_BY_HOP) shared between request-forward and response-copy paths
created modified
server/src/middleware/arena-proxy.ts
server/src/middleware/arena-proxy.test.ts
server/src/app.ts
server/src/routes/fights.ts
docker-compose.yml
Arena upstream accepts both http:// and https:// (no scheme assumption in code) — the default VPS2 arena is plain HTTP per user decision; TLS is an env-only upgrade path later, never hardcoded.
SSE stream path (/api/fights/:id/stream) is exempt from the 30s AbortSignal.timeout; every other proxied request gets one.
/api/health is bypassed (answered locally) even in proxy mode so the manifest health check never depends on arena reachability.
arena-proxy.ts's HOP_BY_HOP header set + LOCAL_BYPASS_PATHS constant is the pattern for any future local-vs-proxied route split in this app.
BOT-03
id description requirement verification human_judgment
D1 POST /api/bots against a node instance in proxy mode is served by the canonical arena, and the bot is visible via that node's GET /api/bots (cross-node visibility) BOT-03
kind ref status
integration server/src/middleware/arena-proxy.test.ts#registers a bot upstream and reads it back through the proxy pass
false
id description requirement verification human_judgment
D2 Standalone mode (ARENA_UPSTREAM_URL unset) falls through to local routers unchanged BOT-03
kind ref status
integration server/src/middleware/arena-proxy.test.ts#falls through to local routers when ARENA_UPSTREAM_URL is unset pass
false
id description requirement verification human_judgment
D3 GET /api/health answers locally even when the arena is unreachable BOT-03
kind ref status
integration server/src/middleware/arena-proxy.test.ts#answers /api/health locally even in proxy mode pass
false
id description requirement verification human_judgment
D4 Method, query string, and JSON body forward unchanged; inbound Host header is dropped; stale content-encoding/content-length are stripped from the response BOT-03
kind ref status
integration server/src/middleware/arena-proxy.test.ts#forwards method, query string and JSON body unchanged pass
kind ref status
integration server/src/middleware/arena-proxy.test.ts#does not forward the inbound Host header pass
kind ref status
integration server/src/middleware/arena-proxy.test.ts#strips response content-encoding and content-length pass
false
id description requirement verification human_judgment
D5 A proxied SSE fight stream delivers events incrementally (not buffered until the upstream stream closes) BOT-03
kind ref status
integration server/src/middleware/arena-proxy.test.ts#streams SSE incrementally through the proxy pass
false
id description requirement verification human_judgment
D6 Originating client IP reaches the canonical arena via x-forwarded-for/x-real-ip, verified over a real loopback socket BOT-03
kind ref status
integration server/src/middleware/arena-proxy.test.ts#forwards the client address in x-forwarded-for pass
false
id description requirement verification human_judgment
D7 An unreachable arena degrades to 502 {error} instead of a hang or a 500 stack trace BOT-03
kind ref status
integration server/src/middleware/arena-proxy.test.ts#answers 502 when the arena is unreachable pass
false
id description verification human_judgment rationale
D8 Live real-world cross-node fight visibility (a bot registered on one node instance visibly fights on another) against the deployed VPS2 arena
true Requires the canonical VPS2 arena to actually be deployed and reachable (plan 09-04) and apps/botfights/manifest.yml to carry ARENA_UPSTREAM_URL (plan 09-06) — this plan only proves the proxy middleware in isolation against a real second HTTP process, not the full deployed topology. Out of this plan's scope per its objective.
40min 2026-07-31 complete

Phase 9 Plan 01: Arena-Proxy Reverse-Proxy Middleware Summary

Hono middleware that forwards every /api/* request (REST + SSE) to ARENA_UPSTREAM_URL via native fetch/stream-passthrough when set, proven end-to-end against a real second HTTP server — zero new dependencies.

Performance

  • Duration: ~40 min
  • Started: 2026-07-31T01:12:00Z (approx.)
  • Completed: 2026-07-31T01:36:32Z
  • Tasks: 2/2 completed
  • Files modified: 5 (2 created, 3 modified)

Accomplishments

  • server/src/middleware/arena-proxy.ts — the BOT-03 reverse-proxy: no-ops to next() when ARENA_UPSTREAM_URL is unset (today's behavior, byte-for-byte); bypasses /api/health locally even in proxy mode; forwards method/query/body/headers with a shared HOP_BY_HOP drop-list; strips stale content-encoding/content-length from the response; passes the upstream ReadableStream straight through (never buffers, which is what makes SSE work); forwards the caller's IP via x-forwarded-for/x-real-ip when a real socket address is available; times out non-stream requests at 30s (SSE exempt); returns a clean 502 {error} JSON body when the arena is unreachable.
  • Mounted in server/src/app.ts via app.use('/api/*', arenaProxy), positioned before every app.route('/api/...') registration.
  • server/src/routes/fights.ts's SSE stream handler now sets X-Accel-Buffering: no so an nginx-fronted canonical arena (nginx-proxy-manager) doesn't buffer live fight events.
  • docker-compose.yml documents ARENA_UPSTREAM_URL and TRUSTED_PROXY (commented, no active value — the canonical arena's own compose file is a later plan).
  • 9 end-to-end tests in server/src/middleware/arena-proxy.test.ts, all driven against a REAL second Hono process started with @hono/node-server's serve({ port: 0 }) (not mocks): cross-node bot registration + readback, standalone fallthrough, health bypass, method/query/body forwarding, Host-header drop, content-encoding/length stripping, incremental SSE delivery (elapsed-time assertion), x-forwarded-for over a real loopback socket, and 502 on an unreachable arena.

Task Commits

Each task was committed atomically in /home/archipelago/Projects/botfight (pushed to origin main):

  1. Task 1: End-to-end REST forwarding (tracer)143ca80 (feat) — arena-proxy.ts + 6 REST-focused tests + app.ts mount. TDD: tests written first, confirmed failing (module didn't exist — Failed to load url ./arena-proxy.js), then implemented to green.
  2. Task 2: SSE, client-IP forwarding, upstream-down0511b97 (feat) — x-forwarded-for/x-real-ip, 30s timeout (SSE exempt), 502 handling, fights.ts's X-Accel-Buffering header, docker-compose.yml docs, + 3 more tests (SSE, XFF, 502). TDD: the 502 test was written first and confirmed failing (expected 502 to be 500) against the Task 1 implementation, then implemented to green.
  3. Test hardening (post-hoc improvement)a95cada (test) — strengthened the x-forwarded-for test to drive the proxying app over a real loopback socket (@hono/node-server) instead of Hono's in-process app.request() harness, which has no real socket.remoteAddress to exercise. Not a plan task, but needed to make D6's coverage claim genuinely proven rather than a presence-or-absence tautology.

Plan metadata: this SUMMARY + STATE/ROADMAP updates, committed in archy via git push gitea-ai main.

Note: both feature tasks used a TDD red→green cycle; no separate refactor commit was needed.

Files Created/Modified

  • server/src/middleware/arena-proxy.ts — the reverse-proxy middleware (created)
  • server/src/middleware/arena-proxy.test.ts — 9 end-to-end tests against a real upstream HTTP server (created)
  • server/src/app.ts — mounts arenaProxy on /api/* before the route registrations
  • server/src/routes/fights.tsX-Accel-Buffering: no on the SSE stream handler
  • docker-compose.yml — documents ARENA_UPSTREAM_URL/TRUSTED_PROXY (commented)

Decisions Made

  • Plain HTTP is an explicitly supported upstream scheme, not just HTTPS. The plan's threat register (T-09-02) as originally written assumed ARENA_UPSTREAM_URL is always an https:// origin. Per the user's direct instruction during execution, this is superseded: the default VPS2 arena (http://146.59.87.168:9100) is plain HTTP and the proxy must accept both http:// and https:// upstreams with no scheme assumption anywhere in the code — and there is none; buildTargetUrl and fetch are scheme-agnostic by construction. See "Deviations from Plan" below.
  • Timeout scoping is by path pattern, not by streaming-detection at runtime. SSE_STREAM_PATH = /^\/api\/fights\/[^/]+\/stream$/ is checked before the fetch, matching the plan's explicit path guidance rather than trying to detect a streaming response after the fact (which would be too late to skip an already-attached AbortSignal).

Deviations from Plan

Auto-fixed Issues

1. [Rule 2 - missing critical functionality / threat-model correction] T-09-02 mitigation text superseded — HTTP is a supported upstream scheme, not just HTTPS

  • Found during: pre-execution review of the plan's threat model (T-09-02) against the phase's CONTEXT.md "Arena-as-relay" decision and the user's direct instruction for this run.
  • Issue: The plan's threat register states ARENA_UPSTREAM_URL "is an https:// origin." The actual architecture decision (CONTEXT.md, and the default arena address http://146.59.87.168:9100 used throughout 09-CONTEXT.md/09-RESEARCH.md/09-PATTERNS.md) is plain HTTP for the default arena, with TLS as an operator-chosen upgrade later — not a hardcoded assumption.
  • Fix: No code change was needed — the implementation was already scheme-agnostic (new URL(path + search, upstream) and fetch() work identically for http:// and https://; nothing in arena-proxy.ts checks or assumes a scheme). This is a documentation correction to the threat register, recorded here since the plan's committed threat model text is now stale.
  • Updated mitigation (superseding T-09-02 as written): "User-accepted plain HTTP for the default arena; TLS upgrade is env-only later (an operator can point ARENA_UPSTREAM_URL at an https:// origin with zero code change). Credentials crossing node → arena over plain HTTP are visible to any on-path observer between the node and VPS2 — accepted for the alpha/beta default arena per user decision; nodes on an untrusted network path to VPS2 should either use a VPN/Tailscale hop or wait for the TLS-upgraded arena."
  • Files affected: none (documentation-only; no source change required).
  • Not blocking: per explicit instruction, this deviation is recorded and not treated as a blocker.

Total deviations: 1 (threat-model documentation correction, no code change; already scheme-agnostic by construction). Impact on plan: None on delivered code — the implementation matches the corrected, user-stated architecture. Threat register text should be updated in a future pass over 09-01-PLAN.md or carried forward into 09-04/09-06's threat models.

Issues Encountered

  • Fresh local dev environment needed setup before the pre-existing full server test suite (pnpm vitest run --project server) could run at all: node_modules was missing (ran pnpm install); better-sqlite3's native binding wasn't built because pnpm's newer build-approval gate silently skipped postinstall scripts (pnpm approve-builds --all); the local dev SQLite DB had never been migrated. These are one-time local environment bootstrap steps, not code changes, and none were committed (the pnpm-lock.yaml/pnpm-workspace.yaml churn from a locally-different pnpm version was explicitly reverted with git checkout -- before committing anything, to avoid polluting the shared repo with unrelated lockfile noise).
  • Pre-existing schema drift, unrelated to this plan, blocks ~13-20 tests in the full server suite: server/src/db/migrate.ts's hand-written CREATE TABLE IF NOT EXISTS DDL is stale versus server/src/db/schema.ts (missing at least the sats_won column added for payments features), causing SqliteError: no such column 500s in auth.test.ts, auth-audit.test.ts, auth-edge.test.ts, and tournaments.test.ts on any freshly-migrated local DB. Verified this is not caused by arena-proxyARENA_UPSTREAM_URL is unset in the test environment, so arenaProxy is a pure next() no-op, and the failures occur deep inside db.select(...) calls that never touch the new middleware or its mount point. A small number of additional failures (answers.test.ts, lifecycle.test.ts, bot-auth.test.ts's constant-time-comparison test) are timing-budget assertions that flake under this machine's parallel-worker CPU contention — also pre-existing and unrelated. Full detail logged to .planning/phases/09-botfights-platform-upgrade/deferred-items.md per the Scope Boundary rule (out-of-scope for this plan's files). This plan's own required commands are unaffected and green: pnpm vitest run server/src/middleware/arena-proxy.test.ts (9/9), pnpm vitest run server/src/middleware/arena-proxy.test.ts server/src/middleware/rate-limit.test.ts (17/17), pnpm exec tsc --noEmit -p server/tsconfig.json (clean), and eslint on all four touched files (clean).

User Setup Required

None — no external service configuration required. (VPS2 canonical-arena deployment and the manifest's ARENA_UPSTREAM_URL default are later plans, 09-04 and 09-06.)

Next Phase Readiness

  • arena-proxy.ts is ready to be exercised against the real canonical VPS2 arena once it's deployed (plan 09-04) — no further code change expected on the proxy side; docker-compose.yml's documented ARENA_UPSTREAM_URL/TRUSTED_PROXY vars are the contract the manifest work (09-06) should follow.
  • Deferred item worth carrying forward: the migrate.ts/schema.ts drift (see deferred-items.md) will keep breaking fresh local dev environments and CI-from-scratch until a future plan replaces the hand-written migration script with real drizzle-kit generate/migrate output.
  • The plan's threat register text for T-09-02 should be corrected in a future edit pass to match the "plain HTTP accepted for the default arena" decision recorded above, so it doesn't read as contradicting what was actually built.

Phase: 09-botfights-platform-upgrade Completed: 2026-07-31

Self-Check: PASSED

  • FOUND: /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.ts
  • FOUND: /home/archipelago/Projects/botfight/server/src/middleware/arena-proxy.test.ts
  • FOUND: /home/archipelago/Projects/archy/.planning/phases/09-botfights-platform-upgrade/09-01-SUMMARY.md
  • FOUND: /home/archipelago/Projects/archy/.planning/phases/09-botfights-platform-upgrade/deferred-items.md
  • FOUND commit 143ca80 (Task 1) in botfight git history
  • FOUND commit 0511b97 (Task 2) in botfight git history
  • FOUND commit a95cada (test hardening) in botfight git history
  • origin/main HEAD matches local HEAD (a95cada) — push confirmed landed