Every fight now includes one Retro Mode round where bots submit gamepad combo inputs (↑↓←→ A B). 24 moves across 4 tiers: basic (always shown), standard (partially revealed), super (must discover), and ultra (KONAMI CODE for 50 dmg). Discovery bonus gives 1.5x damage. Includes pixel-art gamepad overlays (P1/P2) with animated button presses, retro-specific narrations, and mock bot combo responses scaled by ELO. Also adds loops/plan.md with 11-phase production hardening roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
400 lines
31 KiB
Markdown
400 lines
31 KiB
Markdown
# Overnight Plan — Production Hardening (2-Month Roadmap)
|
|
|
|
> Harden botfights for production: database indexes, memory leak fixes, payment security, Nostr auth bugs, form validation, observability, deployment.
|
|
> Server: `/Users/dorian/projects/botfights/server/` — Write/Edit tools blocked by hooks; use Bash heredoc for server file writes.
|
|
> Frontend: `/Users/dorian/Projects/botfights/frontend/` — standard Write/Edit tools work.
|
|
> Follow CLAUDE.md conventions: `<script setup lang="ts">`, conventional commits, no external code copying.
|
|
> Run `pnpm typecheck` from project root after each phase to verify.
|
|
|
|
---
|
|
|
|
## Phase 1: Database Indexes (Critical Performance)
|
|
|
|
- [ ] **Add SQLite indexes via migration**: In `server/src/db/startup.ts`, add a new migration block after existing migrations. Use the existing pattern of `try { sqlite.exec(...) } catch {}` for each CREATE INDEX IF NOT EXISTS statement. Add these indexes:
|
|
```sql
|
|
CREATE INDEX IF NOT EXISTS idx_fights_status ON fights(status);
|
|
CREATE INDEX IF NOT EXISTS idx_fights_bot_a ON fights(bot_a_id);
|
|
CREATE INDEX IF NOT EXISTS idx_fights_bot_b ON fights(bot_b_id);
|
|
CREATE INDEX IF NOT EXISTS idx_fights_created ON fights(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_fights_winner ON fights(winner_id);
|
|
CREATE INDEX IF NOT EXISTS idx_fights_mode ON fights(mode);
|
|
CREATE INDEX IF NOT EXISTS idx_rounds_fight ON rounds(fight_id);
|
|
CREATE INDEX IF NOT EXISTS idx_payments_fight ON payments(fight_id);
|
|
CREATE INDEX IF NOT EXISTS idx_payments_bot ON payments(bot_id);
|
|
CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
|
|
CREATE INDEX IF NOT EXISTS idx_bets_fight ON bets(fight_id);
|
|
CREATE INDEX IF NOT EXISTS idx_bets_bettor ON bets(bettor_pubkey);
|
|
CREATE INDEX IF NOT EXISTS idx_bets_status ON bets(status);
|
|
CREATE INDEX IF NOT EXISTS idx_bots_pubkey ON bots(public_key);
|
|
CREATE INDEX IF NOT EXISTS idx_bots_elo ON bots(elo_rating);
|
|
CREATE INDEX IF NOT EXISTS idx_bots_active ON bots(is_active);
|
|
CREATE INDEX IF NOT EXISTS idx_bots_type ON bots(bot_type);
|
|
CREATE INDEX IF NOT EXISTS idx_bots_name_lower ON bots(LOWER(name));
|
|
CREATE INDEX IF NOT EXISTS idx_wallet_bot ON wallet_connections(bot_id);
|
|
```
|
|
Add a log line: `console.log('[botfights] database indexes ensured')`. Verify: `cd server && npx tsx src/index.ts` starts without errors, then Ctrl+C.
|
|
|
|
- [ ] **Enable WAL checkpoint strategy**: In `server/src/db/index.ts`, after the existing `PRAGMA journal_mode=WAL` and `PRAGMA foreign_keys=ON`, add these pragmas to the sqlite instance:
|
|
```sql
|
|
PRAGMA wal_autocheckpoint = 1000;
|
|
PRAGMA busy_timeout = 5000;
|
|
PRAGMA cache_size = -20000;
|
|
PRAGMA mmap_size = 268435456;
|
|
```
|
|
These improve concurrent read performance and prevent WAL file growth. `busy_timeout = 5000` prevents SQLITE_BUSY errors under load. `cache_size = -20000` means 20MB page cache. `mmap_size` enables memory-mapped I/O for reads. Verify: `cd server && npx tsx src/index.ts` starts without errors.
|
|
|
|
---
|
|
|
|
## Phase 2: Memory Leak Fixes
|
|
|
|
- [ ] **Rate limiter TTL cleanup**: In `server/src/middleware/rate-limit.ts`, the `rateLimit()` function (line 14) uses an in-memory Map `hitCounts` with a 5-minute cleanup interval. The cleanup loop (`setInterval`) runs every 5 minutes but only cleans entries older than the window. Problem: the `botRateLimit()` function (line 39) uses a Map `cooldowns` with NO cleanup at all — entries persist forever. Fix: Add a cleanup interval to `botRateLimit` that runs every 60 seconds and deletes entries older than `cooldownMs * 2`. Also, cap the `hitCounts` Map size: if it exceeds 10,000 entries, clear the oldest half. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Active fighters Set leak prevention**: In `server/src/engine/orchestrator.ts`, the `activeFighters` Set (around line 37) tracks bots currently in fights but relies on `finally` blocks for cleanup. Add a safety mechanism: create a `cleanupStaleFighters()` function that runs every 60 seconds. It should check each botId in `activeFighters` against the `fights` table — if no fight with status='live' exists for that bot, remove it from the Set. Call this on a `setInterval` inside the existing `cleanupOrphanedFights()` function or alongside it in `app.ts`. Log when stale entries are cleaned: `console.log('[orchestrator] cleaned stale fighter: ${botId}')`. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Betting escrow memory persistence**: In `server/src/engine/betting.ts`, the in-memory escrow Map stores bet data that would be lost on server restart. Add a `recoverEscrow()` function that runs on startup: query `bets` table for rows with `status = 'locked'` and `settled_at IS NULL`, rebuild the escrow Map from these rows. Export and call this function from `server/src/index.ts` after `seedClassicBots()`. Also add a `cleanupSettledEscrow()` function that runs every 5 minutes and removes entries from the escrow Map where all bets for that fightId have been settled (status not 'locked' or 'pending'). Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
---
|
|
|
|
## Phase 3: Nostr Auth — Fix Stale Profile Bug
|
|
|
|
- [ ] **Clear wallet state on key change**: In `frontend/src/composables/useNostr.ts`, the `clearAllState()` function (around line 69-76) clears `pubkey`, `bot`, and `profilePicUrl` but does NOT clear wallet-related localStorage keys. When a user generates a new identity, their old wallet connection persists in `bf_wallet_method` and `bf_nwc_url`. Fix: In `clearAllState()`, add these lines after the existing clears:
|
|
```typescript
|
|
localStorage.removeItem('bf_wallet_method')
|
|
localStorage.removeItem('bf_nwc_url')
|
|
```
|
|
This ensures wallet state from a previous identity doesn't bleed into a new one.
|
|
|
|
- [ ] **Validate stored bot belongs to current pubkey on restore**: In `frontend/src/composables/useNostr.ts`, the auto-restore logic (around line 83-105) loads `bf_bot` from localStorage and trusts it without checking if it matches the current pubkey. Fix: In the auto-restore function, after loading the stored bot and calling `POST /api/auth/login`, validate that the returned bot matches what's in localStorage. If the server returns `exists: false` but localStorage has a bot, clear the stale bot:
|
|
```typescript
|
|
if (!data.exists && bot.value) {
|
|
bot.value = null
|
|
store('bf_bot', null)
|
|
store('bf_pic', null)
|
|
}
|
|
```
|
|
Also, if the server returns a different bot ID than what's stored, update localStorage to match the server response. This prevents showing old fighter data after key regeneration. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
- [ ] **Clear Nostr profile pic cache on identity change**: In `frontend/src/composables/useNostr.ts`, the `generateLogin()` function (around line 175-193) calls `clearAllState()` which clears `bf_pic`, but there's a subtle race condition: if `fetchNostrProfilePic()` is still running from a previous login when a new key is generated, it could write the OLD key's profile pic into the new session. Fix: Add a generation counter (simple integer ref that increments in `generateLogin()` and `loginWithNsec()`). In `fetchNostrProfilePic()`, capture the counter value at the start and check it hasn't changed before writing the result:
|
|
```typescript
|
|
const generation = ref(0)
|
|
// In generateLogin():
|
|
generation.value++
|
|
// In fetchNostrProfilePic():
|
|
const gen = generation.value
|
|
// ... after relay fetch ...
|
|
if (generation.value !== gen) return // identity changed during fetch
|
|
```
|
|
Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
- [ ] **Force profile refetch on pubkey change in BotProfilePage**: In `frontend/src/pages/BotProfilePage.vue`, the profile data is loaded once in `onMounted()` (around line 265-276) with no reactive watcher on the current pubkey. If the user switches identity without a page reload, stale data shows. Fix: Import `watch` from vue and add a watcher on `pubkey` from `useNostr()`:
|
|
```typescript
|
|
watch(() => pubkey.value, async (newPk) => {
|
|
// Refetch stats with new pubkey
|
|
const qs = newPk ? `?pubkey=${encodeURIComponent(newPk)}` : ''
|
|
const res = await fetch(`/api/bots/${encodeURIComponent(botName)}/stats${qs}`)
|
|
if (res.ok) stats.value = await res.json()
|
|
})
|
|
```
|
|
Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
---
|
|
|
|
## Phase 4: Form Validation Hardening
|
|
|
|
- [ ] **Unified name validation with real-time feedback**: In `frontend/src/pages/JoinBoutPage.vue`, the bot name input (around line 608-617) and human name input (around line 866-874) both validate on submit only. Add real-time validation: create a computed `nameError` that checks: (1) length 2-12, (2) alphanumeric + `-_` only via `/^[a-zA-Z0-9_-]+$/`, (3) no leading/trailing hyphens or underscores. Show the error message below the input in red text (`text-red-400 text-xs mt-1`). Disable the confirm button when `nameError` is truthy. Apply to both `botName` and `humanName` fields. Also trim whitespace on input (use `v-model.trim`). Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
- [ ] **Webhook URL validation with protocol enforcement**: In `frontend/src/pages/JoinBoutPage.vue`, the webhook input (around line 781-788) only validates with `new URL()` on submit. Add real-time validation: create a computed `webhookError` that checks: (1) must start with `https://` or `http://` (show "URL must start with https://"), (2) must be valid URL, (3) must not point to localhost/127.0.0.1/private IPs (mirror `isAllowedWebhookUrl` check from `server/src/engine/orchestrator.ts`). Show error below input. Add a "TEST" button next to the URL input that calls the existing test endpoint and shows latency or error inline. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
- [ ] **Server-side validation tightening**: In `server/src/routes/auth.ts`, harden all three registration endpoints (`/register`, `/register-human`, `/login`):
|
|
1. Add input sanitization: trim all string inputs, reject strings with null bytes (`\0`), reject names that are all underscores/hyphens.
|
|
2. In `/register` (line 86): add `webhookUrl` length limit (max 2048 chars). Add `archetype` validation against a known list (import archetype names or validate it's a non-empty alphanumeric string under 32 chars). Add `profilePicUrl` length limit (max 2048 chars) and validate it starts with `https://`.
|
|
3. In `/register-human` (line 185): add `avatarSeed` validation (alphanumeric, max 32 chars).
|
|
4. In `/update` (line 247): add the same `profilePicUrl` validation (https only, max 2048).
|
|
Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **NWC connection string validation**: In `frontend/src/composables/useWallet.ts`, the `connectNWC()` function (around line 49-75) sends the NWC connection string directly to the server without client-side validation. Add validation before the API call: (1) must start with `nostr+walletconnect://`, (2) must contain `relay=` parameter, (3) must contain `secret=` parameter, (4) secret must be 64-char hex. Return a descriptive error if validation fails. Also in `frontend/src/components/WalletConnect.vue`, show these validation errors inline below the NWC paste field. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
---
|
|
|
|
## Phase 5: Payment Security Hardening
|
|
|
|
- [ ] **Add pubkey auth to all payment endpoints**: In `server/src/routes/payments.ts`, several endpoints lack proper authentication. Fix:
|
|
1. `POST /create-invoice` (line 86): require `pubkey` in body, verify bot belongs to that pubkey before creating invoice.
|
|
2. `GET /winnings/:botId` (line 162): require `pubkey` query param, verify bot belongs to that pubkey. Currently returns all Cashu tokens for any botId without auth — this is a critical vulnerability.
|
|
3. `POST /submit-cashu` (line 144): require `pubkey` in body, verify bot belongs to that pubkey.
|
|
4. `DELETE /disconnect-wallet` (line 212): already has pubkey, but add explicit length/format check (64-char hex).
|
|
Add a helper function `verifyBotOwnership(pubkey: string, botId: string): Promise<boolean>` to reduce code duplication. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Rate limit payment endpoints**: In `server/src/routes/payments.ts`, add rate limiting to prevent abuse:
|
|
1. `POST /connect-wallet`: `rateLimit(3600_000, 10)` — 10 connections per hour per IP
|
|
2. `POST /create-invoice`: `rateLimit(60_000, 5)` — 5 invoices per minute per IP
|
|
3. `POST /submit-cashu`: `rateLimit(60_000, 5)` — 5 redemptions per minute per IP
|
|
4. `POST /claim/:paymentId`: `rateLimit(60_000, 10)` — 10 claims per minute per IP
|
|
5. `POST /confirm/:paymentId`: `rateLimit(60_000, 10)` — 10 confirms per minute per IP
|
|
Import `rateLimit` from `../middleware/rate-limit.js`. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **NWC relay connection pooling and timeout hardening**: In `server/src/engine/payments.ts`, the `nwcRequest()` function (around line 300+) opens a new WebSocket to the NWC relay for every request with a 30-second timeout. This blocks the fight completion and can leak connections. Fix:
|
|
1. Create a `RelayPool` class that maintains a single WebSocket per relay URL, reconnects on disconnect, and queues requests.
|
|
2. Reduce the NWC response timeout from 30s to 10s.
|
|
3. Add a `nwcRequestWithRetry(method, params, retries=2)` wrapper that retries on timeout with exponential backoff (2s, 4s).
|
|
4. In `payWinner()`, run the payout in a `setTimeout(0)` / `queueMicrotask` so it doesn't block fight completion — the fight result should be returned immediately while payout happens asynchronously. Update `fight.payoutStatus` to 'pending' before returning, then 'paid' or 'failed' after the async payout completes.
|
|
Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Cashu token verification hardening**: In `server/src/engine/betting.ts`, the `placeBet()` function verifies Cashu tokens but may not handle all failure modes. In `server/src/engine/payments.ts`, the `redeemCashuToken()` function should also be hardened. Fix:
|
|
1. Add token amount verification: decode the Cashu token and verify the sum of proofs matches `amountSats` before accepting.
|
|
2. Add mint URL validation: only accept tokens from the configured `BOTFIGHTS_CASHU_MINT_URL`. Reject tokens from unknown mints.
|
|
3. Add double-spend protection: before accepting a token, check if any proof `secret` in the token already exists in the payments or bets table (store proof secrets on successful redemption).
|
|
4. Wrap all Cashu operations in try/catch with specific error messages (network error, invalid token, insufficient amount, wrong mint, double spend).
|
|
Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Payment idempotency keys**: In `server/src/routes/payments.ts`, add idempotency protection:
|
|
1. `POST /create-invoice`: Accept an optional `idempotencyKey` in the body. Before creating a new invoice, check if a payment with the same idempotency key exists. If so, return the existing invoice instead of creating a duplicate. Store the idempotency key in a new column on the payments table (add migration in startup.ts: `ALTER TABLE payments ADD COLUMN idempotency_key TEXT`).
|
|
2. `POST /confirm/:paymentId`: Already idempotent (returns existing status if already confirmed). No change needed.
|
|
3. `POST /submit-cashu`: Add idempotency key check similar to create-invoice.
|
|
Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
---
|
|
|
|
## Phase 6: Concurrent Fight Limits & Reliability
|
|
|
|
- [ ] **Global concurrent fight cap**: In `server/src/engine/orchestrator.ts`, there is no limit on simultaneous fights. Add a constant `MAX_CONCURRENT_FIGHTS = 20` at the top of the file. In `runFightAsync()`, before starting a fight, check `activeFighters.size / 2` (each fight has 2 fighters). If `>= MAX_CONCURRENT_FIGHTS`, reject with error `'Server at capacity — try again in a moment.'`. Also add a `getActiveFightCount()` export that returns `Math.floor(activeFighters.size / 2)`. In `server/src/routes/fights.ts`, use this in the matchmake and practice endpoints to return 503 with retry-after header when at capacity. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Webhook circuit breaker**: In `server/src/engine/orchestrator.ts`, bots are deactivated after 5 consecutive webhook errors (`consecutiveErrors >= 5`). But there's no recovery path — once deactivated, only manual `/update` re-enables. Add an automatic recovery check: in `trackWebhookResult()`, if a bot has `consecutiveErrors >= 5` and `isActive = false`, and `lastErrorAt` is more than 1 hour ago, attempt a single test call to the webhook. If it succeeds, reset `consecutiveErrors = 0` and `isActive = true`. Log: `console.log('[orchestrator] bot ${botId} auto-recovered after webhook fix')`. Cap the check to once per hour per bot using a simple Map with timestamps. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Graceful shutdown**: In `server/src/index.ts`, add SIGTERM and SIGINT handlers that:
|
|
1. Call `stopBackgroundFights()` from `server/src/engine/background.ts`
|
|
2. Wait up to 30 seconds for `activeFighters` Set to empty (poll every 1s)
|
|
3. Close the database connection: `sqlite.close()` from `server/src/db/index.ts` (export the sqlite instance if not already exported)
|
|
4. Log `[botfights] graceful shutdown complete`
|
|
5. `process.exit(0)`
|
|
This prevents data corruption on deploy/restart. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Fight timeout enforcement**: In `server/src/engine/orchestrator.ts`, the `executeFightRounds()` function has per-round timeouts but no overall fight timeout. If a fight hangs (e.g., webhook returns slowly for every round), it could block resources indefinitely. Add an overall fight timeout of 5 minutes: wrap the round loop in a `Promise.race()` with a 5-minute timer. On timeout, cancel the fight (`status = 'cancelled'`), clean up `activeFighters`, and log `[orchestrator] fight ${fightId} timed out after 5 minutes`. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
---
|
|
|
|
## Phase 7: Security Hardening
|
|
|
|
- [ ] **CORS origin restriction**: In `server/src/app.ts`, CORS is currently set to `'*'` (all origins) via the `CORS_ORIGIN` env var. For production, this should restrict to the actual frontend domain. Fix: In `app.ts`, where the CORS middleware is configured, change the default from `'*'` to check `NODE_ENV`: if production, require `CORS_ORIGIN` to be explicitly set and warn if it's `'*'`. Log: `console.warn('[botfights] WARNING: CORS_ORIGIN is *, set to your domain in production')`. Also add the CORS headers `Access-Control-Allow-Credentials: true` and restrict `Access-Control-Allow-Methods` to `GET, POST, DELETE, OPTIONS`. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Request body size limits**: In `server/src/app.ts`, there is no request body size limit. A malicious client could send a multi-GB body and crash the server. Fix: Add a body size limit middleware before the routes. In Hono, use the built-in body limit middleware: `app.use('/api/*', bodyLimit({ maxSize: 64 * 1024 }))` (64KB max). Import `bodyLimit` from `hono/body-limit`. For the webhook test endpoint specifically, the 10KB limit is already in the orchestrator, but the route itself should also enforce it. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Helmet-style security headers**: In `server/src/app.ts`, add a middleware that sets security headers on all responses:
|
|
```typescript
|
|
app.use('*', async (c, next) => {
|
|
await next()
|
|
c.header('X-Content-Type-Options', 'nosniff')
|
|
c.header('X-Frame-Options', 'DENY')
|
|
c.header('X-XSS-Protection', '0')
|
|
c.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
|
})
|
|
```
|
|
Place this BEFORE the CORS middleware so it applies to all responses. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Webhook SSRF hardening**: In `server/src/engine/orchestrator.ts`, the `isAllowedWebhookUrl()` function (around line 50) blocks private IPs but uses simple string matching. Harden it: (1) resolve the hostname to IP via DNS lookup (`dns.promises.lookup`) and check the resolved IP against private ranges — this catches DNS rebinding attacks where a domain resolves to 127.0.0.1. (2) Block `file://`, `ftp://`, `data:` schemes. (3) Block URLs with userinfo (`user:pass@host`). (4) Block URLs with ports below 80 or above 65535 (except 443, 8080, 8443, 3000-9999). Make the function async and update all callers (`auth.ts` register and update endpoints). Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Wallet encryption key enforcement in production**: In `server/src/engine/crypto.ts` (38 lines), if `BOTFIGHTS_WALLET_ENCRYPTION_KEY` is not set, a random key is generated. This means wallet data encrypted in one session can't be decrypted after a restart. Fix: In production (`NODE_ENV === 'production'`), throw an error on startup if `BOTFIGHTS_WALLET_ENCRYPTION_KEY` is not set, rather than silently using a random key. In dev mode, keep the random key behavior but log a warning. Also validate that the key is exactly 64 hex characters (32 bytes). Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
---
|
|
|
|
## Phase 8: Observability & Health
|
|
|
|
- [ ] **Health check endpoint**: In `server/src/app.ts`, add a `GET /health` endpoint (outside `/api/*` so it's not rate-limited) that returns:
|
|
```json
|
|
{
|
|
"status": "ok",
|
|
"uptime": process.uptime(),
|
|
"activeFights": activeFighters.size / 2,
|
|
"dbOk": true/false,
|
|
"timestamp": new Date().toISOString()
|
|
}
|
|
```
|
|
Check DB health by running `SELECT 1` via `sqlite.prepare('SELECT 1').get()`. If it throws, set `dbOk: false` and `status: 'degraded'`. Import `activeFighters` from orchestrator or use the new `getActiveFightCount()`. Return 200 for ok, 503 for degraded. This endpoint will be used by the VPS monitoring and deployment scripts. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Structured logging utility**: Create `server/src/engine/logger.ts` with a simple structured logger. No dependencies — just wrap `console.log`/`console.error` with JSON output in production and human-readable in dev:
|
|
```typescript
|
|
export function log(level: 'info' | 'warn' | 'error', component: string, message: string, data?: Record<string, unknown>) {
|
|
const entry = { ts: Date.now(), level, component, msg: message, ...data }
|
|
if (process.env.NODE_ENV === 'production') {
|
|
console[level === 'error' ? 'error' : 'log'](JSON.stringify(entry))
|
|
} else {
|
|
console[level === 'error' ? 'error' : 'log'](`[${component}] ${message}`, data || '')
|
|
}
|
|
}
|
|
```
|
|
Replace 5-10 key `console.log` calls in `orchestrator.ts`, `payments.ts`, and `background.ts` with this logger (focus on fight start/end, payment events, and errors). Do NOT replace every console.log — just the critical operational ones. Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Request timing middleware**: In `server/src/app.ts`, add a middleware that logs slow requests. Before the routes, add:
|
|
```typescript
|
|
app.use('/api/*', async (c, next) => {
|
|
const start = Date.now()
|
|
await next()
|
|
const ms = Date.now() - start
|
|
if (ms > 1000) {
|
|
log('warn', 'http', `slow request: ${c.req.method} ${c.req.path}`, { ms, status: c.res.status })
|
|
}
|
|
})
|
|
```
|
|
Import `log` from `./engine/logger.js`. This catches endpoints that are unexpectedly slow (DB queries without indexes, webhook timeouts). Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
- [ ] **Fight metrics counter**: In `server/src/engine/orchestrator.ts`, add a simple metrics object that tracks counts since startup:
|
|
```typescript
|
|
export const metrics = {
|
|
fightsStarted: 0,
|
|
fightsCompleted: 0,
|
|
fightsCancelled: 0,
|
|
webhookErrors: 0,
|
|
webhookTimeouts: 0,
|
|
avgRoundMs: 0,
|
|
totalRoundMs: 0,
|
|
totalRounds: 0,
|
|
}
|
|
```
|
|
Increment `fightsStarted` in `runFightAsync()`, `fightsCompleted`/`fightsCancelled` in `executeFightRounds()`, `webhookErrors`/`webhookTimeouts` in `callWebhook()`. Track round duration and compute running average. Expose these via `GET /health` endpoint (add `metrics` field to the health response). Verify: `cd server && npx tsc --noEmit` passes.
|
|
|
|
---
|
|
|
|
## Phase 9: Frontend Polish & Error States
|
|
|
|
- [ ] **Global API error handler**: In `frontend/src/composables/useNostr.ts` and `frontend/src/composables/useWallet.ts`, API calls use raw `fetch()` without consistent error handling. Create `frontend/src/utils/api.ts` with a thin wrapper:
|
|
```typescript
|
|
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const res = await fetch(path, {
|
|
...options,
|
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({ error: res.statusText }))
|
|
throw new ApiError(res.status, body.error || 'Unknown error', body.details)
|
|
}
|
|
return res.json()
|
|
}
|
|
export class ApiError extends Error {
|
|
constructor(public status: number, message: string, public details?: string) {
|
|
super(message)
|
|
}
|
|
}
|
|
```
|
|
Replace 5-8 of the most important `fetch()` calls in `useNostr.ts` (login, register, registerHuman, update) and `useWallet.ts` (connectNWC, payEntryFee, createInvoice) with `apiFetch()`. Handle `ApiError` in the callers to show user-friendly error messages. Do NOT replace every fetch call — focus on the ones where errors are user-facing. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
- [ ] **Loading states for all async operations in JoinBoutPage**: In `frontend/src/pages/JoinBoutPage.vue`, there are multiple async operations (login, name check, webhook test, registration) that should show loading states. Check each submit button: if it doesn't already have a loading/disabled state, add one. Specifically:
|
|
1. Login button: add `isLoggingIn` ref, set true during `handleLogin()`, disable button and show "LOGGING IN..." text while true.
|
|
2. Name confirm buttons (both bot and human): already have `isCheckingName` — verify it works and the button text changes.
|
|
3. Webhook confirm: add `isTestingWebhook` ref, show "TESTING..." during the webhook verification.
|
|
4. Register: add `isRegistering` ref, show "REGISTERING..." during the API call.
|
|
All buttons should use `:disabled="isLoading"` and show the loading text via ternary in the button content. Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
- [ ] **Error toast/notification system**: Create `frontend/src/composables/useToast.ts` — a minimal toast notification composable:
|
|
```typescript
|
|
const toasts = ref<Array<{ id: number; message: string; type: 'error' | 'success' | 'info'; timeout: number }>>([])
|
|
let nextId = 0
|
|
export function useToast() {
|
|
function show(message: string, type: 'error' | 'success' | 'info' = 'info', duration = 4000) {
|
|
const id = nextId++
|
|
toasts.value.push({ id, message, type, timeout: window.setTimeout(() => dismiss(id), duration) })
|
|
}
|
|
function dismiss(id: number) {
|
|
toasts.value = toasts.value.filter(t => t.id !== id)
|
|
}
|
|
return { toasts: readonly(toasts), show, dismiss }
|
|
}
|
|
```
|
|
Create `frontend/src/components/ToastContainer.vue` — renders toasts fixed at bottom-right with appropriate colors (red for error, green for success, cyan for info). Style matches existing neon aesthetic: `font-display`, `tracking-wider`, dark background with colored border. Add `<ToastContainer />` to `frontend/src/App.vue` (or the root layout). Verify: `cd frontend && npx vue-tsc --noEmit` passes.
|
|
|
|
---
|
|
|
|
## Phase 10: Deployment & Operations
|
|
|
|
- [ ] **Database backup script**: Create `server/scripts/backup-db.sh`:
|
|
```bash
|
|
#!/bin/bash
|
|
DB_PATH="${DB_PATH:-./data/botfights.db}"
|
|
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
|
mkdir -p "$BACKUP_DIR"
|
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
|
sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/botfights_$TIMESTAMP.db'"
|
|
# Keep only last 30 backups
|
|
ls -t "$BACKUP_DIR"/botfights_*.db | tail -n +31 | xargs -r rm
|
|
echo "[backup] created botfights_$TIMESTAMP.db ($(du -h "$BACKUP_DIR/botfights_$TIMESTAMP.db" | cut -f1))"
|
|
```
|
|
Make it executable: `chmod +x server/scripts/backup-db.sh`. This uses SQLite's online backup API (safe during writes). The 30-backup retention keeps ~1 day of hourly backups. Verify: script is syntactically valid with `bash -n server/scripts/backup-db.sh`.
|
|
|
|
- [ ] **Systemd service file**: Create `server/deploy/botfights.service`:
|
|
```ini
|
|
[Unit]
|
|
Description=Botfights Server
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=botfights
|
|
WorkingDirectory=/opt/botfights/server
|
|
ExecStart=/usr/bin/node --experimental-specifier-resolution=node dist/index.js
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
Environment=NODE_ENV=production
|
|
Environment=PORT=9100
|
|
EnvironmentFile=-/opt/botfights/.env
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=botfights
|
|
|
|
# Security hardening
|
|
NoNewPrivileges=true
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
ReadWritePaths=/opt/botfights/server/data
|
|
PrivateTmp=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
Also create `server/deploy/botfights-backup.timer` and `server/deploy/botfights-backup.service` for hourly database backups via systemd timer. The backup service runs `/opt/botfights/server/scripts/backup-db.sh`. Verify: files are valid INI format.
|
|
|
|
- [ ] **Zero-downtime deploy script**: Create `server/scripts/deploy.sh`:
|
|
```bash
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
DEPLOY_DIR="/opt/botfights"
|
|
echo "[deploy] pulling latest..."
|
|
cd "$DEPLOY_DIR" && git pull origin main
|
|
echo "[deploy] installing deps..."
|
|
cd server && pnpm install --frozen-lockfile
|
|
echo "[deploy] building..."
|
|
pnpm build
|
|
echo "[deploy] backing up database..."
|
|
bash scripts/backup-db.sh
|
|
echo "[deploy] restarting service..."
|
|
sudo systemctl restart botfights
|
|
echo "[deploy] waiting for health check..."
|
|
for i in $(seq 1 30); do
|
|
if curl -sf http://localhost:9100/health > /dev/null 2>&1; then
|
|
echo "[deploy] healthy after ${i}s"
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "[deploy] FAILED: health check timeout"
|
|
exit 1
|
|
```
|
|
Make executable: `chmod +x server/scripts/deploy.sh`. Verify: `bash -n server/scripts/deploy.sh`.
|
|
|
|
---
|
|
|
|
## Phase 11: Typecheck & Final Verification
|
|
|
|
- [ ] **Full typecheck**: Run `pnpm typecheck` from project root (`/Users/dorian/Projects/botfights`). Fix ALL TypeScript errors in both server and frontend. This is the final gate — every previous phase should have verified individually, but this catches any cross-package issues.
|
|
|
|
- [ ] **Server startup smoke test**: Run `cd /Users/dorian/projects/botfights/server && npx tsx src/index.ts` and verify:
|
|
1. `[botfights] database migrated` appears
|
|
2. `[botfights] database indexes ensured` appears
|
|
3. No crash on startup
|
|
4. `curl http://localhost:9100/health` returns `{"status":"ok",...}`
|
|
5. `curl http://localhost:9100/api/bots` returns a list
|
|
Stop the server after verification.
|
|
|
|
- [ ] **Frontend build**: Run `cd /Users/dorian/Projects/botfights/frontend && pnpm build`. Verify zero errors. Check the output bundle size is reasonable (should be under 5MB total).
|
|
|
|
- [ ] **Commit all changes**: Stage all modified and new files. Commit with message: `feat: production hardening — indexes, auth fixes, payment security, observability`. Do NOT push — the user will review and push manually.
|