Files
botfights/server/src/app.ts
T
DorianandClaude Fable 5 877d1f6389
CI / check (push) Failing after 6m10s
fix: broken profile images (CSP img-src), AI-answer discoverability, arena compose tag
1. server/src/app.ts: CSP img-src only allowed 'self'/data:/blob: — nostr
   profile pictures come from kind:0 metadata, a URL the user sets via their
   own client, hosted on whatever domain they picked. There's no central
   image host for a decentralized identity system, so every external
   profile pic was CSP-blocked and rendered as a broken image. Added
   https: (broad) — safe here since images can't execute script even from
   an untrusted origin, unlike script-src which stays locked to 'self'.

2. frontend/src/pages/JoinBoutPage.vue: the new AI-answer option (1.2.7)
   was reported as invisible — it was gated behind picking POLLING (not the
   default WEBHOOK) AND behind a collapsed toggle within that. Changed:
   POLLING is now the default mode (also the documented default in
   BOTFIGHTS.md), the AI section is expanded by default instead of
   collapsed, and the POLLING button's own description now mentions the
   option so it's visible without any extra click.

3. docker-compose.arena.yml: image tag 1.2.1 -> 1.2.7, matching what's
   actually deployed on the canonical arena (rolled live via
   ssh+docker compose pull/up this session — this commit just brings the
   repo's copy of the compose file back in sync with reality).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 08:52:21 -04:00

235 lines
9.7 KiB
TypeScript

import { Hono, type Context } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { logger as appLogger } from './lib/logger.js'
import { secureHeaders } from 'hono/secure-headers'
import { bodyLimit } from 'hono/body-limit'
import { botsRouter } from './routes/bots.js'
import { fightsRouter } from './routes/fights.js'
import { queueRouter } from './routes/queue.js'
import { authRouter } from './routes/auth.js'
import { docsRouter } from './routes/docs.js'
import { betsRouter } from './routes/bets.js'
import { paymentsRouter } from './routes/payments.js'
import { tournamentsRouter } from './routes/tournaments.js'
import { adminRouter } from './routes/admin.js'
import { statsRouter } from './routes/stats.js'
import { arcadeRouter } from './routes/arcade.js'
import { rateLimit } from './middleware/rate-limit.js'
import { arenaProxy } from './middleware/arena-proxy.js'
import { existsSync, readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { cleanupOrphanedFights } from './engine/orchestrator.js'
import { recoverOrphanedPayments } from './engine/payments.js'
import { startDailyBackups } from './engine/backup.js'
import { startMemoryTracking } from './engine/analytics.js'
export const app = new Hono()
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)
})
app.use('*', logger())
// CORS: lock down in production, allow all in dev
// Supports comma-separated origins: CORS_ORIGIN=https://a.com,https://b.com
const corsEnv = process.env.CORS_ORIGIN || '*'
const allowedOrigins = corsEnv === '*' ? '*' : corsEnv.split(',').map(s => s.trim())
app.use('/api/*', cors({
origin: Array.isArray(allowedOrigins)
? (origin) => allowedOrigins.includes(origin) ? origin : allowedOrigins[0]
: allowedOrigins,
}))
// COOP/COEP headers: required for SharedArrayBuffer (Kokoro TTS WASM threading)
app.use('*', async (c, next) => {
await next()
c.header('Cross-Origin-Opener-Policy', 'same-origin')
c.header('Cross-Origin-Embedder-Policy', 'credentialless')
})
// Security headers: X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, etc.
// ARCHY_EMBEDDED=1 means this instance is running as an app inside the
// Archipelago node dashboard's iframe (a first-party, trusted embedding
// context on the same host, different port — never a third-party site).
// X-Frame-Options: SAMEORIGIN (the secureHeaders default) blocks that framing
// outright, since the dashboard and this app are different origins by port.
// Standalone/public-arena instances (ARCHY_EMBEDDED unset) keep the default
// clickjacking protection.
const isEmbedded = process.env.ARCHY_EMBEDDED === '1'
app.use('*', secureHeaders({
xFrameOptions: isEmbedded ? false : true,
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'blob:', "'wasm-unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
// https: (broad) is required, not optional: profile pictures come from
// nostr kind:0 metadata events — a URL the USER sets via their own
// client, hosted on whatever domain they picked. There is no central
// image host to allowlist for a decentralized identity system. Images
// can't execute script even from an untrusted origin, so this is the
// standard, safe CSP relaxation for user-supplied avatar URLs (unlike
// broadening script-src, which stays locked to 'self').
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: ["'self'", 'https://huggingface.co', 'https://*.huggingface.co', 'https://*.hf.co', 'https://cdn.jsdelivr.net', 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
workerSrc: ["'self'", 'blob:'],
} : undefined,
}))
// Body size limit: 256KB max for API requests (prevents OOM)
app.use('/api/*', bodyLimit({ maxSize: 256 * 1024 }))
// Global rate limit (120/min per IP — generous for polling + signup flows)
app.use('/api/*', rateLimit(60_000, 300))
// API cache headers
app.use('/api/*', async (c, next) => {
await next()
// Default: no-store for dynamic data
if (!c.res.headers.has('Cache-Control')) {
c.header('Cache-Control', 'no-store')
}
})
// Leaderboard and public stats can be cached briefly
app.use('/api/bots/leaderboard', async (c, next) => {
await next()
c.header('Cache-Control', 'public, max-age=60')
})
app.use('/api/stats/public', async (c, next) => {
await next()
c.header('Cache-Control', 'public, max-age=300')
})
app.use('/api/docs/*', async (c, next) => {
await next()
if (c.req.method === 'GET') c.header('Cache-Control', 'public, max-age=3600')
})
// When ARENA_UPSTREAM_URL is set, forward every /api/* request to the
// canonical arena instead of the local routers (BOT-03). No-ops otherwise.
app.use('/api/*', arenaProxy)
app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' }))
app.route('/api/auth', authRouter)
app.route('/api/bots', botsRouter)
app.route('/api/fights', fightsRouter)
app.route('/api/queue', queueRouter)
app.route('/api/docs', docsRouter)
app.route('/api/bets', betsRouter)
app.route('/api/payments', paymentsRouter)
app.route('/api/tournaments', tournamentsRouter)
app.route('/api/admin', adminRouter)
app.route('/api/stats', statsRouter)
app.route('/api/arcade', arcadeRouter)
// In production, serve the frontend SPA
const __dirname = dirname(fileURLToPath(import.meta.url))
const publicDir = join(__dirname, '..', 'public')
if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
const MIME: Record<string, string> = {
js: 'application/javascript',
css: 'text/css',
html: 'text/html',
json: 'application/json',
png: 'image/png',
jpg: 'image/jpeg',
svg: 'image/svg+xml',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
webp: 'image/webp',
webmanifest: 'application/manifest+json',
wav: 'audio/wav',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
md: 'text/markdown',
}
function serveFile(c: Context, reqPath: string, cacheControl: string) {
const resolved = join(publicDir, reqPath)
// Prevent path traversal
if (!resolved.startsWith(publicDir)) return c.notFound()
if (!existsSync(resolved)) return c.notFound()
const ext = resolved.split('.').pop() || ''
c.header('Content-Type', MIME[ext] || 'application/octet-stream')
c.header('Cache-Control', cacheControl)
return c.body(readFileSync(resolved))
}
// Hashed assets — immutable cache. If missing (stale deploy), return JS that triggers reload.
app.get('/assets/*', (c) => {
const resolved = join(publicDir, c.req.path)
if (!resolved.startsWith(publicDir) || !existsSync(resolved)) {
// Stale chunk hash from old deploy — tell the browser to reload
c.header('Content-Type', 'application/javascript')
c.header('Cache-Control', 'no-cache')
return c.body('window.location.reload();')
}
return serveFile(c, c.req.path, 'public, max-age=31536000, immutable')
})
// Root static files (favicon, manifest, robots, etc.)
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
app.get('/robots.txt', (c) => serveFile(c, '/robots.txt', 'public, max-age=86400'))
app.get('/manifest.webmanifest', (c) => serveFile(c, '/manifest.webmanifest', 'public, max-age=86400'))
// PWA service worker + related root files
app.get('/sw.js', (c) => serveFile(c, '/sw.js', 'no-cache'))
app.get('/registerSW.js', (c) => serveFile(c, '/registerSW.js', 'no-cache'))
app.get('/workbox-*.js', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable'))
app.get('/icon-*.png', (c) => serveFile(c, c.req.path, 'public, max-age=86400'))
app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400'))
app.get('/apple-touch-icon.png', (c) => serveFile(c, '/apple-touch-icon.png', 'public, max-age=86400'))
// Archipelago native NIP-07 signer bridge (see index.html <script> tag) —
// no-cache since it's a small, host-provided shim that should always be
// fresh, not a hashed/immutable build asset.
app.get('/nostr-provider.js', (c) => serveFile(c, '/nostr-provider.js', 'no-cache'))
// Docs (markdown setup guides)
app.get('/docs/*', (c) => serveFile(c, c.req.path, 'public, max-age=3600'))
// Audio files (pre-generated TTS, SFX)
app.get('/audio/*', (c) => serveFile(c, c.req.path, 'public, max-age=86400'))
// SPA fallback: only for navigation requests (not JS/CSS/asset files)
app.get('*', (c) => {
if (c.req.path.startsWith('/api/')) return c.notFound()
// Don't serve index.html for asset requests — return 404 so the browser gets a proper error
const ext = c.req.path.split('.').pop()
if (ext && ext !== c.req.path && ['js', 'css', 'map', 'json', 'png', 'jpg', 'svg', 'woff', 'woff2', 'webp', 'ico', 'wav', 'mp3', 'ogg', 'md'].includes(ext)) {
return c.notFound()
}
const indexPath = join(publicDir, 'index.html')
c.header('Content-Type', 'text/html')
c.header('Cache-Control', 'no-cache')
return c.body(readFileSync(indexPath))
})
appLogger.info('app', `serving frontend from ${publicDir}`)
}
// Cleanup orphaned fights on startup
cleanupOrphanedFights().then(() => {
appLogger.info('app', 'orphaned fights cleaned up')
}).catch(err => {
appLogger.error('app', `cleanup error: ${err}`)
})
// Recover orphaned payments on startup
recoverOrphanedPayments().catch(err => {
appLogger.error('app', `payment recovery error: ${err}`)
})
// Start daily database backups (production only)
if (process.env.NODE_ENV === 'production') {
startDailyBackups()
}
// Start memory tracking
startMemoryTracking()