Files

55 KiB

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

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

</user_constraints>

<phase_requirements>

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

</phase_requirements>

Project Constraints (from CLAUDE.md)

  • Commit + push every unit of work — both repos: botfightorigin (our Gitea, source.archipelago-foundation.org); archygit 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://<lan-ip>; 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:

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

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

// 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:

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/<id>/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)

# Source: server/src/routes/bots.ts POST / (verified current code, rate-limited 5/hour/IP)
curl -X POST https://<arena-host>/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)

// 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 })
})
// 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 <name>.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)