Files
archy/.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md
T

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.tsGET /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 patternauthFetch 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.tsGET /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