import { Hono } from 'hono' import { db, schema } from '../db/index.js' import { eq } from 'drizzle-orm' import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js' import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js' import { rateLimit } from '../middleware/rate-limit.js' import { joinRankedSchema, sanitizeError } from '../lib/validators.js' import { verifyBotOwner } from '../middleware/bot-auth.js' export const queueRouter = new Hono() // Get queue status queueRouter.get('/status', (c) => { return c.json({ waiting: getQueueSize(), queue: getQueueSnapshot(), }) }) // Join the queue — blocks until matched, then returns fightId queueRouter.post('/join/:botId', async (c) => { const botId = c.req.param('botId') const botRows = await db.select({ id: schema.bots.id, name: schema.bots.name }) .from(schema.bots) .where(eq(schema.bots.id, botId)) .limit(1) if (botRows.length === 0) { return c.json({ error: 'Bot not found.' }, 404) } try { const fightId = await joinQueue(botId) return c.json({ fightId, message: 'Matched! Fight starting.' }) } catch (err: any) { const raw = err instanceof Error ? err.message : '' const isConflict = raw.includes('already in a fight') const message = isConflict ? raw : sanitizeError(err, 'Queue error') const status = isConflict ? 409 : 500 return c.json({ error: message, fightId: err?.fightId || undefined }, status) } }) // Leave the queue queueRouter.post('/leave/:botId', (c) => { const botId = c.req.param('botId') const left = leaveQueue(botId) return c.json({ left }) }) // Get ranked queue status queueRouter.get('/ranked-status', (c) => { return c.json(getRankedQueueStatus()) }) // Join ranked queue — requires confirmed payment + bot ownership. // Ownership can be proven either way, since ranked/staked fights are for // BOTH audiences (not just nostr-signed-in humans): // 1. Authorization: Bearer (nostr-authenticated bots, the web UI's // own JWT session flow) — verified via verifyBotOwner, which derives // pubkey from the JWT itself, never from a client-supplied field. A // bare `body.pubkey === bot.publicKey` comparison (the previous // implementation here) is not an ownership check: pubkeys are public // by design in nostr, shown on every bot's own profile page, so it let // anyone who'd seen a bot's page join ranked queue as that bot. Found // and fixed alongside the identical bug at POST /api/auth/update (09-06). // 2. Authorization: Bot : (anonymous poll-mode bots — the // primary registration path for AI agents per BOTFIGHTS.md, which // never have a publicKey at all: confirmed live, publicKey is null // for every bot registered via POST /api/bots). Without this, staking // was silently unusable for the whole poll-mode/AI-agent audience. queueRouter.post('/join-ranked/:botId', async (c) => { const botId = c.req.param('botId') const parsed = joinRankedSchema.safeParse(await c.req.json().catch(() => ({}))) if (!parsed.success) { return c.json({ error: parsed.error.issues[0]?.message || 'Missing paymentId' }, 400) } const { paymentId } = parsed.data // Verify bot ownership in production if (process.env.NODE_ENV === 'production') { const ownerCheck = await verifyBotOwner(c, botId) if (ownerCheck !== true) return ownerCheck } try { const fightId = await joinRankedQueue(botId, paymentId) return c.json({ fightId, message: 'Ranked match found! Fight starting.' }) } catch (err) { const message = sanitizeError(err, 'Ranked queue error') return c.json({ error: message }, 500) } })