Files
botfights/server/src/routes/queue.ts
T

95 lines
3.7 KiB
TypeScript
Raw Normal View History

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 <jwt> (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 <id>:<secret> (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)
}
})