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)
- BOT-01 — Native nostr signer login. Replace the bare-pubkey
POST /api/auth/logintrust 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'snostr-login-implementation.md. The user never shares a private key with the app. - 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."
- 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.orgavailable, 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. - 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/promptor/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:
botfight→origin(our Gitea,source.archipelago-foundation.org);archy→git push gitea-ai main(main is protected, use theaiaccount). - 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
botfightsinstance 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 bycontainer::secrets, 0600/rootless) — never hardcoded.JWT_SECRETMUST 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):
- Browser on Node A loads the local static SPA (fast, same as today).
- 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/sessionhits Node A's Hono server. - 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. - VPS2's canonical instance verifies the NIP-98 signature (path-only
u-tag compare — origin-independent, confirmed by readingmiddleware/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. - An AI agent (no browser) reads the unified prompt (served from wherever it's asked — same proxy path applies),
curl -X POST .../api/botswith 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):
// 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'senvironment: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 consolidatedBOTFIGHTS.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
-
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-actiontask (create the NPM proxy host + point DNS + confirm cert issuance), not something Claude can complete unattended.
- What we know: VPS2 runs nginx-proxy-manager on :80/:443/:81(admin)/:8443/:9443 already, fronting other subdomains under
-
Whether the canonical VPS2 arena should reuse the existing
docker-compose.ymlas-is or needs its own tuned copy- What we know:
docker-compose.ymlalready binds9100:9100, setsFIGHT_LOOP_ENABLED=true(mock-bot background activity for site liveliness) and referencesBOTFIGHTS_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).
- What we know:
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 newGET /api/auth/meroute (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 infragit log --onelineinbotfightconfirming3ba05a6"feat: NIP-98 + JWT authentication with signer support" and 4 follow-up fix commits already onmain/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 withss -tlnpbefore 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_SECRETcrash-loop finding is a direct code read (middleware/jwt.tsline 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)