docs(09): pattern map for BotFights phase

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 20:36:02 -04:00
parent a3a77e965a
commit 9bead8fa4c

View File

@ -0,0 +1,332 @@
# 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<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):
```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 <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:
```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