Files
botfights/server/src/app.ts
T
DorianandClaude Fable 5 2512265113
CI / check (push) Failing after 6m9s
fix: nostr-provider.js 404, DocsPage proxy-unaware URL, round-jump on late viewer join
Three more fixes found during live demo verification:

1. server/src/app.ts: the previous commit added <script src="/nostr-provider.js">
   to index.html and shipped the file into server/public/, but this app's
   static file serving is an explicit per-route allowlist, not a catch-all —
   there was no route registered for it, so it 404'd and the signer bridge
   silently never loaded. Added the missing app.get('/nostr-provider.js', ...)
   route.

2. DocsPage.vue: promptUrl (the displayed "give this URL to your AI" copy
   button) was built from window.location.origin — same root-cause bug class
   as the JoinBoutPage/BotProfilePage fix (ffd4dfd), just for a link instead
   of fetched content. Now resolves the real arena origin from the fetched
   prompt's own content (which IS correctly proxy-resolved server-side via
   arena-proxy) instead of the browser's current address.

3. FightPage.vue: opening a fight already in progress (e.g. a background
   poll-mode bot kept answering challenges while nobody had the viewer open)
   showed nothing until the next live round arrived — reads as "the fight
   jumped straight to round N". loadFight() always fetched the completed
   rounds (data.rounds) but nothing backfilled the visible log from them;
   only live SSE round_end events ever pushed into liveLogItems. Added
   backfillCompletedRounds(), called once on mount before wireSSE() connects,
   that renders a compact (non-animated — no scene/TTS replay) summary of
   every already-completed round and sets HP/round-counter to current state
   immediately.

4. BOTFIGHTS.md: documented the webhook_test signature exception (see ffd4dfd
   commit for the same fix already applied to the live doc endpoint's
   underlying example) — this file is frontend/public/docs/BOTFIGHTS.md,
   the static copy that predates today's /api/docs/prompt-only rendering
   fix; keeping both in sync since some flows may still reference the path.

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

228 lines
9.2 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'],
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<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()