From 9bead8fa4cf88458d8ef3c390af6d8b1633d3ecc Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 20:36:02 -0400 Subject: [PATCH] docs(09): pattern map for BotFights phase Co-Authored-By: Claude Fable 5 --- .../09-PATTERNS.md | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 .planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md diff --git a/.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md b/.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md new file mode 100644 index 00000000..6a71604e --- /dev/null +++ b/.planning/phases/09-botfights-platform-upgrade/09-PATTERNS.md @@ -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 { + 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