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>
31 KiB
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. Runpnpm typecheckfrom 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 oftry { sqlite.exec(...) } catch {}for each CREATE INDEX IF NOT EXISTS statement. Add these indexes: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.tsstarts without errors, then Ctrl+C. -
Enable WAL checkpoint strategy: In
server/src/db/index.ts, after the existingPRAGMA journal_mode=WALandPRAGMA foreign_keys=ON, add these pragmas to the sqlite instance: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 = 5000prevents SQLITE_BUSY errors under load.cache_size = -20000means 20MB page cache.mmap_sizeenables memory-mapped I/O for reads. Verify:cd server && npx tsx src/index.tsstarts without errors.
Phase 2: Memory Leak Fixes
-
Rate limiter TTL cleanup: In
server/src/middleware/rate-limit.ts, therateLimit()function (line 14) uses an in-memory MaphitCountswith a 5-minute cleanup interval. The cleanup loop (setInterval) runs every 5 minutes but only cleans entries older than the window. Problem: thebotRateLimit()function (line 39) uses a Mapcooldownswith NO cleanup at all — entries persist forever. Fix: Add a cleanup interval tobotRateLimitthat runs every 60 seconds and deletes entries older thancooldownMs * 2. Also, cap thehitCountsMap size: if it exceeds 10,000 entries, clear the oldest half. Verify:cd server && npx tsc --noEmitpasses. -
Active fighters Set leak prevention: In
server/src/engine/orchestrator.ts, theactiveFightersSet (around line 37) tracks bots currently in fights but relies onfinallyblocks for cleanup. Add a safety mechanism: create acleanupStaleFighters()function that runs every 60 seconds. It should check each botId inactiveFightersagainst thefightstable — if no fight with status='live' exists for that bot, remove it from the Set. Call this on asetIntervalinside the existingcleanupOrphanedFights()function or alongside it inapp.ts. Log when stale entries are cleaned:console.log('[orchestrator] cleaned stale fighter: ${botId}'). Verify:cd server && npx tsc --noEmitpasses. -
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 arecoverEscrow()function that runs on startup: querybetstable for rows withstatus = 'locked'andsettled_at IS NULL, rebuild the escrow Map from these rows. Export and call this function fromserver/src/index.tsafterseedClassicBots(). Also add acleanupSettledEscrow()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 --noEmitpasses.
Phase 3: Nostr Auth — Fix Stale Profile Bug
-
Clear wallet state on key change: In
frontend/src/composables/useNostr.ts, theclearAllState()function (around line 69-76) clearspubkey,bot, andprofilePicUrlbut does NOT clear wallet-related localStorage keys. When a user generates a new identity, their old wallet connection persists inbf_wallet_methodandbf_nwc_url. Fix: InclearAllState(), add these lines after the existing clears: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) loadsbf_botfrom localStorage and trusts it without checking if it matches the current pubkey. Fix: In the auto-restore function, after loading the stored bot and callingPOST /api/auth/login, validate that the returned bot matches what's in localStorage. If the server returnsexists: falsebut localStorage has a bot, clear the stale bot: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 --noEmitpasses. -
Clear Nostr profile pic cache on identity change: In
frontend/src/composables/useNostr.ts, thegenerateLogin()function (around line 175-193) callsclearAllState()which clearsbf_pic, but there's a subtle race condition: iffetchNostrProfilePic()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 ingenerateLogin()andloginWithNsec()). InfetchNostrProfilePic(), capture the counter value at the start and check it hasn't changed before writing the result: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 fetchVerify:
cd frontend && npx vue-tsc --noEmitpasses. -
Force profile refetch on pubkey change in BotProfilePage: In
frontend/src/pages/BotProfilePage.vue, the profile data is loaded once inonMounted()(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: Importwatchfrom vue and add a watcher onpubkeyfromuseNostr():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 --noEmitpasses.
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 computednameErrorthat 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 whennameErroris truthy. Apply to bothbotNameandhumanNamefields. Also trim whitespace on input (usev-model.trim). Verify:cd frontend && npx vue-tsc --noEmitpasses. -
Webhook URL validation with protocol enforcement: In
frontend/src/pages/JoinBoutPage.vue, the webhook input (around line 781-788) only validates withnew URL()on submit. Add real-time validation: create a computedwebhookErrorthat checks: (1) must start withhttps://orhttp://(show "URL must start with https://"), (2) must be valid URL, (3) must not point to localhost/127.0.0.1/private IPs (mirrorisAllowedWebhookUrlcheck fromserver/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 --noEmitpasses. -
Server-side validation tightening: In
server/src/routes/auth.ts, harden all three registration endpoints (/register,/register-human,/login):- Add input sanitization: trim all string inputs, reject strings with null bytes (
\0), reject names that are all underscores/hyphens. - In
/register(line 86): addwebhookUrllength limit (max 2048 chars). Addarchetypevalidation against a known list (import archetype names or validate it's a non-empty alphanumeric string under 32 chars). AddprofilePicUrllength limit (max 2048 chars) and validate it starts withhttps://. - In
/register-human(line 185): addavatarSeedvalidation (alphanumeric, max 32 chars). - In
/update(line 247): add the sameprofilePicUrlvalidation (https only, max 2048). Verify:cd server && npx tsc --noEmitpasses.
- Add input sanitization: trim all string inputs, reject strings with null bytes (
-
NWC connection string validation: In
frontend/src/composables/useWallet.ts, theconnectNWC()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 withnostr+walletconnect://, (2) must containrelay=parameter, (3) must containsecret=parameter, (4) secret must be 64-char hex. Return a descriptive error if validation fails. Also infrontend/src/components/WalletConnect.vue, show these validation errors inline below the NWC paste field. Verify:cd frontend && npx vue-tsc --noEmitpasses.
Phase 5: Payment Security Hardening
-
Add pubkey auth to all payment endpoints: In
server/src/routes/payments.ts, several endpoints lack proper authentication. Fix:POST /create-invoice(line 86): requirepubkeyin body, verify bot belongs to that pubkey before creating invoice.GET /winnings/:botId(line 162): requirepubkeyquery param, verify bot belongs to that pubkey. Currently returns all Cashu tokens for any botId without auth — this is a critical vulnerability.POST /submit-cashu(line 144): requirepubkeyin body, verify bot belongs to that pubkey.DELETE /disconnect-wallet(line 212): already has pubkey, but add explicit length/format check (64-char hex). Add a helper functionverifyBotOwnership(pubkey: string, botId: string): Promise<boolean>to reduce code duplication. Verify:cd server && npx tsc --noEmitpasses.
-
Rate limit payment endpoints: In
server/src/routes/payments.ts, add rate limiting to prevent abuse:POST /connect-wallet:rateLimit(3600_000, 10)— 10 connections per hour per IPPOST /create-invoice:rateLimit(60_000, 5)— 5 invoices per minute per IPPOST /submit-cashu:rateLimit(60_000, 5)— 5 redemptions per minute per IPPOST /claim/:paymentId:rateLimit(60_000, 10)— 10 claims per minute per IPPOST /confirm/:paymentId:rateLimit(60_000, 10)— 10 confirms per minute per IP ImportrateLimitfrom../middleware/rate-limit.js. Verify:cd server && npx tsc --noEmitpasses.
-
NWC relay connection pooling and timeout hardening: In
server/src/engine/payments.ts, thenwcRequest()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:- Create a
RelayPoolclass that maintains a single WebSocket per relay URL, reconnects on disconnect, and queues requests. - Reduce the NWC response timeout from 30s to 10s.
- Add a
nwcRequestWithRetry(method, params, retries=2)wrapper that retries on timeout with exponential backoff (2s, 4s). - In
payWinner(), run the payout in asetTimeout(0)/queueMicrotaskso it doesn't block fight completion — the fight result should be returned immediately while payout happens asynchronously. Updatefight.payoutStatusto 'pending' before returning, then 'paid' or 'failed' after the async payout completes. Verify:cd server && npx tsc --noEmitpasses.
- Create a
-
Cashu token verification hardening: In
server/src/engine/betting.ts, theplaceBet()function verifies Cashu tokens but may not handle all failure modes. Inserver/src/engine/payments.ts, theredeemCashuToken()function should also be hardened. Fix:- Add token amount verification: decode the Cashu token and verify the sum of proofs matches
amountSatsbefore accepting. - Add mint URL validation: only accept tokens from the configured
BOTFIGHTS_CASHU_MINT_URL. Reject tokens from unknown mints. - Add double-spend protection: before accepting a token, check if any proof
secretin the token already exists in the payments or bets table (store proof secrets on successful redemption). - 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 --noEmitpasses.
- Add token amount verification: decode the Cashu token and verify the sum of proofs matches
-
Payment idempotency keys: In
server/src/routes/payments.ts, add idempotency protection:POST /create-invoice: Accept an optionalidempotencyKeyin 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).POST /confirm/:paymentId: Already idempotent (returns existing status if already confirmed). No change needed.POST /submit-cashu: Add idempotency key check similar to create-invoice. Verify:cd server && npx tsc --noEmitpasses.
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 constantMAX_CONCURRENT_FIGHTS = 20at the top of the file. InrunFightAsync(), before starting a fight, checkactiveFighters.size / 2(each fight has 2 fighters). If>= MAX_CONCURRENT_FIGHTS, reject with error'Server at capacity — try again in a moment.'. Also add agetActiveFightCount()export that returnsMath.floor(activeFighters.size / 2). Inserver/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 --noEmitpasses. -
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/updatere-enables. Add an automatic recovery check: intrackWebhookResult(), if a bot hasconsecutiveErrors >= 5andisActive = false, andlastErrorAtis more than 1 hour ago, attempt a single test call to the webhook. If it succeeds, resetconsecutiveErrors = 0andisActive = 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 --noEmitpasses. -
Graceful shutdown: In
server/src/index.ts, add SIGTERM and SIGINT handlers that:- Call
stopBackgroundFights()fromserver/src/engine/background.ts - Wait up to 30 seconds for
activeFightersSet to empty (poll every 1s) - Close the database connection:
sqlite.close()fromserver/src/db/index.ts(export the sqlite instance if not already exported) - Log
[botfights] graceful shutdown complete process.exit(0)This prevents data corruption on deploy/restart. Verify:cd server && npx tsc --noEmitpasses.
- Call
-
Fight timeout enforcement: In
server/src/engine/orchestrator.ts, theexecuteFightRounds()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 aPromise.race()with a 5-minute timer. On timeout, cancel the fight (status = 'cancelled'), clean upactiveFighters, and log[orchestrator] fight ${fightId} timed out after 5 minutes. Verify:cd server && npx tsc --noEmitpasses.
Phase 7: Security Hardening
-
CORS origin restriction: In
server/src/app.ts, CORS is currently set to'*'(all origins) via theCORS_ORIGINenv var. For production, this should restrict to the actual frontend domain. Fix: Inapp.ts, where the CORS middleware is configured, change the default from'*'to checkNODE_ENV: if production, requireCORS_ORIGINto 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 headersAccess-Control-Allow-Credentials: trueand restrictAccess-Control-Allow-MethodstoGET, POST, DELETE, OPTIONS. Verify:cd server && npx tsc --noEmitpasses. -
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). ImportbodyLimitfromhono/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 --noEmitpasses. -
Helmet-style security headers: In
server/src/app.ts, add a middleware that sets security headers on all responses: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 --noEmitpasses. -
Webhook SSRF hardening: In
server/src/engine/orchestrator.ts, theisAllowedWebhookUrl()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) Blockfile://,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.tsregister and update endpoints). Verify:cd server && npx tsc --noEmitpasses. -
Wallet encryption key enforcement in production: In
server/src/engine/crypto.ts(38 lines), ifBOTFIGHTS_WALLET_ENCRYPTION_KEYis 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 ifBOTFIGHTS_WALLET_ENCRYPTION_KEYis 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 --noEmitpasses.
Phase 8: Observability & Health
-
Health check endpoint: In
server/src/app.ts, add aGET /healthendpoint (outside/api/*so it's not rate-limited) that returns:{ "status": "ok", "uptime": process.uptime(), "activeFights": activeFighters.size / 2, "dbOk": true/false, "timestamp": new Date().toISOString() }Check DB health by running
SELECT 1viasqlite.prepare('SELECT 1').get(). If it throws, setdbOk: falseandstatus: 'degraded'. ImportactiveFightersfrom orchestrator or use the newgetActiveFightCount(). Return 200 for ok, 503 for degraded. This endpoint will be used by the VPS monitoring and deployment scripts. Verify:cd server && npx tsc --noEmitpasses. -
Structured logging utility: Create
server/src/engine/logger.tswith a simple structured logger. No dependencies — just wrapconsole.log/console.errorwith JSON output in production and human-readable in dev: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.logcalls inorchestrator.ts,payments.ts, andbackground.tswith 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 --noEmitpasses. -
Request timing middleware: In
server/src/app.ts, add a middleware that logs slow requests. Before the routes, add: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
logfrom./engine/logger.js. This catches endpoints that are unexpectedly slow (DB queries without indexes, webhook timeouts). Verify:cd server && npx tsc --noEmitpasses. -
Fight metrics counter: In
server/src/engine/orchestrator.ts, add a simple metrics object that tracks counts since startup:export const metrics = { fightsStarted: 0, fightsCompleted: 0, fightsCancelled: 0, webhookErrors: 0, webhookTimeouts: 0, avgRoundMs: 0, totalRoundMs: 0, totalRounds: 0, }Increment
fightsStartedinrunFightAsync(),fightsCompleted/fightsCancelledinexecuteFightRounds(),webhookErrors/webhookTimeoutsincallWebhook(). Track round duration and compute running average. Expose these viaGET /healthendpoint (addmetricsfield to the health response). Verify:cd server && npx tsc --noEmitpasses.
Phase 9: Frontend Polish & Error States
-
Global API error handler: In
frontend/src/composables/useNostr.tsandfrontend/src/composables/useWallet.ts, API calls use rawfetch()without consistent error handling. Createfrontend/src/utils/api.tswith a thin wrapper: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 inuseNostr.ts(login, register, registerHuman, update) anduseWallet.ts(connectNWC, payEntryFee, createInvoice) withapiFetch(). HandleApiErrorin 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 --noEmitpasses. -
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:- Login button: add
isLoggingInref, set true duringhandleLogin(), disable button and show "LOGGING IN..." text while true. - Name confirm buttons (both bot and human): already have
isCheckingName— verify it works and the button text changes. - Webhook confirm: add
isTestingWebhookref, show "TESTING..." during the webhook verification. - Register: add
isRegisteringref, 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 --noEmitpasses.
- Login button: add
-
Error toast/notification system: Create
frontend/src/composables/useToast.ts— a minimal toast notification composable: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 />tofrontend/src/App.vue(or the root layout). Verify:cd frontend && npx vue-tsc --noEmitpasses.
Phase 10: Deployment & Operations
-
Database backup script: Create
server/scripts/backup-db.sh:#!/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 withbash -n server/scripts/backup-db.sh. -
Systemd service file: Create
server/deploy/botfights.service:[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.targetAlso create
server/deploy/botfights-backup.timerandserver/deploy/botfights-backup.servicefor 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:#!/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 1Make executable:
chmod +x server/scripts/deploy.sh. Verify:bash -n server/scripts/deploy.sh.
Phase 11: Typecheck & Final Verification
-
Full typecheck: Run
pnpm typecheckfrom 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.tsand verify:[botfights] database migratedappears[botfights] database indexes ensuredappears- No crash on startup
curl http://localhost:9100/healthreturns{"status":"ok",...}curl http://localhost:9100/api/botsreturns 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.