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
+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')