From b615a686171aa38a6b932b078ad2c7f77ca31738 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 20:32:04 -0400 Subject: [PATCH] docs(09): research botfights platform upgrade phase (nostr login, unified prompt, shared arena, catalog publish) Co-Authored-By: Claude Fable 5 --- .../09-RESEARCH.md | 476 ++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md diff --git a/.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md b/.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md new file mode 100644 index 00000000..536200e8 --- /dev/null +++ b/.planning/phases/09-botfights-platform-upgrade/09-RESEARCH.md @@ -0,0 +1,476 @@ +# Phase 9: BotFights Platform Upgrade - Research + +**Researched:** 2026-07-30 +**Domain:** Nostr (NIP-07/NIP-98) auth on an existing Hono/TypeScript+Vue3 app; multi-node "thin client to canonical origin" architecture; Archipelago signed-catalog app publishing +**Confidence:** HIGH (all findings verified directly against the live `botfight` repo source and the `archy` catalog/manifest tooling — this is a codebase-audit-heavy phase, not a greenfield-library phase) + +## Summary + +This phase touches TWO repositories: `/home/archipelago/Projects/botfight` (the app itself — pnpm workspace, Vue3+Vite frontend, Hono/TS server, drizzle/SQLite) and `/home/archipelago/Projects/archy` (this repo — manifest + signed catalog only). The single biggest research finding: **BOT-01 (native nostr signer login) is already ~90% implemented on `botfight`'s `main` branch**, not greenfield work. Commit `3ba05a6` ("feat: NIP-98 + JWT authentication with signer support") plus four follow-up fix commits already ship a complete NIP-07 (`window.nostr`) + NIP-98 (kind 27235) + NIP-55 (Amber `nostrsigner:` URI) login flow, HMAC-verified JWT sessions with a logout blacklist, and full test coverage (`nip98.test.ts`, `jwt.test.ts`, `bot-auth.test.ts`, `auth.test.ts`, `auth-edge.test.ts`, `auth-audit.test.ts`, e2e `signup-human.spec.ts`/`signup-bot.spec.ts`). BOT-01's real work is: closing one remaining "trust the pubkey" call (`useNostr.ts`'s auto-restore hits `POST /api/auth/login` with a bare pubkey — read-only, low severity, but still the pattern the user wants gone), and **verifying it against a real browser extension and Amber** (the existing e2e suite only drives the local-key dev path, never a real NIP-07 provider). + +BOT-02's target content already exists in fragments: `BOTFIGHTS.md` (452 lines, both webhook AND poll modes inline, already written to be AI-agent-readable — "Your AI reads this file to set up a fighting bot") is ~90% of the target "one prompt." The confusing part is the fragmentation across `BOTFIGHTS.md` + `BOTFIGHTS-EASY.md` + `BOTFIGHTS-POLLING.md` + `BOTFIGHTS-WEBHOOK.md` + `BOT_SETUP.md` + the `DocsPage.vue` UI's own separate copy of nearly the same content, PLUS the fact that none of them document the actual registration call (`POST /api/bots`, fully anonymous — no nostr signer needed for a bot script, only for the human dashboard). The work is consolidation into one file + adding the missing registration step, not new authoring from scratch. + +BOT-03 is a genuine architecture decision. The frontend has **zero** configurable API base URL — all 64 fetch call sites use relative `/api/...` paths, same-origin. Given that, and given the CONTEXT.md instruction to bias toward the simplest thing, the recommended design is a **server-side reverse-proxy ("thin client") mode**: the canonical arena runs once on VPS2 (today's docker-compose stack, unchanged, fronted by nginx-proxy-manager + Let's Encrypt on a new subdomain); every node's own `botfights` container gets a new `ARENA_UPSTREAM_URL` env var that, when set, makes the Hono server proxy every `/api/*` request (including the SSE fight-event stream) to the canonical origin instead of touching its own local SQLite DB. This requires zero frontend code changes, no CORS configuration, no JWT-secret-sharing across nodes (local JWT/DB code paths simply never execute in proxy mode), and NIP-98 verification is proven host-independent (`verifyNip98Token` compares URL **pathname** only, not origin) so a proxied auth request from a different node's origin still verifies cleanly at the canonical arena. The tradeoff to surface to the user: a node with no path to VPS2 shows a fully broken arena (no cached/offline fighter list) — the app is a PWA (`vite-plugin-pwa` is a dependency) so the static shell still loads, but all data calls fail. Local per-node bot registrations that predate this change become orphaned; migrating them is a data task, not just a code change. + +BOT-04 surfaced one blocking finding independent of the other three: the JWT auth code added in `3ba05a6` **throws at module import time** if `JWT_SECRET` is unset and `NODE_ENV=production` (`middleware/jwt.ts` line 4-6). The current `apps/botfights/manifest.yml` sets `NODE_ENV=production` and does **not** set `JWT_SECRET` anywhere — a fresh install of any image built from current `main` (which any 1.2.0 build will be) will **crash-loop on start** unless the manifest adds `generated_secrets`/`secret_env` for it, using the exact pattern already established for `netbird-server` (`kind: base64`) and `fedimint-gateway`. This is a must-fix, not a nice-to-have — it is stated as a Common Pitfall below, not buried in a table. + +**Primary recommendation:** Treat BOT-01 as an audit+harden+verify task (not a build task), BOT-02 as a doc-consolidation task, BOT-03 as a small reverse-proxy feature behind one new env var (`ARENA_UPSTREAM_URL`) with the canonical arena deployed once on VPS2, and BOT-04 as: bump `apps/botfights/manifest.yml` to 1.2.0 with the new env var + the mandatory `JWT_SECRET` `generated_secrets` entry, regenerate `releases/app-catalog.json` via `scripts/generate-app-catalog.sh`, then sign via `scripts/sign-catalog.sh` (human-mnemonic checkpoint), then push. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Human login (NIP-07/NIP-98/NIP-55) | Browser / Client (`window.nostr`, signer app) | Node's local Hono server (`/api/auth/nostr/session` — proxied in BOT-03 mode) | Private key never leaves the signer; the server only verifies a signed event and issues a JWT | +| Bot self-registration (AI agent, no human) | API / Backend (`POST /api/bots`, anonymous) | — | Deliberately no nostr/browser dependency — an AI coding agent can `curl` it directly, which is the whole point of BOT-02's unified prompt | +| Bot runtime auth (poll/webhook) | API / Backend (`bot_id`+`secret` SHA-256 compare, or HMAC webhook signature) | — | Independent of the human's nostr identity; a bot's credentials are separate from its owner's login | +| Match/fighter/queue state ("the arena") | API / Backend — **one canonical instance on VPS2** | Database/Storage (VPS2's SQLite volume) | BOT-03's core decision: single source of truth, not N independently-diverging per-node DBs | +| Per-node BotFights instance (post BOT-03) | API / Backend (thin reverse-proxy) + CDN/Static (serves its own frontend bundle) | — | Frontend stays node-local (fast static asset load, PWA shell); all dynamic `/api/*` calls tunnel to VPS2 | +| Unified AI setup prompt | API / Backend (served at a stable URL, e.g. `/api/docs/prompt` or `/prompt.md`) | Browser / Client (copy button in `DocsPage.vue`/`JoinBoutPage.vue`) | Must be fetchable by an AI agent without a browser, hence backend-served, not SPA-route-only | +| Signed catalog publish (BOT-04) | Archipelago repo tooling (`scripts/generate-app-catalog.sh` + `sign-catalog.sh`) | — | Out of the botfight repo entirely; this repo's job is manifest + catalog only, never app code | + +## User Constraints + + + +### Locked Decisions (from CONTEXT.md, verbatim) + +1. **BOT-01 — Native nostr signer login.** Replace the bare-pubkey `POST /api/auth/login` trust model with a real signer flow: NIP-07 (`window.nostr` — browser extension / Amber on Android) + NIP-98 signed HTTP auth event verified server-side, per the design already written in the repo's `nostr-login-implementation.md`. The user never shares a private key with the app. +2. **BOT-02 — One self-contained AI bot-setup prompt.** The current DocsPage (BOTFIGHTS.md / BOTFIGHTS-EASY.md / BOTFIGHTS-POLLING.md / BOTFIGHTS-WEBHOOK.md + BOT_SETUP.md) is confusing. Replace with a single copy-paste prompt that contains EVERYTHING an AI agent needs to set up a working bot: registration, auth/secrets handling, webhook AND polling protocols, all API endpoints, response formats. "It all needs to be given inside the one prompt." +3. **BOT-03 — Shared public match endpoint on VPS2.** Every node's BotFights instance must use a public arena endpoint hosted on VPS2 (146.59.87.168, docker + nginx-proxy-manager; subdomains under `archipelago-foundation.org` available, pattern: new NPM proxy host + Let's Encrypt) **by default**, so all nodes see all fighters and battle across nodes. Node-local instance remains the runtime but match/fighter state is the shared public arena. +4. **BOT-04 — Registry/manifest update.** New app version: build + push new image to the vps2 registry, bump `apps/botfights/manifest.yml`, regenerate + re-sign + republish the signed catalog (catalog manifest overlay supremacy — disk edits don't apply to catalog-covered apps). + +### Claude's Discretion + +- Exact architecture for BOT-03 (thin-client mode vs sync/federation protocol) — chosen during planning based on research; bias toward the simplest thing that makes "all nodes see all fighters" true. +- JWT/session mechanics, token TTLs, migration path for existing registered bots (preserve existing bots — migrations never destroy data). +- Whether the unified prompt lives at a stable GET endpoint (e.g. `/api/docs/prompt` or `/prompt.md`) plus a copy button in the UI — recommended so the prompt is itself fetchable by AI agents. +- Version number for the release (suggest 1.2.0). + +### Deferred Ideas (OUT OF SCOPE) + +- IN: botfights repo changes (server + frontend + docs/prompt), VPS2 public arena deployment, archy manifest/catalog update, dev-pair verification. +- OUT: Lightning/cashu payment changes, arcade gameplay changes, tournament logic, companion app work, any archy core/orchestrator changes beyond the manifest/catalog. + + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| BOT-01 | Native nostr signer login | `useNostr.ts`/`nostr-auth.ts`/`nip98.ts`/`jwt.ts` already implement NIP-07+NIP-98+NIP-55 end-to-end (commit `3ba05a6` + follow-ups); remaining gap = the bare-pubkey auto-restore call and real-signer verification — see Common Pitfalls #1 and Code Examples | +| BOT-02 | Unified AI bot-setup prompt | `BOTFIGHTS.md` (452 lines, both modes inline) is 90% of the target content; `POST /api/bots` (anonymous registration) is the missing piece; consolidation plan in Architecture Patterns and Don't Hand-Roll | +| BOT-03 | Shared public match/fighter endpoint on VPS2, federate by default | 64/64 frontend fetch call sites use relative `/api/...` (verified via grep) → reverse-proxy thin-client design in Architecture Patterns; VPS2 port/NPM constraints documented in Environment Availability | +| BOT-04 | Registry/manifest + signed catalog updated and republished | Exact `generated_secrets`/`secret_env` pattern, `generate-app-catalog.sh` embed-loop behavior, and `sign-catalog.sh` human-mnemonic step documented in Architecture Patterns and Common Pitfalls | + + + +## Project Constraints (from CLAUDE.md) + +- **Commit + push every unit of work** — both repos: `botfight` → `origin` (our Gitea, `source.archipelago-foundation.org`); `archy` → `git push gitea-ai main` (main is protected, use the `ai` account). +- **Rootless podman only on Archipelago nodes.** VPS2 itself is explicitly host infra (docker + nginx-proxy-manager), not a node app — the "rootless podman only" invariant does not apply to the VPS2 canonical-arena container, but every node's own `botfights` instance is still installed via the normal rootless podman/Quadlet app-install path. +- **No per-app Rust installers / no OS-level reliance.** The manifest must be fully declarative (image + env + generated_secrets); nothing hand-installed on nodes. +- **Secrets are manifest-declared** (`generated_secrets`, materialised by `container::secrets`, 0600/rootless) — never hardcoded. `JWT_SECRET` MUST use this mechanism (see Common Pitfalls #1). +- **Migrations never destroy data** — existing registered bots (local per-node SQLite) must have a preserve/migrate path before any node is switched into proxy mode, not silently orphaned. +- **Verify on the real node .228 / dev pair before any catalog publish or OTA-adjacent change** — deploy to the dev pair (archi-dev-box + x250-dev) first per the standing process rule, even though this phase's manifest change is catalog-only (no binary OTA). +- **Never commit secrets** — catalog signing is done offline by the user via `scripts/sign-catalog.sh` (paste-mnemonic ceremony); never attempt to script around it. + +## Standard Stack + +### Core (already in place — no new packages required) + +| Library | Version (verified in `package.json`) | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `nostr-tools` | 2.23.3 (both `server/package.json` and `frontend/package.json`) | `verifyEvent`, `generateSecretKey`/`getPublicKey`, `nip19.nsecEncode` | Canonical TS nostr library; already used correctly for NIP-98 verification (`middleware/nip98.ts`) and NIP-07/NIP-55 client flows | +| `hono` | 4.12.6 | HTTP framework, `hono/streaming` (`streamSSE`) for the live fight-event stream | Already the whole server; a reverse-proxy for BOT-03 is a new Hono middleware, not a new framework | +| `drizzle-orm` + `better-sqlite3` | 0.40.1 / 11.9.1 | Bot/fight/queue persistence | Unchanged by this phase; the canonical VPS2 instance keeps using it as-is | +| `zod` | 4.3.6 | Request validation (`lib/validators.ts`) | Already used for every auth/register schema — extend, don't hand-roll new validation | +| `vite-plugin-pwa` | 1.2.0 (frontend devDependency) | Service worker / offline shell | Relevant to BOT-03's offline-behavior tradeoff — the static shell already has PWA caching; only the `/api/*` calls go dark without VPS2 | + +**No new external packages are required for BOT-01, BOT-02, or BOT-03.** A reverse-proxy for BOT-03 can be built with Node's native `fetch`/`ReadableStream` (Node 22, matches the `node:22-slim` Dockerfile base) — no proxy library needed. Because zero new dependencies are introduced, the Package Legitimacy Gate has nothing to check; see the Package Legitimacy Audit section below for the explicit "N/A" disposition. + +### Supporting + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `@hono/node-server` | 1.19.10 | Node HTTP adapter for Hono | Unchanged — the proxy middleware still runs inside the same Hono app instance | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Server-side reverse-proxy (recommended) | Frontend calls VPS2 directly (rewrite all 64 fetch sites to an absolute URL) | Requires CORS config on VPS2 for every node origin, breaks same-origin JWT cookie/localStorage assumptions per node, and mixed-content issues for any node still served over plain `http://`; strictly more surface area for the same outcome | +| Server-side reverse-proxy (recommended) | Full bidirectional sync protocol (CRDT/event-log merge between N SQLite DBs) | Enormous engineering lift for a 5-6 table schema with betting/tournament state; explicitly the option CONTEXT.md's "bias toward simplest" steers away from | +| `generated_secrets` (archy manifest mechanism) | Bake `JWT_SECRET` into the image or manifest `environment:` in plaintext | Violates the CLAUDE.md secrets invariant directly; also every node would share ONE secret if hardcoded, defeating the point of per-install secrets | + +**Installation:** No new package installs needed. Version verification commands (run inside `/home/archipelago/Projects/botfight`) if the planner wants to re-confirm before starting: +```bash +cd /home/archipelago/Projects/botfight && cat server/package.json frontend/package.json | grep -A1 '"nostr-tools"\|"hono"\|"drizzle-orm"' +``` + +## Package Legitimacy Audit + +**No new packages are introduced by this phase.** `nostr-tools`, `hono`, `drizzle-orm`, `zod`, `better-sqlite3`, `vite-plugin-pwa` are all pre-existing dependencies already vendored in `botfight`'s `pnpm-lock.yaml` prior to this phase's start — none require the Package Legitimacy Gate. If the planner's implementation later needs a proxy/streaming helper library (not expected — native `fetch` covers it), run `gsd-tools query package-legitimacy check` on it before adding it to `package.json`. + +**Packages removed due to [SLOP] verdict:** none (N/A — no new packages). +**Packages flagged as suspicious [SUS]:** none (N/A — no new packages). + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌─────────────────────────────┐ ┌─────────────────────────────┐ +│ Node A (e.g. archi-dev-box) │ │ Node B (e.g. x250-dev) │ +│ ┌─────────────────────────┐ │ │ ┌─────────────────────────┐ │ +│ │ botfights container │ │ │ │ botfights container │ │ +│ │ (rootless podman/Quadlet)│ │ │ │ (rootless podman/Quadlet)│ │ +│ │ │ │ │ │ │ │ +│ │ Static Vue SPA ─────────┼─┼─(1)────┼→│ same frontend bundle │ │ +│ │ (served locally, PWA) │ │ │ │ (served locally, PWA) │ │ +│ │ │ │ │ │ │ │ +│ │ Hono server │ │ │ │ Hono server │ │ +│ │ /api/* ─── if │ │ │ │ /api/* ─── if │ │ +│ │ ARENA_UPSTREAM_URL set: │ │ │ │ ARENA_UPSTREAM_URL set: │ │ +│ │ PROXY (2) ──────┐ │ │ │ │ PROXY (2) ──────┐ │ │ +│ │ else: local │ │ │ │ │ else: local │ │ │ +│ │ SQLite (unused │ │ │ │ │ SQLite (unused │ │ │ +│ │ once proxying) │ │ │ │ │ once proxying) │ │ │ +│ └────────────────────┼────────┘ │ │ └────────────────────┼────────┘ │ +└──────────────────────┼──────────┘ └──────────────────────┼──────────┘ + │ │ + │ HTTPS (3), NIP-98 header + JSON/SSE │ + ▼ ▼ + ┌──────────────────────────────────────────────────┐ + │ VPS2 (146.59.87.168) — host infra, docker │ + │ nginx-proxy-manager :443 (Let's Encrypt) │ + │ → arena.archipelago-foundation.org │ + │ │ │ + │ ▼ │ + │ canonical botfights container (docker-compose) │ + │ (STANDALONE mode — ARENA_UPSTREAM_URL unset) │ + │ Hono server + drizzle/SQLite (the ONE real DB) │ + │ - /api/auth/* (NIP-98 verify, JWT issue) │ + │ - /api/bots (anonymous bot registration) │ + │ - /api/fights/* (queue, poll, SSE stream) │ + │ - /api/docs/prompt (unified AI setup prompt) │ + └──────────────────────────────────────────────────┘ +``` + +Trace the primary use case (human logs in and registers a fighter, then an AI agent registers a bot and fights): +1. Browser on Node A loads the local static SPA (fast, same as today). +2. User clicks Login → `window.nostr.getPublicKey()` (NIP-07 extension or Amber via NIP-55) → app builds a NIP-98 kind-27235 event → `POST /api/auth/nostr/session` hits Node A's Hono server. +3. Node A's server, in proxy mode, forwards that exact request (headers + body untouched) over HTTPS to `https://arena.archipelago-foundation.org/api/auth/nostr/session`. +4. VPS2's canonical instance verifies the NIP-98 signature (path-only `u`-tag compare — origin-independent, confirmed by reading `middleware/nip98.ts`), issues a JWT, looks up/creates the bot row in ITS OWN SQLite DB, and returns the response, which Node A streams straight back to the browser unmodified. From here on every fighter/fight the user sees is drawn from the same VPS2 DB regardless of which node's UI they're looking at it through — this is what makes "all nodes see all fighters" true. +5. An AI agent (no browser) reads the unified prompt (served from wherever it's asked — same proxy path applies), `curl -X POST .../api/bots` with just a name — no nostr identity needed for a bot script — gets back `{id, secret}`, and starts polling `/api/fights/poll`. That poll request also proxies through whichever node origin it happened to hit, landing on the same canonical queue. + +### Recommended Project Structure (within `/home/archipelago/Projects/botfight`) + +``` +server/src/ +├── middleware/ +│ ├── arena-proxy.ts # NEW — the BOT-03 reverse-proxy middleware +│ ├── nip98.ts # unchanged — already correct, path-only URL compare +│ └── jwt.ts # unchanged, EXCEPT manifest must now supply JWT_SECRET (see Pitfall #1) +├── routes/ +│ ├── auth.ts # add GET /me (JWT-authenticated) to replace the bare-pubkey auto-restore call +│ └── docs.ts # add GET /api/docs/prompt serving the unified setup prompt (BOT-02) +BOTFIGHTS.md # becomes the canonical unified prompt (already 90% there — extend, don't replace wholesale) +BOTFIGHTS-EASY.md # DELETE — folded into BOTFIGHTS.md's own "tell your AI" framing +frontend/public/docs/ +├── BOTFIGHTS-POLLING.md # DELETE — content merges into BOTFIGHTS.md (already has "Option A/Option B" inline) +└── BOTFIGHTS-WEBHOOK.md # DELETE — same +BOT_SETUP.md # DELETE (or reduce to a redirect stub) — its unique content (customization API, archetype list) merges into BOTFIGHTS.md or docs.ts's JSON, not both +frontend/src/pages/ +└── JoinBoutPage.vue # setupDocPath()/setupDocName() collapse to a single doc + still substitute YOUR_BOT_ID/YOUR_BOT_SECRET placeholders in-place (existing behavior, preserve it) +``` + +``` +apps/botfights/ +└── manifest.yml # (archy repo) version 1.1.0 → 1.2.0, image tag bump, + # + generated_secrets/secret_env for JWT_SECRET (MANDATORY, see Pitfall #1), + # + environment: ARENA_UPSTREAM_URL=https://arena.archipelago-foundation.org (default-on federation) +``` + +### Pattern 1: NIP-98 auth is already origin-independent — safe to proxy unmodified + +**What:** `verifyNip98Token(authHeader, requestPath, requestMethod)` in `server/src/middleware/nip98.ts` extracts the event's `u` tag, does `new URL(urlTag[1]).pathname`, and compares ONLY the pathname against `requestPath` — it never compares scheme/host/port. +**When to use:** This is why BOT-03's reverse-proxy design works without any auth-layer changes: a NIP-98 event the browser signed against `https://node-a.local/api/auth/nostr/session` will still verify correctly when Node A's server forwards the identical request to `https://arena.archipelago-foundation.org/api/auth/nostr/session`, because only `/api/auth/nostr/session` is compared. +**Example (verified, current code):** +```ts +// server/src/middleware/nip98.ts (already shipped, unchanged by this phase) +const urlTag = event.tags?.find((t: string[]) => t[0] === 'u') +const eventPath = new URL(urlTag[1]).pathname +if (eventPath !== requestPath) { + return { valid: false, error: `URL path mismatch: ${eventPath} !== ${requestPath}` } +} +``` +Caveat: the client (`frontend/src/lib/nostr-auth.ts`'s `buildNip98Token`) builds the `u` tag from `window.location.origin` — i.e. Node A's own origin, not VPS2's. That's fine given the path-only compare, but it means the signed event's `u` tag will literally read `https://node-a.local/api/auth/nostr/session` even though it lands on VPS2 — expected and correct, no client change needed. + +### Pattern 2: Reverse-proxy middleware for BOT-03 (new code, sketch) + +**What:** A Hono middleware, mounted before the existing route registrations, that forwards to `ARENA_UPSTREAM_URL` when set — including the SSE stream (`hono/streaming`'s `streamSSE` reads on the upstream side; the proxy just needs to pipe the upstream `Response.body` through untouched, not re-parse SSE frames). +**When to use:** Every node's `botfights` container in production, once `ARENA_UPSTREAM_URL` is set in `apps/botfights/manifest.yml`'s `environment:`. The VPS2 canonical instance itself leaves this var unset and runs standalone (today's code path, untouched). +**Example (illustrative — verify against `app.ts`'s exact router order when planning):** +```ts +// server/src/middleware/arena-proxy.ts (NEW) +import type { Context, Next } from 'hono' + +const UPSTREAM = process.env.ARENA_UPSTREAM_URL + +export async function arenaProxy(c: Context, next: Next) { + if (!UPSTREAM) return next() // standalone mode — fall through to local routers + const target = new URL(c.req.path + (c.req.query() ? '?' + new URLSearchParams(c.req.query()).toString() : ''), UPSTREAM) + const upstreamRes = await fetch(target, { + method: c.req.method, + headers: c.req.raw.headers, + body: ['GET', 'HEAD'].includes(c.req.method) ? undefined : c.req.raw.body, + // @ts-expect-error Node fetch requires duplex for streamed bodies + duplex: 'half', + }) + return new Response(upstreamRes.body, { status: upstreamRes.status, headers: upstreamRes.headers }) +} +// mount in app.ts: app.use('/api/*', arenaProxy) -- BEFORE app.route('/api/auth', authRouter) etc. +``` +Note the `duplex: 'half'` requirement is a real Node 22 `fetch` gotcha for any request with a streamed/body-bearing method — verify it during implementation (Node's undici-backed `fetch` throws `RequestInit: duplex option is required when sending a body` without it). + +### Anti-Patterns to Avoid + +- **Don't rewrite the 64 frontend fetch call sites to point at an absolute VPS2 URL.** It reintroduces CORS, cross-origin JWT storage, and mixed-content problems the proxy design avoids entirely — see Alternatives Considered. +- **Don't build a sync/replication layer between per-node SQLite DBs.** Explicitly the "not the simplest thing" option CONTEXT.md steers away from, and this schema (bots/fights/bets/tournaments/payments) has real invariants (unique pubkey, unique name, ELO, wallet balances) that are hard to merge correctly across independently-diverging copies. +- **Don't hardcode `arena.archipelago-foundation.org` (or whatever subdomain is chosen) inside frontend or server code.** It belongs in the manifest's `environment:` so a node operator retains the documented discretion to unset/override it later (e.g. a fully offline arena) without a code change. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| NIP-98 signature verification | Custom schnorr/secp256k1 verify | `nostr-tools`'s `verifyEvent` (already used in `nip98.ts`) | Already correct and tested; don't touch it | +| JWT issuing/verification | A JWT library dependency | The existing hand-rolled `middleware/jwt.ts` (HMAC-SHA256, `timingSafeEqual`, blacklist map) | It's already implemented correctly and tested (`jwt.test.ts`) — this is the one "hand-rolled" thing in the codebase that's fine to keep as-is; don't introduce `jsonwebtoken`/`jose` mid-phase, that's unrelated churn | +| Secrets provisioning on nodes | Any script/manual step to seed `JWT_SECRET` per node | `container.generated_secrets` + `secret_env` in `apps/botfights/manifest.yml` (see Pattern below) | This is exactly what the mechanism exists for — self-provisioning secrets with zero host provisioning, per the CLAUDE.md invariant | +| Reverse-proxy streaming | A proxy npm package (`http-proxy`, `express-http-proxy`, etc.) | Native `fetch`/`Response` piping (Node 22 undici) | One small middleware function; a dependency is unjustified for forwarding a body/headers/status | + +**Key insight:** almost everything this phase needs already exists in the codebase in a correct, tested form (NIP-98 verify, JWT issuing, zod validation, secrets provisioning in the archy manifest schema). The actual net-new code surface is small: one proxy middleware, one `GET /me` route, one doc consolidation, and manifest edits. + +## Runtime State Inventory + +> BOT-03 changes where match/fighter data lives (per-node SQLite → one canonical VPS2 SQLite), which is a data-migration-shaped change even though the phase isn't a rename. + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | Each node's `botfights-data` volume (`/var/lib/archipelago/botfights` per the manifest's bind mount) holds its OWN independent SQLite `bots`/`fights`/`bets`/`tournaments` tables. Once a node is switched to `ARENA_UPSTREAM_URL` proxy mode, its local DB is never written to again — any bots registered there before the switch become invisible to the shared arena. | **Data migration task**, not just a code edit: before/at BOT-03 rollout, export each node's local `bots` table (`sqlite3 botfights.db "SELECT * FROM bots"` or a small drizzle script) and offer to re-register those bots against the canonical VPS2 DB (dedupe by `publicKey`/lowercased `name` uniqueness — VPS2 already enforces both). Given this app is early/low-registration-count today, confirm actual row counts on each target node before deciding whether this needs to be automated or is a manual one-off — flag as `checkpoint:human-verify` in the plan. | +| Live service config | VPS2's nginx-proxy-manager config (proxy hosts, Let's Encrypt certs) lives in NPM's own SQLite/UI, not in this git repo. | Must be created via NPM's admin UI or API (a `checkpoint:human-action` task) — not a git-tracked artifact, mirrors the memory note "companion/fips subdomains recorded for VPS2 migration" pattern already established for other VPS2 subdomains. | +| OS-registered state | None found — the VPS2 canonical arena is a plain `docker compose up -d` container, no systemd unit, no Task Scheduler equivalent. | None. | +| Secrets/env vars | `JWT_SECRET` currently has NO value anywhere on any deployed node's `apps/botfights/manifest.yml` (only `NODE_ENV=production` is set) — see Common Pitfalls #1. `BOTFIGHTS_CREATOR_PUBKEYS` defaults to a hardcoded value in `docker-compose.yml` (`da5e0c1b...`) — not secret, just a pubkey allowlist; leave as a manifest `environment:` plain value, not `generated_secrets`. | Add `generated_secrets`/`secret_env` for `JWT_SECRET` (mandatory — see Pitfall #1). `BOTFIGHTS_CREATOR_PUBKEYS` unaffected by this phase (out of scope — creator/tournament logic). | +| Build artifacts / installed packages | The `146.59.87.168:3000/lfg2025/botfights:1.1.0` image tag is referenced by BOTH `apps/botfights/manifest.yml` (archy) AND `app-catalog/catalog.json` (legacy secondary catalog file, checked for drift by `scripts/check-app-catalog-drift.py`). | Both files must be bumped together to the new tag (1.2.0), or `check-app-catalog-drift.py` will flag drift. `releases/app-catalog.json` is NOT hand-edited — it's regenerated from `apps/*/manifest.yml` by `scripts/generate-app-catalog.sh`, so only `apps/botfights/manifest.yml` and `app-catalog/catalog.json` need manual edits; `releases/app-catalog.json` is a build output. | + +## Common Pitfalls + +### Pitfall 1: `JWT_SECRET` unset + `NODE_ENV=production` crash-loops the container at startup (BLOCKING) + +**What goes wrong:** `server/src/middleware/jwt.ts` line 4-6 executes `if (!process.env.JWT_SECRET && process.env.NODE_ENV === 'production') { throw new Error('JWT_SECRET required in production') }` at MODULE IMPORT time (not lazily, unlike the wallet-encryption-key check in `engine/crypto.ts` which only throws when a wallet op is actually invoked). `apps/botfights/manifest.yml`'s current `environment:` block is just `- NODE_ENV=production` — no `JWT_SECRET`. Any image built from current `main` (which the 1.2.0 build for this phase will be, since it includes the NIP-98/JWT commits) will throw on the very first `node server/dist/index.js` and the container will crash-loop indefinitely. +**Why it happens:** The auth code was added to `main` after `apps/botfights/manifest.yml` was last updated for image 1.1.0 (manifest predates the JWT requirement, or 1.1.0 was tagged before `3ba05a6`); nobody has re-synced the manifest's env vars to what the code now requires. +**How to avoid:** Add to `apps/botfights/manifest.yml`: +```yaml +container: + generated_secrets: + - name: botfights-jwt-secret + kind: hex32 # 32 random bytes, 64 hex chars — matches the app's `openssl rand -hex 32` expectation exactly + secret_env: + - key: JWT_SECRET + secret_file: botfights-jwt-secret +``` +This is the identical pattern already shipped for `apps/netbird-server/manifest.yml` (`base64` kind) and the fedimint-gateway fix (FED-07, same milestone) — verified against `core/container/src/manifest.rs`'s `GeneratedSecret`/`SecretGenKind` types. +**Warning signs:** After deploying the new image, `podman ps` shows the container in a restart loop; `podman logs botfights` shows `Error: JWT_SECRET required in production` as the very first line. + +### Pitfall 2: SSE proxying needs true byte-stream passthrough, not response buffering + +**What goes wrong:** If the BOT-03 proxy middleware calls `await upstreamRes.text()` (or otherwise buffers the body) before returning, the live fight-event SSE stream (`hono/streaming`'s `streamSSE` in `routes/fights.ts`, `MAX_SSE_PER_IP = 5`) will never deliver events — the client will hang waiting for a response that only arrives after the upstream connection closes (which for a fight-duration SSE stream may be minutes). +**Why it happens:** The most naive proxy implementation (`fetch` + `res.json()` + re-`c.json()`) is correct for normal REST calls but silently breaks streaming endpoints. +**How to avoid:** Return `new Response(upstreamRes.body, ...)` (pass the `ReadableStream` straight through) as shown in Pattern 2 above — verify this specifically against the SSE route path during implementation with a manual curl test (`curl -N https://node-a.local/api/fights//stream` should show events arriving incrementally, not all at once at connection close). +**Warning signs:** Fight replays/live viewing works fine when hitting VPS2 directly but appears frozen/late when viewed through a node's proxied instance. + +### Pitfall 3: `app-catalog/catalog.json` is a separate, hand-maintained file from `releases/app-catalog.json` + +**What goes wrong:** Bumping only `apps/botfights/manifest.yml` and regenerating `releases/app-catalog.json` (the signed, node-consumed one) leaves the legacy `app-catalog/catalog.json` (checked by `scripts/check-app-catalog-drift.py`, used by an older/parallel marketplace-catalog code path per its own README) pointing at the stale `1.1.0` `dockerImage` tag. +**Why it happens:** Two catalog files exist in this repo for historical reasons; only one (`releases/app-catalog.json`) is the cryptographically-signed, orchestrator-consumed one described in `docs/registry-manifest-design.md`. +**How to avoid:** Update `app-catalog/catalog.json`'s `botfights` entry's `version`/`dockerImage` fields by hand alongside the manifest bump, then run `scripts/check-app-catalog-drift.py` to confirm no drift before considering BOT-04 done. +**Warning signs:** `check-app-catalog-drift.py` (if run in CI or manually) reports a version mismatch for `botfights`. + +### Pitfall 4: `BOTFIGHTS.md`'s in-app placeholder substitution must survive the doc consolidation + +**What goes wrong:** `frontend/src/pages/JoinBoutPage.vue`'s `toggleSetupContent()` fetches `setupDocPath()` (currently `/docs/BOTFIGHTS-POLLING.md` or `/docs/BOTFIGHTS-WEBHOOK.md`) and does `content.replace(/YOUR_BOT_ID/g, botId.value)` / `.../YOUR_BOT_SECRET/g, botSecret.value)` to hand the user a ready-to-paste, credential-filled doc immediately after registration. If BOT-02's consolidation deletes those two files without updating `setupDocPath()`/`setupDocName()` to point at the new single doc, this in-app "copy your personalized setup" feature silently 404s. +**Why it happens:** Doc consolidation is easy to do file-by-file and miss a call site that references the old filenames. +**How to avoid:** Grep `frontend/src` for `BOTFIGHTS-POLLING\|BOTFIGHTS-WEBHOOK\|BOTFIGHTS-EASY\|BOT_SETUP` before deleting any doc file (all four found via this research: `JoinBoutPage.vue`, `BotProfilePage.vue`, and `DocsPage.vue`'s own inline copies) and update every reference to the new consolidated filename/route. +**Warning signs:** e2e `signup-bot.spec.ts`/`signup-human.spec.ts` (if extended to click "show setup code") would catch this; at minimum, manual click-through after the doc rename. + +### Pitfall 5: e2e coverage never exercises a real NIP-07 provider + +**What goes wrong:** `e2e/signup-human.spec.ts`/`signup-bot.spec.ts` (Playwright) contain no `window.nostr` references — they exercise the local-generated-key (`generateLogin`) dev path, not a real browser extension or Amber. Marking BOT-01 "done" on green e2e tests alone would miss the actual user-facing risk (a real signer's `getPublicKey()`/`signEvent()` round-trip, timing, and Amber's NIP-55 redirect-based flow on Android). +**Why it happens:** Driving a real browser extension from Playwright requires either a pre-packaged extension loaded into the test browser context or a scripted `window.nostr` mock (`page.addInitScript`) — neither exists today. +**How to avoid:** Per CLAUDE.md's "test before claiming fixed" rule, this phase's plan must include a manual real-browser + real-extension login test (and, if feasible, a real Amber-on-Android NIP-55 test) as an explicit verification step — not rely on the existing green e2e suite as sufficient evidence. +**Warning signs:** None automatable — this is exactly why it needs a manual checkpoint. + +## Code Examples + +### Registering a bot anonymously (already-shipped endpoint — BOT-02's prompt must document this) + +```bash +# Source: server/src/routes/bots.ts POST / (verified current code, rate-limited 5/hour/IP) +curl -X POST https:///api/bots \ + -H "Content-Type: application/json" \ + -d '{"name": "my_bot"}' +# → { "id": "...", "secret": "...", "webhookUrl": "http://poll.local/", ... } (poll mode, no public URL needed) +``` + +### GET /me pattern to close the last bare-pubkey gap (BOT-01) + +```ts +// server/src/routes/auth.ts — NEW, mirrors the existing regenerate-secret pattern +// Source: existing extractPubkeyFromAuth in middleware/jwt.ts, already used elsewhere in this file +authRouter.get('/me', async (c) => { + const pubkey = extractPubkeyFromAuth(c.req.header('Authorization')) + if (!pubkey) return c.json({ error: 'Authentication required.' }, 401) + const rows = await db.select({ /* same projection as /nostr/session */ }) + .from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) + return c.json({ exists: rows.length > 0, bot: rows[0] ?? null }) +}) +``` +```ts +// frontend/src/composables/useNostr.ts — replace the auto-restore block's +// `authFetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ pubkey: pubkey.value }) })` +// with a GET that relies purely on the already-valid JWT, no bare pubkey in the body: +authFetch('/api/auth/me').then(r => r.json()).then(data => { /* ... */ }) +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|---------------|--------| +| `POST /api/auth/login` with a bare, unverified pubkey (the original design this phase is meant to replace) | `POST /api/auth/nostr/session` with a NIP-98 signed event, JWT issued | Already shipped on `main` (`3ba05a6`), predates this research | The phase's BOT-01 work is auditing/finishing this, not building it | +| 4-5 separate markdown docs + a UI tab set duplicating the same content | Single `BOTFIGHTS.md` already written "for your AI to read" | `BOTFIGHTS.md` already exists in this form; fragmentation is the remaining problem | BOT-02 is consolidation, not authoring | + +**Deprecated/outdated:** +- `BOTFIGHTS-EASY.md`, `BOTFIGHTS-POLLING.md`, `BOTFIGHTS-WEBHOOK.md`, `BOT_SETUP.md`: superseded by the consolidated `BOTFIGHTS.md`/prompt endpoint once BOT-02 lands — delete or redirect, don't leave live and diverging. +- `POST /api/auth/login` (bare pubkey): keep only as the currently-used lookup-by-pubkey internal helper if still needed elsewhere, but the client should stop calling it for anything session-related (see Pitfall/Code Example above); do not expose it as a login mechanism in any new docs/prompt. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | The subdomain for the canonical VPS2 arena will follow the existing `.archipelago-foundation.org` pattern (e.g. `arena.archipelago-foundation.org`) and can be created via nginx-proxy-manager's UI/API with a fresh Let's Encrypt cert, the same way `source.archipelago-foundation.org` and `demo.archipelago-foundation.org` already exist. | Architecture Patterns diagram, Runtime State Inventory | If DNS for `archipelago-foundation.org` isn't fully under the user's control or NPM API access differs from assumed, the exact subdomain-creation steps in the plan need adjusting — low risk, this is standard NPM usage already established for this domain. | +| A2 self-check | The count/severity of pre-existing local per-node bot registrations that would need migrating in BOT-03's Runtime State Inventory item was NOT measured (no node was queried for row counts in this research pass). | Runtime State Inventory | If a node already has meaningful production bot registrations, the "manual one-off" framing undersells the migration work; the plan should verify actual row counts on real nodes before scoping this as trivial. | +| A3 | Building the new `botfights:1.2.0` image can be done with `podman build`/`podman push --tls-verify=false` to `146.59.87.168:3000/lfg2025/botfights:1.2.0`, mirroring the pattern already used by `scripts/build-bitcoin-image.sh` for the same registry. | Environment Availability | If the vps2 Gitea registry requires a `podman login` step not yet captured in this research (credentials weren't located for a botfights-specific push), the plan needs an explicit credential-setup task first. | + +**If this table is empty:** N/A — see rows above. + +## Open Questions + +1. **Exact NPM/DNS steps for the new VPS2 subdomain** + - What we know: VPS2 runs nginx-proxy-manager on :80/:443/:81(admin)/:8443/:9443 already, fronting other subdomains under `archipelago-foundation.org`. + - What's unclear: The precise NPM admin UI/API credentials and the DNS provider/access needed to add a new subdomain record — not documented in this repo's memory or docs. + - Recommendation: Plan this as a `checkpoint:human-action` task (create the NPM proxy host + point DNS + confirm cert issuance), not something Claude can complete unattended. + +2. **Whether the canonical VPS2 arena should reuse the existing `docker-compose.yml` as-is or needs its own tuned copy** + - What we know: `docker-compose.yml` already binds `9100:9100`, sets `FIGHT_LOOP_ENABLED=true` (mock-bot background activity for site liveliness) and references `BOTFIGHTS_WALLET_ENCRYPTION_KEY`/`BOTFIGHTS_NWC_URL`/`BOTFIGHTS_CASHU_MINT_URL` (all payments-related, out of scope for this phase). + - What's unclear: Whether the user wants payments/wallet features live on the public arena (out of scope per CONTEXT.md, but the existing compose file half-wires them) or explicitly left unset/disabled there. + - Recommendation: Deploy the canonical instance with payments env vars left unset (matches "OUT: Lightning/cashu payment changes") and confirm with the user during planning/discuss if this surfaces as a UX gap (e.g. a "Bet" button that 500s). + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Docker (on VPS2, 146.59.87.168) | BOT-03 canonical arena deploy | ✓ (per memory `reference_ovh_168_mirror.md`) | not re-verified this session | — | +| Rootless podman (on Archipelago nodes) | Every node's `botfights` container (unchanged install path) | ✓ (fleet invariant) | — | — | +| VPS2 unused host port for the canonical container | BOT-03 | Likely ✓ — port 9100 is NOT in the documented occupied-ports list (80/81/443/8443/9443 NPM; 8080/1935 owncast; 8000/8092/8123/7788/3009; 3000/2222 Gitea) | — | Verify with `ss -tlnp` on VPS2 before binding; NPM can forward from 443 to a `127.0.0.1`-only bind so the raw port doesn't even need to be public | +| Container registry push access to `146.59.87.168:3000/lfg2025/` | BOT-04 image build+push | Unverified this session (credentials pattern seen for other images via `podman push --tls-verify=false`, not confirmed specifically for a fresh `botfights` push) | — | If push fails, confirm registry credentials/Gitea package-registry settings before the plan's image-push task | +| `scripts/sign-catalog.sh`'s prebuilt signer binary (`/tmp/archy-sign-bin/release/archipelago` or `core/target/release/archipelago`) | BOT-04 catalog signing | Must be built fresh each session (script explicitly refuses to compile itself) | — | Plan must include "build the release signer binary" as a prerequisite task before the human-mnemonic signing checkpoint | + +**Missing dependencies with no fallback:** +- None identified as fully blocking — all gaps above have either a fallback or are one verification command away from confirmed. + +**Missing dependencies with fallback:** +- Registry push credentials (verify at plan/execute time, not blocking research). + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Vitest (workspace: `server` project + `frontend/vitest.config.ts`), Playwright for e2e (`e2e/playwright.config.ts`) | +| Config file | `botfight/vitest.workspace.ts`, `botfight/frontend/vitest.config.ts`, `botfight/e2e/playwright.config.ts` | +| Quick run command | `cd /home/archipelago/Projects/botfight && pnpm test -- --run` (unit, both projects) | +| Full suite command | `pnpm test -- --run && pnpm test:e2e` (adds Playwright e2e) | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| BOT-01 | NIP-98 verification correctness (kind, url path, method, timestamp window, signature) | unit | `pnpm vitest run server/src/middleware/nip98.test.ts` | ✅ exists | +| BOT-01 | JWT issue/verify/blacklist correctness | unit | `pnpm vitest run server/src/middleware/jwt.test.ts` | ✅ exists | +| BOT-01 | `/api/auth/nostr/session` route behavior, auth edge cases | unit/integration | `pnpm vitest run server/src/routes/auth.test.ts server/src/routes/auth-edge.test.ts server/src/routes/auth-audit.test.ts` | ✅ exist | +| BOT-01 | Real signer login (NIP-07 extension) end-to-end | manual (no automated harness) | N/A — manual browser test with a real extension (e.g. nos2x/Alby) | ❌ Wave 0 gap — no `window.nostr` mock in Playwright config | +| BOT-01 | New `GET /api/auth/me` route (replaces bare-pubkey auto-restore) | unit | New test file, e.g. `server/src/routes/auth-me.test.ts` | ❌ Wave 0 — write alongside the route | +| BOT-02 | Registration + poll/webhook flow works via the documented prompt exactly as written | e2e | `pnpm test:e2e -- e2e/signup-bot.spec.ts` (extend to assert the doc's curl example matches live behavior) | ✅ exists (signup-bot.spec.ts), extend coverage | +| BOT-03 | Reverse-proxy forwards REST + SSE correctly, both request and response | integration | New test, e.g. `server/src/middleware/arena-proxy.test.ts` mocking an upstream Hono instance | ❌ Wave 0 — write alongside the middleware | +| BOT-04 | `apps/botfights/manifest.yml` validates against the archy schema (no crash-loop risk from missing secrets) | unit (archy repo) | `cd core && cargo test -p container manifest` (or the specific `AppManifest::validate` test covering `generated_secrets`) | Partially — generic validate tests exist in `core/container/src/manifest.rs`; no botfights-specific fixture test today | + +### Sampling Rate +- **Per task commit:** `pnpm test -- --run` (botfight repo, unit only, fast) +- **Per wave merge:** `pnpm test -- --run && pnpm test:e2e` (botfight repo) + `cargo test -p container` (archy repo, for the manifest change) +- **Phase gate:** Full suite green in `botfight` + a real-browser manual NIP-07 login test + a real cross-node fight visible from two node instances (per CLAUDE.md's "test the real user path" rule, also explicitly required by CONTEXT.md's Constraints section) before `/gsd-verify-work`. + +### Wave 0 Gaps +- [ ] `server/src/routes/auth-me.test.ts` — covers the new `GET /api/auth/me` route (BOT-01) +- [ ] `server/src/middleware/arena-proxy.test.ts` — covers REST + SSE forwarding correctness (BOT-03) +- [ ] Manual test procedure doc (not a test file, but should be written down as an execution-phase checklist) — real NIP-07 extension login + real Amber NIP-55 login + real cross-node fight visibility (BOT-01/BOT-03, non-automatable per Pitfall #5) +- [ ] No framework install needed — Vitest/Playwright already configured and used. + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | NIP-07/NIP-98 signer-based auth (already implemented) — no passwords, private key never touches the app; keep using `nostr-tools`' `verifyEvent`, never hand-roll signature checks | +| V3 Session Management | yes | JWT (HS256, 24h expiry, logout blacklist) already implemented in `middleware/jwt.ts` — keep the `timingSafeEqual` comparison, don't switch to `===` | +| V4 Access Control | yes | Bot ownership tied to unique `publicKey`/`name` DB constraints; NIP-98 required to mutate a bot's own settings; `GET /me` (new) must require a valid JWT, never accept a bare pubkey for anything beyond public leaderboard-style reads | +| V5 Input Validation | yes | `zod` schemas in `lib/validators.ts` (already used for every auth/register endpoint) — extend for any new proxy/route inputs, don't hand-roll | +| V6 Cryptography | yes | `JWT_SECRET` MUST come from `container.generated_secrets` (`kind: hex32`), never hardcoded/shared across nodes — see Pitfall #1; NIP-98/JWT crypto primitives are library-provided, not hand-rolled | + +### Known Threat Patterns for this stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| NIP-98 replay within the 120s freshness window (a captured signed event could be replayed against the same endpoint until it expires — no consumed-nonce/jti tracking exists today) | Spoofing / Replay | Pre-existing limitation, not introduced by this phase; HTTPS-only deployment (already required — VPS2 is behind NPM+Let's Encrypt, and node-local access is typically LAN/Tailscale) substantially mitigates interception risk. Not a required fix for this phase's scope, but worth naming to the user as a known, accepted tradeoff rather than silently unaddressed. | +| Anonymous bot registration (`POST /api/bots`) abuse (mass bot creation) | Denial of Service | Already rate-limited (5/hour/IP, `rateLimit(3600_000, 5)` in `bots.ts`) — unchanged by this phase, confirm it still applies once requests are proxied (verify the proxy forwards the real client IP via a header the rate-limiter reads, or that VPS2's own rate limiter — now the one actually enforcing the limit in proxy mode — sees the originating client IP correctly and not every node's own IP as a single bucket) | +| Reverse-proxy header/IP handling collapsing all requests from a node into one apparent "client" | Denial of Service / Spoofing | The proxy must forward (or set) an `X-Forwarded-For`-style header with the real client IP so VPS2's per-IP rate limiting (`rateLimit` middleware, IP-keyed) doesn't either (a) rate-limit an entire node's user base as one IP or (b) become trivially bypassable by rotating which node a request is routed through. Flag as an explicit BOT-03 implementation requirement, not just a proxy nicety. | +| SSRF via bot webhook URLs | Tampering | Already mitigated — `isAllowedWebhookUrl` in `engine/orchestrator.ts` blocks private/internal addresses; unchanged by this phase, don't weaken it while touching adjacent auth code | + +## Sources + +### Primary (HIGH confidence — direct codebase read this session) +- `/home/archipelago/Projects/botfight/server/src/routes/auth.ts`, `middleware/nip98.ts`, `middleware/jwt.ts`, `middleware/bot-auth.ts`, `routes/bots.ts`, `routes/docs.ts`, `app.ts` — full auth/routing surface read +- `/home/archipelago/Projects/botfight/frontend/src/composables/useNostr.ts`, `lib/nostr-auth.ts`, `pages/DocsPage.vue`, `pages/JoinBoutPage.vue`, `components/NavBar.vue` — full client auth/UX surface read +- `/home/archipelago/Projects/botfight/nostr-login-implementation.md`, `BOTFIGHTS.md`, `BOT_SETUP.md`, `frontend/public/docs/BOTFIGHTS-{EASY,POLLING,WEBHOOK}.md` — full doc-fragmentation audit +- `/home/archipelago/Projects/botfight/docker-compose.yml`, `Dockerfile`, `deploy.sh`, `server/package.json`, `frontend/package.json`, `vitest.workspace.ts`, `e2e/` listing — deployment + test infra +- `git log --oneline` in `botfight` confirming `3ba05a6` "feat: NIP-98 + JWT authentication with signer support" and 4 follow-up fix commits already on `main` +- `/home/archipelago/Projects/archy/apps/botfights/manifest.yml`, `apps/netbird-server/manifest.yml`, `core/container/src/manifest.rs` (`GeneratedSecret`/`SecretGenKind`/`secret_env`) — exact secrets-provisioning pattern +- `/home/archipelago/Projects/archy/scripts/generate-app-catalog.sh`, `scripts/sign-catalog.sh`, `scripts/check-app-catalog-drift.py`, `app-catalog/catalog.json`, `docs/registry-manifest-design.md` — catalog publish pipeline +- `/home/archipelago/Projects/archy/.planning/phases/09-botfights-platform-upgrade/09-CONTEXT.md`, `.planning/REQUIREMENTS.md`, `.planning/STATE.md`, `/home/archipelago/Projects/archy/CLAUDE.md` + +### Secondary (MEDIUM confidence) +- `~/.claude/.../memory/reference_ovh_168_mirror.md`, `reference_servers.md` (agent long-term memory, 99 days old at read time — flagged as point-in-time, verify port occupancy with `ss -tlnp` before relying on it for BOT-03's VPS2 port choice) + +### Tertiary (LOW confidence) +- None used — this phase's research was entirely first-party codebase/repo reads, no external web search was needed given the domain (auditing an existing, fully-owned codebase). + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — no new packages, all versions read directly from `package.json` +- Architecture (BOT-03 proxy design): HIGH — grounded in a verified 64/64 relative-fetch-path grep and a verified path-only NIP-98 URL compare; MEDIUM on the exact VPS2 subdomain/NPM steps (Open Question #1) +- Pitfalls: HIGH — the `JWT_SECRET` crash-loop finding is a direct code read (`middleware/jwt.ts` line 4-6) cross-referenced against the actual current manifest content, not inferred + +**Research date:** 2026-07-30 +**Valid until:** 2026-08-13 (30 days for the archy-side catalog/manifest tooling, which is stable; re-verify the `botfight` repo's `main` branch state at planning time if execution is delayed more than a few days, since it's an actively-developed separate repo with commits landing outside this phase's control)