feat: polling API, HMAC webhook signing, session-only keys, prod audio fix

- Add polling API (GET/POST /api/fights/poll) so bots don't need public URLs
- Add HMAC-SHA256 webhook signing (X-Botfights-Signature header)
- Stop auto-persisting nsec keys — session-only by default with opt-in "Remember on this device"
- Fix production TTS: add wav/mp3/ogg MIME types, /audio/* route, SPA blocklist
- Overhaul docs: mode selector (poll vs webhook), AI-first bot examples, security tab
- Fix duplicate sign-in buttons, login flow bugs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 18:34:22 +00:00
co-authored by Claude Opus 4.6
parent 150ce7447d
commit 95ed80335a
12 changed files with 1143 additions and 433 deletions
+6 -1
View File
@@ -122,6 +122,9 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
woff2: 'font/woff2',
webp: 'image/webp',
webmanifest: 'application/manifest+json',
wav: 'audio/wav',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
}
function serveFile(c: Context, reqPath: string, cacheControl: string) {
@@ -160,12 +163,14 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
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'))
// 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'].includes(ext)) {
if (ext && ext !== c.req.path && ['js', 'css', 'map', 'json', 'png', 'jpg', 'svg', 'woff', 'woff2', 'webp', 'ico', 'wav', 'mp3', 'ogg'].includes(ext)) {
return c.notFound()
}
const indexPath = join(publicDir, 'index.html')
+6 -3
View File
@@ -2,7 +2,8 @@ import { FIGHT_LOOP_INTERVAL_MS, ELO_MATCHING_RANDOMNESS } from '../lib/constant
import { logger } from '../lib/logger.js'
import { db, schema } from '../db/index.js'
import { runMockFight } from './mock.js'
import { eq } from 'drizzle-orm'
import { eq, ne } from 'drizzle-orm'
import { isPollingBot } from './poll-responses.js'
import { pick, toError } from '../lib/utils.js'
import { fightEvents } from './events.js'
@@ -58,14 +59,16 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
while (fightCount < maxFights) {
try {
// Re-fetch bots to get updated elo ratings
const bots = await db.select({
// Re-fetch bots to get updated elo ratings (exclude polling bots — they need active polling)
const allFetchedBots = await db.select({
id: schema.bots.id,
eloRating: schema.bots.eloRating,
name: schema.bots.name,
wins: schema.bots.wins,
losses: schema.bots.losses,
webhookUrl: schema.bots.webhookUrl,
}).from(schema.bots)
const bots = allFetchedBots.filter(b => !isPollingBot(b.webhookUrl))
const [botA, botB] = pickMatchup(bots, matchmakingStyle, fightCount)
+27 -3
View File
@@ -19,6 +19,8 @@ import { getCurrentSeason } from './seasons.js'
import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js'
import { invalidateLeaderboardCache } from '../routes/bots.js'
import { createHmac } from 'crypto'
import { isPollingBot, waitForPollResponse } from './poll-responses.js'
const webhookResponseSchema = z.object({
answer: z.string().nullable().optional(),
@@ -29,6 +31,7 @@ interface BotRecord {
id: string
name: string
webhookUrl: string
secretHash: string
eloRating: number
wins: number
losses: number
@@ -146,6 +149,7 @@ async function callWebhook(
fightId: string,
opponent: { name: string; wins: number; losses: number },
arena: Arena,
secretHash?: string,
): Promise<WebhookResponse> {
const body = JSON.stringify({
fight_id: fightId,
@@ -174,9 +178,20 @@ async function callWebhook(
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
// HMAC-SHA256 signature for webhook verification
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (secretHash) {
const timestamp = Math.floor(Date.now() / 1000).toString()
const signature = createHmac('sha256', secretHash)
.update(`${timestamp}.${body}`)
.digest('hex')
headers['X-Botfights-Signature'] = `sha256=${signature}`
headers['X-Botfights-Timestamp'] = timestamp
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body,
signal: controller.signal,
})
@@ -288,8 +303,17 @@ async function getBotResponse(
error: mock.error,
}
}
if (isPollingBot(bot.webhookUrl)) {
logger.info('fight', `${bot.name} is polling bot, waiting for poll response`)
emit(fightId, 'poll_challenge', { botId: bot.id, round: roundNumber, type: challenge.type })
const start = Date.now()
const result = await waitForPollResponse(fightId, bot.id, challenge, roundNumber, opponent, arena.id, arena.modifier)
const elapsed = Date.now() - start
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
}
logger.info('fight', `${bot.name} has real webhook: ${bot.webhookUrl}`)
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena)
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena, bot.secretHash)
}
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
@@ -332,7 +356,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena,
// Track webhook errors per bot
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl) || isPollingBot(webhookUrl)) return
if (succeeded) {
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
} else {
+140
View File
@@ -0,0 +1,140 @@
// In-memory store for pending polling bot challenges.
// When the fight engine needs a polling bot's response, it stores the challenge here
// and waits for the bot to poll and submit an answer via REST.
import { logger } from '../lib/logger.js'
import type { Challenge } from './challenges.js'
import { POLL_GRACE_MS } from '../lib/constants.js'
interface PendingPollChallenge {
fightId: string
botId: string
challenge: Challenge
roundNumber: number
createdAt: number
opponent: { name: string; wins: number; losses: number }
arena: string
arenaModifier: string | null
resolve: (response: { answer: string | null; trashTalk?: string; timedOut: boolean }) => void
timeoutHandle: ReturnType<typeof setTimeout>
}
const pending = new Map<string, PendingPollChallenge>()
// Secondary index: botId -> key (for poll lookups where bot doesn't know fightId)
const botIndex = new Map<string, string>()
/** Clear all pending poll challenges (used during graceful shutdown) */
export function clearAllPendingPolls(): void {
for (const [key, entry] of pending) {
clearTimeout(entry.timeoutHandle)
pending.delete(key)
}
botIndex.clear()
}
export function isPollingBot(webhookUrl: string): boolean {
return webhookUrl === 'http://poll.local/'
}
export function waitForPollResponse(
fightId: string,
botId: string,
challenge: Challenge,
roundNumber: number,
opponent: { name: string; wins: number; losses: number },
arena: string,
arenaModifier: string | null,
): Promise<{ answer: string | null; trashTalk?: string; timedOut: boolean }> {
return new Promise((resolve) => {
const key = `${fightId}:${botId}`
const timeoutMs = (challenge.timeout_ms || 8000) + POLL_GRACE_MS
const timeoutHandle = setTimeout(() => {
pending.delete(key)
botIndex.delete(botId)
logger.info('poll', `${key} timed out after ${timeoutMs}ms`)
resolve({ answer: null, timedOut: true })
}, timeoutMs)
pending.set(key, {
fightId,
botId,
challenge,
roundNumber,
createdAt: Date.now(),
opponent,
arena,
arenaModifier,
resolve: (response) => {
clearTimeout(timeoutHandle)
pending.delete(key)
botIndex.delete(botId)
resolve(response)
},
timeoutHandle,
})
botIndex.set(botId, key)
logger.info('poll', `waiting: ${key} round=${roundNumber} type=${challenge.type} timeout=${timeoutMs}ms`)
})
}
export function submitPollResponse(
botId: string,
answer: string,
trashTalk?: string,
): boolean {
const key = botIndex.get(botId)
if (!key) return false
const entry = pending.get(key)
if (!entry) return false
logger.info('poll', `response: ${key} answer=${answer.slice(0, 80)}`)
entry.resolve({
answer: answer.slice(0, 2000),
trashTalk: trashTalk?.slice(0, 200),
timedOut: false,
})
return true
}
export function getPendingPollChallenge(botId: string): {
fightId: string
type: string
label: string
prompt: string
roundNumber: number
timeoutMs: number
remainingMs: number
scoring: string
opponent: { name: string; wins: number; losses: number }
arena: string
arenaModifier: string | null
constraints: { timeout_ms: number; max_tokens: number }
} | null {
const key = botIndex.get(botId)
if (!key) return null
const entry = pending.get(key)
if (!entry) return null
const totalTimeoutMs = (entry.challenge.timeout_ms || 8000) + POLL_GRACE_MS
const elapsed = Date.now() - entry.createdAt
const remaining = Math.max(0, totalTimeoutMs - elapsed)
return {
fightId: entry.fightId,
type: entry.challenge.type,
label: entry.challenge.label,
prompt: entry.challenge.prompt,
roundNumber: entry.roundNumber,
timeoutMs: entry.challenge.timeout_ms || 8000,
remainingMs: remaining,
scoring: entry.challenge.scoring,
opponent: entry.opponent,
arena: entry.arena,
arenaModifier: entry.arenaModifier,
constraints: {
timeout_ms: entry.challenge.timeout_ms || 8000,
max_tokens: 500,
},
}
}
+3
View File
@@ -85,3 +85,6 @@ const CREATOR_PUBKEY = 'da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d
export function isCreatorPubkey(pubkey: string | undefined | null): boolean {
return !!pubkey && pubkey === CREATOR_PUBKEY
}
// --- Polling bot ---
export const POLL_GRACE_MS = 10_000 // Extra time for polling bots to discover + respond
+76
View File
@@ -0,0 +1,76 @@
// Bot authentication helper.
// Verifies bot identity via bot_id + secret (SHA256 hash comparison).
// Supports: Authorization header or query params.
import { createHash } from 'crypto'
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import type { Context } from 'hono'
export interface BotAuthContext {
botId: string
botName: string
webhookUrl: string
}
/** Extract and verify bot credentials from request. Returns bot context or error response. */
export async function authenticateBot(c: Context): Promise<BotAuthContext | Response> {
let botId: string | undefined
let secret: string | undefined
// 1. Authorization: Bot <bot_id>:<secret>
const auth = c.req.header('Authorization')
if (auth?.startsWith('Bot ')) {
const parts = auth.slice(4).split(':')
if (parts.length === 2 && parts[0] && parts[1]) {
botId = parts[0]
secret = parts[1]
}
}
// 2. Query params: ?bot_id=xxx&secret=yyy
if (!botId || !secret) {
const qBotId = c.req.query('bot_id')
const qSecret = c.req.query('secret')
if (qBotId && qSecret) {
botId = qBotId
secret = qSecret
}
}
if (!botId || !secret) {
return c.json({ error: 'Authentication required. Use Authorization: Bot <bot_id>:<secret> or query params ?bot_id=...&secret=...' }, 401)
}
const hash = createHash('sha256').update(secret).digest('hex')
const rows = await db.select({
id: schema.bots.id,
name: schema.bots.name,
secretHash: schema.bots.secretHash,
webhookUrl: schema.bots.webhookUrl,
}).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (rows.length === 0) {
return c.json({ error: 'Invalid bot_id or secret.' }, 401)
}
// Constant-time comparison
const expected = rows[0].secretHash
if (hash.length !== expected.length) {
return c.json({ error: 'Invalid bot_id or secret.' }, 401)
}
let mismatch = 0
for (let i = 0; i < hash.length; i++) {
mismatch |= hash.charCodeAt(i) ^ expected.charCodeAt(i)
}
if (mismatch !== 0) {
return c.json({ error: 'Invalid bot_id or secret.' }, 401)
}
return {
botId: rows[0].id,
botName: rows[0].name,
webhookUrl: rows[0].webhookUrl,
}
}
+34 -22
View File
@@ -153,18 +153,21 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
const normalizedName = name.toLowerCase()
if (!webhookUrl || typeof webhookUrl !== 'string') {
return c.json({ error: 'webhookUrl is required.' }, 400)
}
// Poll mode: if no webhookUrl provided, bot will poll for challenges
const isPollMode = !webhookUrl || webhookUrl === ''
try {
new URL(webhookUrl)
} catch {
return c.json({ error: 'webhookUrl must be a valid URL.' }, 400)
}
if (!isAllowedWebhookUrl(webhookUrl)) {
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
if (!isPollMode) {
if (typeof webhookUrl !== 'string') {
return c.json({ error: 'webhookUrl must be a string.' }, 400)
}
try {
new URL(webhookUrl)
} catch {
return c.json({ error: 'webhookUrl must be a valid URL.' }, 400)
}
if (!isAllowedWebhookUrl(webhookUrl)) {
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
}
}
// Check pubkey not already used
@@ -187,18 +190,23 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
return c.json({ error: 'A bot with that name already exists.' }, 409)
}
// Test the webhook
const testResult = await testWebhook(webhookUrl)
if (!testResult.reachable || !testResult.validResponse) {
return c.json({
error: 'Webhook verification failed.',
details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.',
latencyMs: testResult.latencyMs,
}, 422)
// Test the webhook (skip for poll mode)
let testResult: { latencyMs: number } | null = null
if (!isPollMode) {
const result = await testWebhook(webhookUrl)
if (!result.reachable || !result.validResponse) {
return c.json({
error: 'Webhook verification failed.',
details: result.error || 'Webhook must return {"answer": "..."} as JSON.',
latencyMs: result.latencyMs,
}, 422)
}
testResult = result
}
const id = nanoid(12)
const secret = randomBytes(32).toString('hex')
const effectiveWebhookUrl = isPollMode ? 'http://poll.local/' : webhookUrl
const baseArchetype = custResult.data.archetype || archetype || 'standard'
const effectiveArchetype = isCreatorPubkey(pubkey) ? 'the_creator' : baseArchetype
@@ -207,7 +215,7 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
await db.insert(schema.bots).values({
id,
name: normalizedName,
webhookUrl,
webhookUrl: effectiveWebhookUrl,
avatarSeed: normalizedName,
archetype: effectiveArchetype,
secretHash: createHash('sha256').update(secret).digest('hex'),
@@ -220,10 +228,14 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
return c.json({
id,
name: normalizedName,
secret,
mode: isPollMode ? 'poll' : 'webhook',
archetype: effectiveArchetype,
customization: custResult.data,
webhookLatencyMs: testResult.latencyMs,
message: 'Bot registered. Webhook verified.',
webhookLatencyMs: testResult?.latencyMs ?? null,
message: isPollMode
? 'Bot registered in poll mode. No public URL needed. Use GET /api/fights/poll to receive challenges.'
: 'Bot registered. Webhook verified.',
}, 201)
})
+33 -23
View File
@@ -39,19 +39,21 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
// Force lowercase for case-insensitive uniqueness
const normalizedName = name.toLowerCase()
if (!webhook_url || typeof webhook_url !== 'string') {
return c.json({ error: 'webhook_url is required.' }, 400)
}
// Poll mode: webhook_url is optional
const isPollMode = !webhook_url || webhook_url === ''
try {
new URL(webhook_url)
} catch {
return c.json({ error: 'webhook_url must be a valid URL.' }, 400)
}
// SSRF check
if (!isAllowedWebhookUrl(webhook_url)) {
return c.json({ error: 'webhook_url must not point to private/internal addresses.' }, 400)
if (!isPollMode) {
if (typeof webhook_url !== 'string') {
return c.json({ error: 'webhook_url must be a string.' }, 400)
}
try {
new URL(webhook_url)
} catch {
return c.json({ error: 'webhook_url must be a valid URL.' }, 400)
}
if (!isAllowedWebhookUrl(webhook_url)) {
return c.json({ error: 'webhook_url must not point to private/internal addresses.' }, 400)
}
}
// Check for duplicate name (case-insensitive)
@@ -64,23 +66,28 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
return c.json({ error: 'A bot with that name already exists.' }, 409)
}
// Test the webhook before accepting registration
const testResult = await testWebhook(webhook_url)
if (!testResult.reachable || !testResult.validResponse) {
return c.json({
error: 'Webhook verification failed.',
details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.',
latencyMs: testResult.latencyMs,
}, 422)
// Test the webhook before accepting registration (skip for poll mode)
let testResult: { latencyMs: number } | null = null
if (!isPollMode) {
const result = await testWebhook(webhook_url)
if (!result.reachable || !result.validResponse) {
return c.json({
error: 'Webhook verification failed.',
details: result.error || 'Webhook must return {"answer": "..."} as JSON.',
latencyMs: result.latencyMs,
}, 422)
}
testResult = result
}
const id = nanoid(12)
const secret = randomBytes(32).toString('hex')
const effectiveWebhookUrl = isPollMode ? 'http://poll.local/' : webhook_url
await db.insert(schema.bots).values({
id,
name: normalizedName,
webhookUrl: webhook_url,
webhookUrl: effectiveWebhookUrl,
avatarSeed: avatar_seed || normalizedName,
secretHash: hashSecret(secret),
createdAt: new Date().toISOString(),
@@ -90,8 +97,11 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
id,
name: normalizedName,
secret,
webhookLatencyMs: testResult.latencyMs,
message: 'Bot registered. Webhook verified. Save your secret -- it will not be shown again.',
mode: isPollMode ? 'poll' : 'webhook',
webhookLatencyMs: testResult?.latencyMs ?? null,
message: isPollMode
? 'Bot registered in poll mode. Save your secret and bot ID. Use GET /api/fights/poll to receive challenges.'
: 'Bot registered. Webhook verified. Save your secret -- it will not be shown again.',
}, 201)
})
+51
View File
@@ -11,6 +11,8 @@ import { runFight, runFightAsync, isInFight, getActiveFightId } from '../engine/
import { fightEvents } from '../engine/events.js'
import { botRateLimit } from '../middleware/rate-limit.js'
import { getPendingChallenge, getPendingAnswers, submitHumanResponse } from '../engine/human-responses.js'
import { getPendingPollChallenge, submitPollResponse } from '../engine/poll-responses.js'
import { authenticateBot } from '../middleware/bot-auth.js'
import { checkAnswer } from '../engine/answers.js'
// --- Request validation schemas ---
@@ -365,6 +367,55 @@ fightsRouter.post('/:fightId/respond/:botId', async (c) => {
return c.json({ accepted: true, correct })
})
// --- Polling API (for bots that don't expose a public URL) ---
// Poll for a pending challenge (bot authenticates with id+secret)
fightsRouter.get('/poll', async (c) => {
const botOrRes = await authenticateBot(c)
if (botOrRes instanceof Response) return botOrRes
const bot = botOrRes
const challenge = getPendingPollChallenge(bot.botId)
if (!challenge) {
return c.json({ pending: false })
}
return c.json({
pending: true,
fight_id: challenge.fightId,
round: challenge.roundNumber,
type: challenge.type,
challenge: challenge.prompt,
constraints: challenge.constraints,
opponent: challenge.opponent,
arena: challenge.arena,
arena_modifier: challenge.arenaModifier,
remaining_ms: challenge.remainingMs,
scoring: challenge.scoring,
})
})
// Submit answer to a pending poll challenge
fightsRouter.post('/poll/respond', async (c) => {
const botOrRes = await authenticateBot(c)
if (botOrRes instanceof Response) return botOrRes
const bot = botOrRes
const parsed = respondSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: 'Answer is required (string, 1-2000 chars).' }, 400)
}
const { answer, trashTalk } = parsed.data
const accepted = submitPollResponse(bot.botId, answer, trashTalk)
if (!accepted) {
return c.json({ error: 'No pending challenge. Either timed out or no active fight.' }, 404)
}
return c.json({ accepted: true })
})
// SSE stream for live fight events
fightsRouter.get('/:id/stream', (c) => {
const fightId = c.req.param('id')