From 12d4b35404d8acabc4de562ea99d76d9d073ae58 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 30 Jul 2026 23:21:34 -0400 Subject: [PATCH] fix(09-05): GET /api/fights/poll was shadowed by GET /:id, always 404d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/src/routes/fights.ts registered the dynamic GET /:id route before the static GET /poll route. Hono resolves same-shape single-segment routes in registration order, so any GET /api/fights/poll request was matched as a fight-id lookup for id="poll" and always returned 404 {"error":"Fight not found."} instead of the poll handler's {"pending":false}/{"pending":true,...} response. This meant the polling protocol — one of the two bot integration modes BOT-02's unified prompt documents — never actually worked. Found while verifying bot auth against the freshly-rolled 1.2.0 arena (plan 09-05 Task 2 acceptance criterion), reproduced independently on a throwaway container with a fresh DB to confirm it wasn't an artifact of the arena's seeded data. Fix: move the /poll and /poll/respond route registrations above /:id. No other GET route in this router collides in shape with /:id (verified by listing every registered path/method pair). Co-Authored-By: Claude Fable 5 --- server/src/routes/fights.ts | 105 +++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts index 9235edc..a5ba1ab 100644 --- a/server/src/routes/fights.ts +++ b/server/src/routes/fights.ts @@ -88,6 +88,62 @@ fightsRouter.get('/', async (c) => { return c.json(enriched) }) +// --- Polling API (for bots that don't expose a public URL) --- +// NOTE: these two static routes (/poll, /poll/respond) MUST be registered +// before the dynamic GET /:id route below — Hono resolves same-shape +// single-segment routes in registration order, so a GET /:id registered +// first would otherwise shadow GET /poll (a literal request for +// GET /api/fights/poll would be matched as id="poll", a lookup that always +// 404s "Fight not found."). This was a real pre-existing bug: polling bots +// could never receive a challenge. Fixed 2026-07-31 (phase 09-05). + +// Poll for a pending challenge (bot authenticates with id+secret) +fightsRouter.get('/poll', rateLimit(1_000, 30), 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 }) +}) + // Get a single fight with rounds and bot details fightsRouter.get('/:id', async (c) => { const id = c.req.param('id') @@ -357,55 +413,6 @@ 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', rateLimit(1_000, 30), 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')