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
+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) {