refactor: add centralized Zod validators for all API inputs

Create server/src/lib/validators.ts with reusable schemas for all API
inputs (auth, fights, bets, payments, tournaments, queue, docs).
Import and use in all route handlers, replacing inline validation.
Add formatZodError helper for user-friendly error messages.
77 test cases in validators.test.ts cover valid, invalid, boundary,
and attack inputs (SQL injection, XSS, prototype pollution).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 09:16:04 +00:00
co-authored by Claude Opus 4.6
parent 55d0f84251
commit 2051a95e13
11 changed files with 549 additions and 122 deletions
+22 -34
View File
@@ -8,6 +8,7 @@ import { validateCustomization } from '../engine/customization.js'
import { testWebhook } from '../engine/webhook-test.js'
import { rateLimit } from '../middleware/rate-limit.js'
import { isCreatorPubkey } from '../lib/constants.js'
import { loginSchema, registerSchema, registerHumanSchema, updateBotSchema, pubkeySchema, formatZodError } from '../lib/validators.js'
export const authRouter = new Hono()
@@ -26,12 +27,11 @@ authRouter.get("/check-name/:name", async (c) => {
// Login with Nostr pubkey (rate limited: 30 per minute per IP)
authRouter.post('/login', rateLimit(60_000, 30), async (c) => {
const body = await c.req.json()
const { pubkey } = body
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
const parsed = loginSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
const { pubkey } = parsed.data
const rows = await db.select({
id: schema.bots.id,
@@ -130,20 +130,15 @@ authRouter.post('/login', rateLimit(60_000, 30), async (c) => {
// Register a new bot with Nostr pubkey
authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
const body = await c.req.json()
const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = body
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
const parsed = registerSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {
pubkey: 'Invalid pubkey.',
name: 'Name must be 2-12 alphanumeric characters, hyphens, or underscores.',
webhookUrl: 'webhookUrl must be a valid URL.',
}, 'Invalid registration data.') }, 400)
}
const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = parsed.data
// Validate customization
const custResult = validateCustomization(rawCustomization)
@@ -242,20 +237,14 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
// Register a human player (no webhook required)
authRouter.post('/register-human', rateLimit(600_000, 10), async (c) => {
const body = await c.req.json()
const { pubkey, name, profilePicUrl, avatarSeed } = body
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
const parsed = registerHumanSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {
pubkey: 'Invalid pubkey.',
name: 'Name must be 2-12 alphanumeric characters, hyphens, or underscores.',
}, 'Invalid registration data.') }, 400)
}
const { pubkey, name, profilePicUrl, avatarSeed } = parsed.data
const normalizedName = name.toLowerCase()
@@ -306,12 +295,11 @@ authRouter.post('/register-human', rateLimit(600_000, 10), async (c) => {
// Update bot webhook and/or customization (requires pubkey match)
authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
const body = await c.req.json()
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
const parsed = updateBotSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = parsed.data
const rows = await db.select({ id: schema.bots.id, customization: schema.bots.customization })
.from(schema.bots)
+1 -1
View File
@@ -57,7 +57,7 @@ async function placeBetReq(cashuToken: string, overrides: Record<string, any> =
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fightId: 'test-fight',
pubkey: 'deadbeef',
pubkey: 'a'.repeat(64),
botId: 'test-bot',
amountSats: 100,
cashuToken,
+18 -18
View File
@@ -1,5 +1,6 @@
import { Hono } from 'hono'
import { toError } from '../lib/utils.js'
import { placeBetSchema, depositSchema, withdrawSchema, formatZodError } from '../lib/validators.js'
import { getDecodedToken } from '@cashu/cashu-ts'
import { db, schema } from '../db/index.js'
import { eq, desc } from 'drizzle-orm'
@@ -49,16 +50,13 @@ betsRouter.get('/odds/:fightId', async (c) => {
// Place a bet
betsRouter.post('/place', rateLimit(60_000, 10), async (c) => {
const body = await c.req.json()
const { fightId, pubkey, botId, amountSats, cashuToken } = body
if (!fightId || !pubkey || !botId || !amountSats || !cashuToken) {
return c.json({ error: 'Missing required fields.' }, 400)
}
if (typeof amountSats !== 'number' || !Number.isInteger(amountSats) || amountSats < 1 || amountSats > 1_000_000) {
return c.json({ error: 'amountSats must be an integer between 1 and 1,000,000' }, 400)
const parsed = placeBetSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {
amountSats: 'amountSats must be an integer between 1 and 1,000,000',
}, 'Missing required fields.') }, 400)
}
const { fightId, pubkey, botId, amountSats, cashuToken } = parsed.data
// Validate Cashu token format before any DB lookups
try {
@@ -161,13 +159,14 @@ betsRouter.get('/history/:pubkey', async (c) => {
// Lightning deposit — get invoice
betsRouter.post('/deposit', rateLimit(60_000, 5), async (c) => {
const { amountSats, pubkey } = await c.req.json()
if (!amountSats || !pubkey) {
return c.json({ error: 'Missing amountSats or pubkey.' }, 400)
}
if (amountSats < 100 || amountSats > 1_000_000) {
return c.json({ error: 'Amount must be 100-1,000,000 sats.' }, 400)
const parsed = depositSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {
amountSats: 'Amount must be 100-1,000,000 sats.',
pubkey: 'Missing amountSats or pubkey.',
}, 'Missing amountSats or pubkey.') }, 400)
}
const { amountSats, pubkey } = parsed.data
const invoice = await createDepositInvoice(amountSats, pubkey)
return c.json(invoice)
@@ -175,10 +174,11 @@ betsRouter.post('/deposit', rateLimit(60_000, 5), async (c) => {
// Lightning withdraw — burn Cashu tokens, pay LN invoice
betsRouter.post('/withdraw', rateLimit(60_000, 3), async (c) => {
const { cashuToken, bolt11 } = await c.req.json()
if (!cashuToken || !bolt11) {
return c.json({ error: 'Missing cashuToken or bolt11 invoice.' }, 400)
const parsed = withdrawSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message || 'Missing cashuToken or bolt11 invoice.' }, 400)
}
const { cashuToken, bolt11 } = parsed.data
const result = await withdrawToLightning(cashuToken, bolt11)
if (!result.success) {
+3 -6
View File
@@ -8,6 +8,7 @@ import { computeAchievements } from '../engine/achievements.js'
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
import { testWebhook } from '../engine/webhook-test.js'
import { rateLimit } from '../middleware/rate-limit.js'
import { botNameSchema, httpUrlSchema } from '../lib/validators.js'
export const botsRouter = new Hono()
@@ -34,12 +35,8 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
const body = await c.req.json()
const { name, webhook_url, avatar_seed } = body
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
if (!botNameSchema.safeParse(name).success) {
return c.json({ error: 'Name must be 2-12 alphanumeric characters, hyphens, or underscores.' }, 400)
}
// Force lowercase for case-insensitive uniqueness
+10 -7
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { getAllChallengeTypes } from '../engine/challenges.js'
import { testWebhookSchema } from '../lib/validators.js'
export const docsRouter = new Hono()
docsRouter.get('/webhook', (c) => {
@@ -166,17 +166,20 @@ docsRouter.get('/webhook', (c) => {
// POST /test — interactive webhook tester (no auth required)
docsRouter.post('/test', async (c) => {
const body = await c.req.json()
const validTypes = getAllChallengeTypes()
const testSchema = z.object({
url: z.string().url(),
type: z.enum(validTypes as [string, ...string[]]).optional(),
})
const parsed = testSchema.safeParse(body)
const parsed = testWebhookSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message || 'Invalid request' }, 400)
}
const { url, type } = parsed.data
// Validate challenge type if provided
if (type) {
const validTypes = getAllChallengeTypes()
if (!validTypes.includes(type)) {
return c.json({ error: 'Invalid challenge type' }, 400)
}
}
// Validate URL protocol and host
let parsedUrl: URL
try {
+1 -11
View File
@@ -1,4 +1,3 @@
import { z } from 'zod'
import { Hono } from 'hono'
import { logger } from '../lib/logger.js'
import { streamSSE } from 'hono/streaming'
@@ -14,16 +13,7 @@ import { getPendingChallenge, getPendingAnswers, submitHumanResponse } from '../
import { getPendingPollChallenge, submitPollResponse, isPollingBot } from '../engine/poll-responses.js'
import { authenticateBot } from '../middleware/bot-auth.js'
import { checkAnswer } from '../engine/answers.js'
// --- Request validation schemas ---
const respondSchema = z.object({
answer: z.string().max(2000),
trashTalk: z.string().max(200).optional(),
})
const reactSchema = z.object({
emoji: z.string().min(1),
})
import { respondSchema, reactSchema } from '../lib/validators.js'
const isValidId = (id: string) => /^[a-zA-Z0-9_-]{1,64}$/.test(id)
+18 -23
View File
@@ -6,20 +6,17 @@ import { eq } from 'drizzle-orm'
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
import { encrypt, decrypt } from '../engine/crypto.js'
import { rateLimit } from '../middleware/rate-limit.js'
import { connectWalletSchema, createInvoiceSchema, submitCashuSchema, disconnectWalletSchema, zapSchema, formatZodError } from '../lib/validators.js'
export const paymentsRouter = new Hono()
// POST /connect-wallet
paymentsRouter.post('/connect-wallet', async (c) => {
const { pubkey, method, connectionData } = await c.req.json<{
pubkey: string
method: 'nwc' | 'lnaddress' | 'cashu_mint'
connectionData: string
}>()
if (!pubkey || !method || !connectionData) {
return c.json({ error: 'Missing pubkey, method, or connectionData' }, 400)
const parsed = connectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey, method, or connectionData') }, 400)
}
const { pubkey, method, connectionData } = parsed.data
// Look up bot by publicKey
const botRows = await db.select({ id: schema.bots.id })
@@ -86,8 +83,9 @@ paymentsRouter.get('/wallet-status', async (c) => {
// POST /create-invoice — rate limited: 10 per minute per IP
paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
const { botId, pubkey } = await c.req.json<{ botId: string; pubkey?: string }>()
if (!botId) return c.json({ error: 'Missing botId' }, 400)
const parsed = createInvoiceSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, { botId: 'Missing botId' }, 'Missing botId') }, 400)
const { botId, pubkey } = parsed.data
// In production, verify bot ownership
if (process.env.NODE_ENV === 'production') {
@@ -185,8 +183,9 @@ paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
// POST /submit-cashu
paymentsRouter.post('/submit-cashu', async (c) => {
const { botId, token } = await c.req.json<{ botId: string; token: string }>()
if (!botId || !token) return c.json({ error: 'Missing botId or token' }, 400)
const parsed = submitCashuSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, {}, 'Missing botId or token') }, 400)
const { botId, token } = parsed.data
try {
const result = await redeemCashuToken(token, botId)
@@ -252,8 +251,9 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
// DELETE /disconnect-wallet
paymentsRouter.delete('/disconnect-wallet', async (c) => {
const { pubkey } = await c.req.json<{ pubkey: string }>()
if (!pubkey) return c.json({ error: 'Missing pubkey' }, 400)
const parsed = disconnectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey') }, 400)
const { pubkey } = parsed.data
const botRows = await db.select({ id: schema.bots.id })
.from(schema.bots)
@@ -275,16 +275,11 @@ paymentsRouter.delete('/disconnect-wallet', async (c) => {
// POST /zap — zap sats to a fight winner
paymentsRouter.post('/zap', rateLimit(60_000, 10), async (c) => {
const { winnerId, fightId, amountSats } = await c.req.json<{
winnerId: string
fightId: string
amountSats: number
}>()
if (!winnerId || !fightId) return c.json({ error: 'Missing winnerId or fightId' }, 400)
if (typeof amountSats !== 'number' || !Number.isInteger(amountSats) || amountSats < 1 || amountSats > 1_000_000) {
return c.json({ error: 'amountSats must be an integer between 1 and 1,000,000' }, 400)
const parsed = zapSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, { amountSats: 'amountSats must be an integer between 1 and 1,000,000' }, 'Missing winnerId or fightId') }, 400)
}
const { winnerId, fightId, amountSats } = parsed.data
const amount = amountSats
// Verify the fight exists and this bot actually won
+5 -4
View File
@@ -4,6 +4,7 @@ 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 } from '../lib/validators.js'
export const queueRouter = new Hono()
@@ -53,11 +54,11 @@ queueRouter.get('/ranked-status', (c) => {
// Join ranked queue — requires confirmed payment + bot ownership
queueRouter.post('/join-ranked/:botId', async (c) => {
const botId = c.req.param('botId')
const { paymentId, pubkey } = await c.req.json<{ paymentId: string; pubkey?: string }>()
if (!paymentId) {
return c.json({ error: 'Missing paymentId' }, 400)
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, pubkey } = parsed.data
// Verify bot ownership in production
if (process.env.NODE_ENV === 'production') {
+15 -18
View File
@@ -10,6 +10,7 @@ import {
getTournamentBracket,
listTournaments,
} from '../engine/tournaments.js'
import { createTournamentSchema, joinTournamentSchema, startTournamentSchema, formatZodError } from '../lib/validators.js'
export const tournamentsRouter = new Hono()
@@ -30,27 +31,21 @@ tournamentsRouter.get('/:id', async (c) => {
// Create a tournament (admin only — creator pubkey required)
tournamentsRouter.post('/', async (c) => {
const body = await c.req.json<{
pubkey: string
name: string
format?: 'single_elim' | 'round_robin'
size?: 8 | 16 | 32
entrySats?: number
}>()
const parsed = createTournamentSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {
name: 'Tournament name required (1-100 chars)',
size: 'Size must be 8, 16, or 32',
}, 'Invalid tournament data') }, 400)
}
const body = parsed.data
if (!isCreatorPubkey(body.pubkey)) {
return c.json({ error: 'Only the creator can create tournaments' }, 403)
}
if (!body.name || body.name.length < 1 || body.name.length > 100) {
return c.json({ error: 'Tournament name required (1-100 chars)' }, 400)
}
const format = body.format ?? 'single_elim'
const size = body.size ?? 8
if (![8, 16, 32].includes(size)) {
return c.json({ error: 'Size must be 8, 16, or 32' }, 400)
}
const id = createTournament(body.name, format, size, body.entrySats ?? 0)
return c.json({ id, name: body.name, format, size }, 201)
@@ -59,9 +54,9 @@ tournamentsRouter.post('/', async (c) => {
// Join a tournament
tournamentsRouter.post('/:id/join', async (c) => {
const tournamentId = c.req.param('id')
const body = await c.req.json<{ pubkey: string; paymentId?: string }>()
if (!body.pubkey) return c.json({ error: 'pubkey required' }, 400)
const parsed = joinTournamentSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, { pubkey: 'pubkey required' }, 'pubkey required') }, 400)
const body = parsed.data
// Look up bot by pubkey
const bot = db.select().from(schema.bots)
@@ -81,7 +76,9 @@ tournamentsRouter.post('/:id/join', async (c) => {
// Start a tournament (admin only)
tournamentsRouter.post('/:id/start', async (c) => {
const tournamentId = c.req.param('id')
const body = await c.req.json<{ pubkey: string }>()
const parsed = startTournamentSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, { pubkey: 'pubkey required' }, 'pubkey required') }, 400)
const body = parsed.data
if (!isCreatorPubkey(body.pubkey)) {
return c.json({ error: 'Only the creator can start tournaments' }, 403)