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
+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 {