# Phase 9: BotFights Platform Upgrade - Pattern Map **Mapped:** 2026-07-30 **Files analyzed:** 11 (new/modified across `botfight` + `archy`) **Analogs found:** 11 / 11 (all in-repo — every analog is an existing sibling file in the same codebase; this is an audit/harden/extend phase, not greenfield) **NOTE on repo split:** BOT-01/02/03 analogs live in `/home/archipelago/Projects/botfight`. BOT-04 analogs live in `/home/archipelago/Projects/archy`. Do not cross-copy patterns between the two repos — they have unrelated tech stacks (Hono/Vue/drizzle vs Rust orchestrator + bash catalog tooling). ## File Classification | New/Modified File | Repo | Role | Data Flow | Closest Analog | Match Quality | |---|---|---|---|---|---| | `server/src/routes/auth.ts` — add `GET /me` | botfight | route (Hono) | request-response | same file, `POST /login` handler (lines 29-136) | exact (same file, same router) | | `frontend/src/composables/useNostr.ts` — replace auto-restore POST /login | botfight | composable | request-response | same file, other `authFetch(...)` call sites (register/update/regenerate-secret, lines 301-410) | exact | | `server/src/middleware/arena-proxy.ts` (NEW) | botfight | middleware | streaming + request-response (reverse proxy) | `server/src/middleware/nip98.ts` + `server/src/middleware/rate-limit.ts` (middleware shape) and `app.ts`'s `app.use('/api/*', ...)` mounting convention | role-match (no existing proxy middleware, but middleware skeleton + mount pattern is exact) | | `server/src/middleware/arena-proxy.test.ts` (NEW) | botfight | test | unit/integration | `server/src/routes/auth.test.ts`, `server/src/middleware/nip98.test.ts` | role-match | | `server/src/routes/auth-me.test.ts` (NEW) | botfight | test | request-response | `server/src/routes/auth.test.ts` (lines 1-90) | exact | | `BOTFIGHTS.md` (consolidated unified prompt) | botfight | doc/content | file-I/O (static markdown, served + fetched) | existing `BOTFIGHTS.md` itself (extend in place) + `server/src/routes/docs.ts` (JSON doc route shape, lines 1-70) | exact | | `server/src/routes/docs.ts` — add `GET /prompt` | botfight | route (Hono) | request-response | same file's `GET /webhook` handler (lines 6-63) | exact | | `frontend/src/pages/JoinBoutPage.vue` — collapse `setupDocPath()`/`setupDocName()` | botfight | component (Vue) | request-response (fetch + string templating) | same file, existing `toggleSetupContent()` / placeholder-substitution logic | exact | | `e2e/*.spec.ts` — extend `signup-bot.spec.ts` coverage | botfight | test (Playwright) | request-response | `e2e/signup-bot.spec.ts` (43 lines, full file read) | exact | | `apps/botfights/manifest.yml` — bump to 1.2.0 + `generated_secrets`/`secret_env` + `ARENA_UPSTREAM_URL` | archy | config (app manifest) | CRUD (declarative config, orchestrator-applied) | `apps/netbird-server/manifest.yml` (`generated_secrets` + `secret_env`/`{{secret:...}}` pattern) | exact | | `app-catalog/catalog.json` — bump botfights `dockerImage`/`version` | archy | config (legacy catalog file) | CRUD | same file's `botfights` entry (lines 110-124) | exact (edit in place) | `releases/app-catalog.json` is a **build output**, not hand-edited — regenerated via `scripts/generate-app-catalog.sh` from `apps/*/manifest.yml`. No pattern needed; just run the script, then `scripts/sign-catalog.sh`. ## Pattern Assignments ### `server/src/routes/auth.ts` — `GET /me` (route, request-response) **Analog:** same file, `POST /login` (lines 1-136), plus `extractPubkeyFromAuth` from `server/src/middleware/jwt.ts`. **Imports pattern** (lines 1-11): ```ts import { Hono } from 'hono' import { nanoid } from 'nanoid' import { createHash, randomBytes } from 'crypto' import { db, schema } from '../db/index.js' import { eq, sql } from 'drizzle-orm' import { isAllowedWebhookUrl } from '../engine/orchestrator.js' import { validateCustomization } from '../engine/customization.js' import { testWebhook } from '../engine/webhook-test.js' import { rateLimit } from '../middleware/rate-limit.js' import { isCreatorPubkey } from '../lib/constants.js' import { loginSchema, registerSchema, registerHumanSchema, updateBotSchema, pubkeySchema, formatZodError } from '../lib/validators.js' ``` For the new route add `import { extractPubkeyFromAuth } from '../middleware/jwt.js'`. **Auth pattern** — JWT Bearer, not a bare pubkey body (this is the whole point of BOT-01's fix): ```ts // server/src/middleware/jwt.ts lines 93-98 (already shipped, reuse as-is) export function extractPubkeyFromAuth(authHeader: string | undefined): string | null { if (!authHeader?.startsWith('Bearer ')) return null const payload = verifyJwt(authHeader.slice(7)) return payload?.sub || null } ``` **Core route pattern** — mirrors the `bots` projection/lookup shape already used in `POST /login` (lines 36-53), but gated by JWT instead of accepting `{pubkey}` in the body: ```ts 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 POST /login, lines 36-52 */ }) .from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) if (rows.length === 0) return c.json({ exists: false }) return c.json({ exists: true, bot: { /* same shape as POST /login's response, lines 114-135 */ } }) }) ``` **Error handling pattern:** this codebase uses direct `c.json({ error: '...' }, statusCode)` returns, never throw+catch inside route handlers — the top-level `app.onError` in `app.ts` (lines 30-34) is the only catch-all, for unexpected exceptions: ```ts // server/src/app.ts lines 30-34 app.onError((err, c) => { appLogger.error('app', `ERROR: ${err.message} ${err.stack}`) const msg = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message return c.json({ error: msg }, 500) }) ``` **Rate limiting convention** (apply per-endpoint sensitivity, see `bots.ts`/`auth.ts` for the range): `rateLimit(windowMs, maxRequests)` as a per-route Hono middleware arg, e.g. `authRouter.post('/login', rateLimit(60_000, 10), async (c) => {...})`. `GET /me` (read of your own JWT-authenticated identity) likely doesn't need its own limiter — it's covered by the global `/api/*` limiter already mounted in `app.ts` (line 71: `rateLimit(60_000, 300)`). --- ### `frontend/src/composables/useNostr.ts` — replace auto-restore call (composable, request-response) **Analog:** same file's other `authFetch` call sites (register: line 301, update: 352/376, regenerate-secret: 394, register-human: 410) and `frontend/src/lib/nostr-auth.ts`'s `authFetch` wrapper (lines 99-110). **Current (bad) pattern to replace** (lines 117-130): ```ts if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) { autoRestoreRan = true; (globalThis as any).__bf_autoRestoreRan = true authFetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value }), }).then(r => r.json()).then(data => { if (data.exists) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) } }).catch(err => console.warn('[Nostr] auto-restore failed:', err)) } ``` **New pattern** (per RESEARCH.md's Code Examples section — reuses the exact same `authFetch` import already at line 5, just swaps method/URL/body): ```ts authFetch('/api/auth/me').then(r => r.json()).then(data => { if (data.exists) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) } }).catch(err => console.warn('[Nostr] auto-restore failed:', err)) ``` **Auth transport pattern** — `authFetch` already attaches the Bearer token and clears it on 401 (`frontend/src/lib/nostr-auth.ts` lines 99-110): ```ts export async function authFetch(url: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers) if (currentToken && !isTokenExpired()) { headers.set('Authorization', `Bearer ${currentToken}`) } const res = await fetch(url, { ...init, headers }) if (res.status === 401) setToken(null) return res } ``` No changes needed to `nostr-auth.ts` itself — `GET /api/auth/me` needs zero request body, `authFetch` already does everything required. --- ### `server/src/middleware/arena-proxy.ts` (NEW middleware, streaming + request-response) **Analog:** middleware shape from `server/src/middleware/nip98.ts` (pure function, typed result) is NOT the right shape here (that's a verifier, not a Hono middleware); the correct analog for the *Hono middleware signature + mount convention* is `app.ts`'s existing `app.use('/api/*', ...)` calls (lines 41-45, 68, 71, 74-80) and `rate-limit.ts`'s middleware factory pattern (`rateLimit(windowMs, max)` returns a Hono middleware function, referenced at `bots.ts` line 34 and `auth.ts` line 29). **Mount-order pattern** (must come BEFORE the route registrations, `app.ts` lines 97-107): ```ts // app.ts — insert right after the body-limit/rate-limit middleware (line 71/80), before app.route(...) calls: import { arenaProxy } from './middleware/arena-proxy.js' app.use('/api/*', arenaProxy) // no-ops (calls next()) when ARENA_UPSTREAM_URL is unset app.route('/api/auth', authRouter) app.route('/api/bots', botsRouter) // ... ``` **Core pattern (from RESEARCH.md Pattern 2, already vetted against this exact codebase's Hono/Node version):** ```ts 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 }) } ``` **Critical pitfall (must not violate):** never `await upstreamRes.text()`/`.json()` and re-wrap — that buffers and breaks the SSE fight-stream route in `routes/fights.ts` (uses `hono/streaming`'s `streamSSE`). Pass `upstreamRes.body` straight through as shown above. **Rate-limit/IP-forwarding requirement (Security Domain in RESEARCH.md):** forward the real client IP so VPS2's per-IP `rateLimit` middleware (IP-keyed) doesn't collapse an entire node's users into one bucket — set/forward `X-Forwarded-For` in the proxied request headers. --- ### `server/src/middleware/arena-proxy.test.ts` (NEW test, unit/integration) **Analog:** `server/src/routes/auth.test.ts` (Vitest, mounts a bare `Hono` app + the router under test, uses `app.request(path, init)`, full file read — 219 lines) and `server/src/middleware/nip98.test.ts` (163 lines, tests a pure function with `finalizeEvent` from `nostr-tools` to build fixtures). **Test harness pattern** (from `auth.test.ts` lines 1-20): ```ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { Hono } from 'hono' import { authRouter } from './auth.js' import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools' const app = new Hono() app.route('/api/auth', authRouter) describe('auth routes', () => { it('...', async () => { const res = await app.request('/api/auth/check-name/x') const body = await res.json() as { available: boolean; error?: string } expect(body.available).toBe(false) }) }) ``` For `arena-proxy.test.ts`: mount `arenaProxy` on a throwaway `Hono` app, spin up a second `Hono` app as the "upstream" (or use `vi.stubGlobal('fetch', ...)` to mock), set `process.env.ARENA_UPSTREAM_URL` in `beforeEach`/`afterEach` (mirrors the existing `beforeEach`/`afterEach` env-var pattern already imported in `auth.test.ts` line 1), and assert: (a) standalone mode (`ARENA_UPSTREAM_URL` unset) falls through to `next()`, (b) proxy mode forwards method/headers/body/status correctly, (c) proxy mode passes a streamed body through without buffering (assert the `Response.body` is the same-shaped `ReadableStream`, not a buffered string). --- ### `server/src/routes/auth-me.test.ts` (NEW test, request-response) **Analog:** `server/src/routes/auth.test.ts` in full — same harness (`app.request('/api/auth/me', { headers: { Authorization: 'Bearer ' } })`), same JWT fixture helper as `nip98.test.ts`'s `makeNip98Header` (lines 9-17) plus `createJwt` from `middleware/jwt.ts` to mint a valid test token: ```ts // mirrors nip98.test.ts's makeNip98Header fixture builder (lines 9-17) import { createJwt } from '../middleware/jwt.js' const token = createJwt(pubkeyHex) const res = await app.request('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } }) ``` Cover: no Authorization header → 401; expired/blacklisted JWT → 401; valid JWT for unregistered pubkey → `{exists: false}`; valid JWT for a registered bot → same shape as the old `POST /login` 200 response (lines 114-135 of `auth.ts`). --- ### `server/src/routes/docs.ts` — `GET /prompt` (route, request-response) **Analog:** same file's `GET /webhook` handler (lines 6-63) — returns a large structured JSON doc object. For the *unified prompt*, prefer serving the raw markdown text (not JSON) so an AI agent can `curl` it directly and get plain text: ```ts // server/src/routes/docs.ts — new handler, mirrors the existing GET /webhook shape // but returns text/markdown instead of JSON (see BOTFIGHTS.md consolidation, BOT-02) import { readFileSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) const promptPath = join(__dirname, '..', '..', '..', 'BOTFIGHTS.md') // repo-root BOTFIGHTS.md docsRouter.get('/prompt', (c) => { c.header('Content-Type', 'text/markdown; charset=utf-8') return c.body(readFileSync(promptPath, 'utf-8')) }) ``` **Cache-Control:** already handled generically in `app.ts` line 90-93 (`app.use('/api/docs/*', ...)` sets `public, max-age=3600` on GET) — no per-route cache header needed. --- ### `frontend/src/pages/JoinBoutPage.vue` — collapse setup doc references (component, request-response + templating) **Analog:** same file's existing `setupDocPath()`/`setupDocName()` + `toggleSetupContent()` fetch-and-substitute logic (per RESEARCH.md Pitfall #4 — grep confirmed call sites also in `BotProfilePage.vue` and `DocsPage.vue`). **Pattern to preserve exactly** (placeholder substitution — this is the load-bearing behavior, don't lose it during consolidation): ```ts // existing pattern (illustrative — verify exact variable names in JoinBoutPage.vue at edit time) const content = await fetch(setupDocPath()).then(r => r.text()) const personalized = content .replace(/YOUR_BOT_ID/g, botId.value) .replace(/YOUR_BOT_SECRET/g, botSecret.value) ``` After consolidation, `setupDocPath()` must point at the single new `BOTFIGHTS.md` (or `/api/docs/prompt`) instead of the deleted `BOTFIGHTS-POLLING.md`/`BOTFIGHTS-WEBHOOK.md`. **Grep before deleting:** `frontend/src` for `BOTFIGHTS-POLLING\|BOTFIGHTS-WEBHOOK\|BOTFIGHTS-EASY\|BOT_SETUP` (confirmed hits: `JoinBoutPage.vue`, `BotProfilePage.vue`, `DocsPage.vue`) — update every reference. --- ### `e2e/*.spec.ts` (Playwright, request-response) **Analog:** `e2e/signup-bot.spec.ts` in full (43 lines): ```ts import { test, expect } from '@playwright/test' test.describe('bot registration flow', () => { test('navigate to join page and see login step', async ({ page }) => { await page.goto('/join') await expect(page.getByText(/sign in/i).first()).toBeVisible({ timeout: 10_000 }) }) // ... click-through steps, getByText selectors, timeout: 5_000-10_000 }) ``` Style conventions to follow: `test.describe` grouping by flow, `page.getByText(/regex/i)` selectors (not test-ids), explicit `{ timeout: N_000 }` on every assertion that waits on async UI state, one `test()` per discrete user-visible step in the flow. No `window.nostr` mocking exists anywhere in this suite (RESEARCH.md Pitfall #5) — do not attempt to add real-NIP-07 e2e coverage; that is explicitly a manual verification step per CONTEXT.md/CLAUDE.md, not a Playwright task. --- ### `apps/botfights/manifest.yml` (archy repo, config/CRUD) **Analog:** `apps/netbird-server/manifest.yml` (full file read, 123 lines) — exact `generated_secrets` + `secret_env`/`{{secret:...}}` template-substitution pattern. **Imports/schema conventions** — this is YAML consumed by `core/container/src/manifest.rs`'s `GeneratedSecret`/`SecretGenKind`/`secret_env` types; no imports, just structural fields: ```yaml container: image: 146.59.87.168:3000/lfg2025/botfights:1.2.0 # bump from 1.1.0 pull_policy: always generated_secrets: - name: botfights-jwt-secret kind: hex32 # 32 random bytes, 64 hex chars — matches `openssl rand -hex 32` secret_env: - key: JWT_SECRET secret_file: botfights-jwt-secret ``` Compare `netbird-server`'s `kind: base64` (its server base64-decodes) — `botfights` must use `kind: hex32` (its `middleware/jwt.ts` expects a raw hex-ish secret string used directly as an HMAC key, no decode step) — do not copy `kind: base64` verbatim, the `kind` must match what the consuming app code expects. **Environment addition (BOT-03 default-on federation):** ```yaml environment: - NODE_ENV=production - ARENA_UPSTREAM_URL=https://arena.archipelago-foundation.org # exact subdomain TBD, see RESEARCH.md Open Question #1 ``` **Existing file to preserve everything else in place** (security/ports/volumes/health_check/interfaces/metadata blocks in `apps/botfights/manifest.yml` lines 20-76 are unaffected — only `version`, `container.image` tag, `container.generated_secrets`, `container.secret_env`, and `environment` change). --- ### `app-catalog/catalog.json` (archy repo, config/CRUD, legacy hand-maintained file) **Analog:** same file's own `botfights` entry (lines 110-124) — edit in place, bump `"dockerImage": "...botfights:1.1.0"` → `:1.2.0` and `"version"` to match. Run `scripts/check-app-catalog-drift.py` afterward to confirm no drift against the regenerated `releases/app-catalog.json` (built by `scripts/generate-app-catalog.sh`, itself unmodified — it reads `apps/*/manifest.yml` automatically). ## Shared Patterns ### Error responses (botfight repo) **Source:** every route file (`auth.ts`, `bots.ts`, `docs.ts`) — direct `c.json({ error: '...' }, statusCode)`, no thrown exceptions inside handlers. **Apply to:** `GET /me`, `GET /prompt`, `arena-proxy.ts`'s fallback paths. ### Zod validation (botfight repo) **Source:** `server/src/lib/validators.ts` (imported by both `auth.ts` line 11 and `bots.ts` line 11: `loginSchema`, `registerSchema`, `botNameSchema`, `httpUrlSchema`, `formatZodError`). **Apply to:** any new route accepting a request body (not needed for `GET /me`/`GET /prompt`, which take no body; needed if BOT-03's proxy ever needs its own validated config). ### Rate limiting (botfight repo) **Source:** `server/src/middleware/rate-limit.ts`, used as a per-route Hono middleware factory: `rateLimit(windowMs, maxRequests)` — see `auth.ts` line 29 (`rateLimit(60_000, 10)`) and `bots.ts` line 34 (`rateLimit(3600_000, 5)`), plus the global mount in `app.ts` line 71 (`rateLimit(60_000, 300)` on `/api/*`). **Apply to:** BOT-03's proxy must preserve/forward client IP so this remains meaningful once requests are proxied (see arena-proxy.ts pattern above). ### Secrets provisioning (archy repo) **Source:** `apps/netbird-server/manifest.yml`'s `generated_secrets`/`secret_env`/`{{secret:...}}` mechanism, backed by `core/container/src/manifest.rs`'s `GeneratedSecret`/`SecretGenKind` types. **Apply to:** `apps/botfights/manifest.yml`'s new `JWT_SECRET` (mandatory — see Pitfall #1 in RESEARCH.md, this is a BLOCKING crash-loop fix, not optional). ### Catalog publish pipeline (archy repo) **Source:** `scripts/generate-app-catalog.sh` (regenerates `releases/app-catalog.json` from `apps/*/manifest.yml`, EMBED_MANIFESTS=1 default) → `scripts/sign-catalog.sh` (human-mnemonic signing ceremony, refuses to compile its own signer — build the release binary first) → push. **Apply to:** BOT-04's final step, after both `apps/botfights/manifest.yml` and `app-catalog/catalog.json` are bumped in lockstep (Pitfall #3 in RESEARCH.md — two catalog files, keep them in sync or `check-app-catalog-drift.py` flags it). ## No Analog Found None — every file in scope has a strong in-repo analog. This phase is explicitly an audit/harden/extend phase per RESEARCH.md, not greenfield: NIP-98/JWT/validation/rate-limit/secrets-provisioning patterns all pre-exist and are meant to be reused, not invented. ## Metadata **Analog search scope:** `/home/archipelago/Projects/botfight/{server/src,frontend/src,e2e}`, `/home/archipelago/Projects/archy/{apps,app-catalog,scripts}` **Files scanned:** `app.ts`, `middleware/{nip98,jwt}.ts`, `routes/{auth,bots,docs}.ts`, `routes/auth.test.ts`, `middleware/nip98.test.ts`, `frontend/src/composables/useNostr.ts`, `frontend/src/lib/nostr-auth.ts`, `e2e/signup-bot.spec.ts`, `Dockerfile`, `deploy.sh`, `apps/netbird-server/manifest.yml`, `apps/botfights/manifest.yml`, `app-catalog/catalog.json`, `scripts/generate-app-catalog.sh` **Pattern extraction date:** 2026-07-30