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:
co-authored by
Claude Opus 4.6
parent
150ce7447d
commit
95ed80335a
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user