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'], imgSrc: ["'self'", 'data:', 'blob:'], 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 = { 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