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
+34
-22
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user