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)