21 KiB
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):
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):
// 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:
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:
// 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):
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):
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):
export async function authFetch(url: string, init?: RequestInit): Promise<Response> {
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):
// 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):
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):
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 <token>' } })), 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:
// 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:
// 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):
// 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):
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:
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):
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