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)
})