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:
co-authored by
Claude Opus 4.6
parent
55d0f84251
commit
2051a95e13
@@ -0,0 +1,299 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
pubkeySchema,
|
||||||
|
botNameSchema,
|
||||||
|
satsSchema,
|
||||||
|
idSchema,
|
||||||
|
httpUrlSchema,
|
||||||
|
loginSchema,
|
||||||
|
registerSchema,
|
||||||
|
registerHumanSchema,
|
||||||
|
updateBotSchema,
|
||||||
|
respondSchema,
|
||||||
|
reactSchema,
|
||||||
|
placeBetSchema,
|
||||||
|
depositSchema,
|
||||||
|
withdrawSchema,
|
||||||
|
connectWalletSchema,
|
||||||
|
createInvoiceSchema,
|
||||||
|
submitCashuSchema,
|
||||||
|
zapSchema,
|
||||||
|
disconnectWalletSchema,
|
||||||
|
createTournamentSchema,
|
||||||
|
joinTournamentSchema,
|
||||||
|
startTournamentSchema,
|
||||||
|
joinRankedSchema,
|
||||||
|
testWebhookSchema,
|
||||||
|
} from './validators.js'
|
||||||
|
|
||||||
|
// --- Primitive schemas ---
|
||||||
|
|
||||||
|
describe('pubkeySchema', () => {
|
||||||
|
const valid = 'a'.repeat(64)
|
||||||
|
it('accepts valid 64-char hex', () => { expect(pubkeySchema.safeParse(valid).success).toBe(true) })
|
||||||
|
it('rejects too short', () => { expect(pubkeySchema.safeParse('abc').success).toBe(false) })
|
||||||
|
it('rejects too long', () => { expect(pubkeySchema.safeParse('a'.repeat(65)).success).toBe(false) })
|
||||||
|
it('rejects non-hex', () => { expect(pubkeySchema.safeParse('g'.repeat(64)).success).toBe(false) })
|
||||||
|
it('rejects uppercase', () => { expect(pubkeySchema.safeParse('A'.repeat(64)).success).toBe(false) })
|
||||||
|
it('rejects empty', () => { expect(pubkeySchema.safeParse('').success).toBe(false) })
|
||||||
|
it('rejects number', () => { expect(pubkeySchema.safeParse(12345).success).toBe(false) })
|
||||||
|
it('rejects null', () => { expect(pubkeySchema.safeParse(null).success).toBe(false) })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('botNameSchema', () => {
|
||||||
|
it('accepts valid names', () => {
|
||||||
|
expect(botNameSchema.safeParse('ab').success).toBe(true)
|
||||||
|
expect(botNameSchema.safeParse('test-bot_1').success).toBe(true)
|
||||||
|
expect(botNameSchema.safeParse('ABCDEFGHIJKL').success).toBe(true) // 12 chars
|
||||||
|
})
|
||||||
|
it('rejects too short', () => { expect(botNameSchema.safeParse('a').success).toBe(false) })
|
||||||
|
it('rejects too long', () => { expect(botNameSchema.safeParse('a'.repeat(13)).success).toBe(false) })
|
||||||
|
it('rejects special chars', () => {
|
||||||
|
expect(botNameSchema.safeParse('bot name').success).toBe(false) // spaces
|
||||||
|
expect(botNameSchema.safeParse('bot@name').success).toBe(false)
|
||||||
|
expect(botNameSchema.safeParse('<script>').success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects empty', () => { expect(botNameSchema.safeParse('').success).toBe(false) })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('satsSchema', () => {
|
||||||
|
it('accepts valid amounts', () => {
|
||||||
|
expect(satsSchema.safeParse(1).success).toBe(true)
|
||||||
|
expect(satsSchema.safeParse(1_000_000).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects zero', () => { expect(satsSchema.safeParse(0).success).toBe(false) })
|
||||||
|
it('rejects negative', () => { expect(satsSchema.safeParse(-1).success).toBe(false) })
|
||||||
|
it('rejects over max', () => { expect(satsSchema.safeParse(1_000_001).success).toBe(false) })
|
||||||
|
it('rejects non-integer', () => { expect(satsSchema.safeParse(1.5).success).toBe(false) })
|
||||||
|
it('rejects string', () => { expect(satsSchema.safeParse('100').success).toBe(false) })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('idSchema', () => {
|
||||||
|
it('accepts valid IDs', () => {
|
||||||
|
expect(idSchema.safeParse('abc123').success).toBe(true)
|
||||||
|
expect(idSchema.safeParse('x'.repeat(24)).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects empty', () => { expect(idSchema.safeParse('').success).toBe(false) })
|
||||||
|
it('rejects too long', () => { expect(idSchema.safeParse('x'.repeat(25)).success).toBe(false) })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('httpUrlSchema', () => {
|
||||||
|
it('accepts http/https URLs', () => {
|
||||||
|
expect(httpUrlSchema.safeParse('https://example.com').success).toBe(true)
|
||||||
|
expect(httpUrlSchema.safeParse('http://api.bot.dev/hook').success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects file:// URLs', () => { expect(httpUrlSchema.safeParse('file:///etc/passwd').success).toBe(false) })
|
||||||
|
it('rejects ftp:// URLs', () => { expect(httpUrlSchema.safeParse('ftp://files.example.com').success).toBe(false) })
|
||||||
|
it('rejects non-URLs', () => { expect(httpUrlSchema.safeParse('not a url').success).toBe(false) })
|
||||||
|
it('rejects javascript: URLs', () => { expect(httpUrlSchema.safeParse('javascript:alert(1)').success).toBe(false) })
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Auth schemas ---
|
||||||
|
|
||||||
|
describe('loginSchema', () => {
|
||||||
|
const validPk = 'a'.repeat(64)
|
||||||
|
it('accepts valid login', () => { expect(loginSchema.safeParse({ pubkey: validPk }).success).toBe(true) })
|
||||||
|
it('rejects missing pubkey', () => { expect(loginSchema.safeParse({}).success).toBe(false) })
|
||||||
|
it('rejects bad pubkey', () => { expect(loginSchema.safeParse({ pubkey: 'short' }).success).toBe(false) })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('registerSchema', () => {
|
||||||
|
const base = { pubkey: 'a'.repeat(64), name: 'testbot' }
|
||||||
|
it('accepts minimal registration', () => { expect(registerSchema.safeParse(base).success).toBe(true) })
|
||||||
|
it('accepts full registration', () => {
|
||||||
|
expect(registerSchema.safeParse({
|
||||||
|
...base,
|
||||||
|
webhookUrl: 'https://mybot.com/hook',
|
||||||
|
archetype: 'warrior',
|
||||||
|
profilePicUrl: 'https://img.com/pic.png',
|
||||||
|
customization: { color: 'red' },
|
||||||
|
}).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('accepts empty webhook (poll mode)', () => {
|
||||||
|
expect(registerSchema.safeParse({ ...base, webhookUrl: '' }).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects bad name', () => {
|
||||||
|
expect(registerSchema.safeParse({ ...base, name: 'x' }).success).toBe(false) // too short
|
||||||
|
expect(registerSchema.safeParse({ ...base, name: 'x'.repeat(13) }).success).toBe(false) // too long
|
||||||
|
expect(registerSchema.safeParse({ ...base, name: 'bad name!' }).success).toBe(false) // special chars
|
||||||
|
})
|
||||||
|
it('rejects bad pubkey', () => {
|
||||||
|
expect(registerSchema.safeParse({ ...base, pubkey: 'bad' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('registerHumanSchema', () => {
|
||||||
|
const base = { pubkey: 'a'.repeat(64), name: 'human01' }
|
||||||
|
it('accepts valid human registration', () => { expect(registerHumanSchema.safeParse(base).success).toBe(true) })
|
||||||
|
it('accepts with avatar seed', () => {
|
||||||
|
expect(registerHumanSchema.safeParse({ ...base, avatarSeed: 'seed123' }).success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateBotSchema', () => {
|
||||||
|
const base = { pubkey: 'a'.repeat(64) }
|
||||||
|
it('accepts pubkey only (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
|
||||||
|
it('accepts webhook update', () => {
|
||||||
|
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'https://new.com/hook' }).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects file:// webhook', () => {
|
||||||
|
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'file:///etc/passwd' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Fight schemas ---
|
||||||
|
|
||||||
|
describe('respondSchema', () => {
|
||||||
|
it('accepts valid response', () => {
|
||||||
|
expect(respondSchema.safeParse({ answer: 'hello' }).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('accepts with trash talk', () => {
|
||||||
|
expect(respondSchema.safeParse({ answer: 'hello', trashTalk: 'rekt' }).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects answer > 2000 chars', () => {
|
||||||
|
expect(respondSchema.safeParse({ answer: 'x'.repeat(2001) }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects trash talk > 200 chars', () => {
|
||||||
|
expect(respondSchema.safeParse({ answer: 'ok', trashTalk: 'x'.repeat(201) }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reactSchema', () => {
|
||||||
|
it('accepts valid emoji', () => { expect(reactSchema.safeParse({ emoji: '🔥' }).success).toBe(true) })
|
||||||
|
it('rejects empty emoji', () => { expect(reactSchema.safeParse({ emoji: '' }).success).toBe(false) })
|
||||||
|
it('rejects oversized emoji', () => { expect(reactSchema.safeParse({ emoji: '🔥'.repeat(5) }).success).toBe(false) })
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Bet schemas ---
|
||||||
|
|
||||||
|
describe('placeBetSchema', () => {
|
||||||
|
const valid = {
|
||||||
|
fightId: 'fight123',
|
||||||
|
pubkey: 'a'.repeat(64),
|
||||||
|
botId: 'bot456',
|
||||||
|
amountSats: 1000,
|
||||||
|
cashuToken: 'cashuAey...',
|
||||||
|
}
|
||||||
|
it('accepts valid bet', () => { expect(placeBetSchema.safeParse(valid).success).toBe(true) })
|
||||||
|
it('rejects zero amount', () => {
|
||||||
|
expect(placeBetSchema.safeParse({ ...valid, amountSats: 0 }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects negative amount', () => {
|
||||||
|
expect(placeBetSchema.safeParse({ ...valid, amountSats: -100 }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects over-max amount', () => {
|
||||||
|
expect(placeBetSchema.safeParse({ ...valid, amountSats: 999999999 }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects non-integer amount', () => {
|
||||||
|
expect(placeBetSchema.safeParse({ ...valid, amountSats: 1.5 }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects missing fields', () => {
|
||||||
|
expect(placeBetSchema.safeParse({ fightId: 'x' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects empty cashu token', () => {
|
||||||
|
expect(placeBetSchema.safeParse({ ...valid, cashuToken: '' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('depositSchema', () => {
|
||||||
|
it('accepts valid deposit', () => {
|
||||||
|
expect(depositSchema.safeParse({ amountSats: 500, pubkey: 'a'.repeat(64) }).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects under minimum (100)', () => {
|
||||||
|
expect(depositSchema.safeParse({ amountSats: 50, pubkey: 'a'.repeat(64) }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('withdrawSchema', () => {
|
||||||
|
it('accepts valid withdrawal', () => {
|
||||||
|
expect(withdrawSchema.safeParse({ cashuToken: 'tok', bolt11: 'lnbc...' }).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects missing bolt11', () => {
|
||||||
|
expect(withdrawSchema.safeParse({ cashuToken: 'tok' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Payment schemas ---
|
||||||
|
|
||||||
|
describe('connectWalletSchema', () => {
|
||||||
|
const valid = { pubkey: 'a'.repeat(64), method: 'nwc' as const, connectionData: 'nostr+walletconnect://...' }
|
||||||
|
it('accepts valid connection', () => { expect(connectWalletSchema.safeParse(valid).success).toBe(true) })
|
||||||
|
it('rejects invalid method', () => {
|
||||||
|
expect(connectWalletSchema.safeParse({ ...valid, method: 'paypal' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects empty connectionData', () => {
|
||||||
|
expect(connectWalletSchema.safeParse({ ...valid, connectionData: '' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zapSchema', () => {
|
||||||
|
const valid = { winnerId: 'bot1', fightId: 'fight1', amountSats: 100 }
|
||||||
|
it('accepts valid zap', () => { expect(zapSchema.safeParse(valid).success).toBe(true) })
|
||||||
|
it('rejects zero sats', () => { expect(zapSchema.safeParse({ ...valid, amountSats: 0 }).success).toBe(false) })
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tournament schemas ---
|
||||||
|
|
||||||
|
describe('createTournamentSchema', () => {
|
||||||
|
const valid = { pubkey: 'a'.repeat(64), name: 'Lightning Cup' }
|
||||||
|
it('accepts minimal tournament', () => { expect(createTournamentSchema.safeParse(valid).success).toBe(true) })
|
||||||
|
it('accepts full tournament', () => {
|
||||||
|
expect(createTournamentSchema.safeParse({
|
||||||
|
...valid, format: 'round_robin', size: 16, entrySats: 500,
|
||||||
|
}).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects invalid size', () => {
|
||||||
|
expect(createTournamentSchema.safeParse({ ...valid, size: 64 }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects empty name', () => {
|
||||||
|
expect(createTournamentSchema.safeParse({ ...valid, name: '' }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects name > 100 chars', () => {
|
||||||
|
expect(createTournamentSchema.safeParse({ ...valid, name: 'x'.repeat(101) }).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Queue schemas ---
|
||||||
|
|
||||||
|
describe('joinRankedSchema', () => {
|
||||||
|
it('accepts valid join', () => {
|
||||||
|
expect(joinRankedSchema.safeParse({ paymentId: 'pay123' }).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('accepts with pubkey', () => {
|
||||||
|
expect(joinRankedSchema.safeParse({ paymentId: 'pay123', pubkey: 'a'.repeat(64) }).success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects missing paymentId', () => {
|
||||||
|
expect(joinRankedSchema.safeParse({}).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Attack inputs ---
|
||||||
|
|
||||||
|
describe('attack inputs', () => {
|
||||||
|
it('rejects SQL injection in pubkey', () => {
|
||||||
|
expect(pubkeySchema.safeParse("' OR 1=1 --").success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects XSS in bot name', () => {
|
||||||
|
expect(botNameSchema.safeParse('<img onerror=alert(1)>').success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects prototype pollution in customization keys', () => {
|
||||||
|
// The schema allows unknown keys, but Zod strips __proto__ in strict mode
|
||||||
|
const result = registerSchema.safeParse({
|
||||||
|
pubkey: 'a'.repeat(64),
|
||||||
|
name: 'test01',
|
||||||
|
customization: { __proto__: { admin: true } },
|
||||||
|
})
|
||||||
|
// This should still parse (customization is z.record(z.unknown()))
|
||||||
|
// The actual protection is that we never spread customization into objects unsafely
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
it('rejects absurdly long answer', () => {
|
||||||
|
expect(respondSchema.safeParse({ answer: 'x'.repeat(10000) }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects negative sats in deposit', () => {
|
||||||
|
expect(depositSchema.safeParse({ amountSats: -1, pubkey: 'a'.repeat(64) }).success).toBe(false)
|
||||||
|
})
|
||||||
|
it('rejects data: URL in webhook', () => {
|
||||||
|
expect(httpUrlSchema.safeParse('data:text/html,<h1>hi</h1>').success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// --- Reusable primitives ---
|
||||||
|
|
||||||
|
/** 64-char hex Nostr pubkey */
|
||||||
|
export const pubkeySchema = z.string().length(64).regex(/^[0-9a-f]{64}$/)
|
||||||
|
|
||||||
|
/** Bot/fighter name: 2-12 alphanumeric, hyphens, underscores */
|
||||||
|
export const botNameSchema = z.string().min(2).max(12).regex(/^[a-zA-Z0-9_-]+$/)
|
||||||
|
|
||||||
|
/** Sats amount: positive integer, max 1M */
|
||||||
|
export const satsSchema = z.number().int().min(1).max(1_000_000)
|
||||||
|
|
||||||
|
/** Generic nanoid-style ID (max 24 chars) */
|
||||||
|
export const idSchema = z.string().min(1).max(24)
|
||||||
|
|
||||||
|
/** URL that must be http or https */
|
||||||
|
export const httpUrlSchema = z.string().url().refine(
|
||||||
|
(u) => { try { return ['http:', 'https:'].includes(new URL(u).protocol) } catch { return false } },
|
||||||
|
{ message: 'URL must be http or https' },
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Auth schemas ---
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
name: botNameSchema,
|
||||||
|
webhookUrl: z.string().url().optional().or(z.literal('')),
|
||||||
|
archetype: z.string().min(1).max(50).optional(),
|
||||||
|
profilePicUrl: httpUrlSchema.optional().nullable(),
|
||||||
|
customization: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const registerHumanSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
name: botNameSchema,
|
||||||
|
profilePicUrl: httpUrlSchema.optional().nullable(),
|
||||||
|
avatarSeed: z.string().min(1).max(50).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateBotSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
webhookUrl: httpUrlSchema.max(2048).optional(),
|
||||||
|
profilePicUrl: httpUrlSchema.max(2048).optional(),
|
||||||
|
customization: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Fight schemas ---
|
||||||
|
|
||||||
|
export const respondSchema = z.object({
|
||||||
|
answer: z.string().max(2000),
|
||||||
|
trashTalk: z.string().max(200).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const reactSchema = z.object({
|
||||||
|
emoji: z.string().min(1).max(8),
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Bet schemas ---
|
||||||
|
|
||||||
|
export const placeBetSchema = z.object({
|
||||||
|
fightId: idSchema,
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
botId: idSchema,
|
||||||
|
amountSats: satsSchema,
|
||||||
|
cashuToken: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const depositSchema = z.object({
|
||||||
|
amountSats: z.number().int().min(100).max(1_000_000),
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const withdrawSchema = z.object({
|
||||||
|
cashuToken: z.string().min(1),
|
||||||
|
bolt11: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Payment schemas ---
|
||||||
|
|
||||||
|
export const connectWalletSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
method: z.enum(['nwc', 'lnaddress', 'cashu_mint']),
|
||||||
|
connectionData: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createInvoiceSchema = z.object({
|
||||||
|
botId: idSchema,
|
||||||
|
pubkey: pubkeySchema.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const submitCashuSchema = z.object({
|
||||||
|
botId: idSchema,
|
||||||
|
token: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const zapSchema = z.object({
|
||||||
|
winnerId: idSchema,
|
||||||
|
fightId: idSchema,
|
||||||
|
amountSats: satsSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const disconnectWalletSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Tournament schemas ---
|
||||||
|
|
||||||
|
export const createTournamentSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
format: z.enum(['single_elim', 'round_robin']).optional(),
|
||||||
|
size: z.union([z.literal(8), z.literal(16), z.literal(32)]).optional(),
|
||||||
|
entrySats: z.number().int().min(0).max(1_000_000).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const joinTournamentSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
paymentId: idSchema.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const startTournamentSchema = z.object({
|
||||||
|
pubkey: pubkeySchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Queue schemas ---
|
||||||
|
|
||||||
|
export const joinRankedSchema = z.object({
|
||||||
|
paymentId: idSchema,
|
||||||
|
pubkey: pubkeySchema.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Docs schemas ---
|
||||||
|
|
||||||
|
export const testWebhookSchema = z.object({
|
||||||
|
url: httpUrlSchema,
|
||||||
|
type: z.string().min(1).max(50).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Error formatting helper ---
|
||||||
|
|
||||||
|
/** Map Zod validation errors to user-friendly messages by field name */
|
||||||
|
export function formatZodError(
|
||||||
|
error: z.ZodError,
|
||||||
|
fieldMessages?: Record<string, string>,
|
||||||
|
fallback = 'Invalid input.',
|
||||||
|
): string {
|
||||||
|
const firstIssue = error.issues[0]
|
||||||
|
if (!firstIssue) return fallback
|
||||||
|
const field = String(firstIssue.path[0] ?? '')
|
||||||
|
if (field && fieldMessages?.[field]) return fieldMessages[field]
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
+22
-34
@@ -8,6 +8,7 @@ import { validateCustomization } from '../engine/customization.js'
|
|||||||
import { testWebhook } from '../engine/webhook-test.js'
|
import { testWebhook } from '../engine/webhook-test.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
import { isCreatorPubkey } from '../lib/constants.js'
|
import { isCreatorPubkey } from '../lib/constants.js'
|
||||||
|
import { loginSchema, registerSchema, registerHumanSchema, updateBotSchema, pubkeySchema, formatZodError } from '../lib/validators.js'
|
||||||
|
|
||||||
export const authRouter = new Hono()
|
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)
|
// Login with Nostr pubkey (rate limited: 30 per minute per IP)
|
||||||
authRouter.post('/login', rateLimit(60_000, 30), async (c) => {
|
authRouter.post('/login', rateLimit(60_000, 30), async (c) => {
|
||||||
const body = await c.req.json()
|
const parsed = loginSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
const { pubkey } = body
|
if (!parsed.success) {
|
||||||
|
|
||||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
|
||||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
return c.json({ error: 'Invalid pubkey.' }, 400)
|
||||||
}
|
}
|
||||||
|
const { pubkey } = parsed.data
|
||||||
|
|
||||||
const rows = await db.select({
|
const rows = await db.select({
|
||||||
id: schema.bots.id,
|
id: schema.bots.id,
|
||||||
@@ -130,20 +130,15 @@ authRouter.post('/login', rateLimit(60_000, 30), async (c) => {
|
|||||||
|
|
||||||
// Register a new bot with Nostr pubkey
|
// Register a new bot with Nostr pubkey
|
||||||
authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
|
authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
|
||||||
const body = await c.req.json()
|
const parsed = registerSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = body
|
if (!parsed.success) {
|
||||||
|
return c.json({ error: formatZodError(parsed.error, {
|
||||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
pubkey: 'Invalid pubkey.',
|
||||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
name: 'Name must be 2-12 alphanumeric characters, hyphens, or underscores.',
|
||||||
}
|
webhookUrl: 'webhookUrl must be a valid URL.',
|
||||||
|
}, 'Invalid registration data.') }, 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 { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = parsed.data
|
||||||
|
|
||||||
// Validate customization
|
// Validate customization
|
||||||
const custResult = validateCustomization(rawCustomization)
|
const custResult = validateCustomization(rawCustomization)
|
||||||
@@ -242,20 +237,14 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
|
|||||||
|
|
||||||
// Register a human player (no webhook required)
|
// Register a human player (no webhook required)
|
||||||
authRouter.post('/register-human', rateLimit(600_000, 10), async (c) => {
|
authRouter.post('/register-human', rateLimit(600_000, 10), async (c) => {
|
||||||
const body = await c.req.json()
|
const parsed = registerHumanSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
const { pubkey, name, profilePicUrl, avatarSeed } = body
|
if (!parsed.success) {
|
||||||
|
return c.json({ error: formatZodError(parsed.error, {
|
||||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
pubkey: 'Invalid pubkey.',
|
||||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
name: 'Name must be 2-12 alphanumeric characters, hyphens, or underscores.',
|
||||||
}
|
}, 'Invalid registration data.') }, 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 { pubkey, name, profilePicUrl, avatarSeed } = parsed.data
|
||||||
|
|
||||||
const normalizedName = name.toLowerCase()
|
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)
|
// Update bot webhook and/or customization (requires pubkey match)
|
||||||
authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
|
authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
|
||||||
const body = await c.req.json()
|
const parsed = updateBotSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
|
if (!parsed.success) {
|
||||||
|
|
||||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
|
||||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
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 })
|
const rows = await db.select({ id: schema.bots.id, customization: schema.bots.customization })
|
||||||
.from(schema.bots)
|
.from(schema.bots)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ async function placeBetReq(cashuToken: string, overrides: Record<string, any> =
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fightId: 'test-fight',
|
fightId: 'test-fight',
|
||||||
pubkey: 'deadbeef',
|
pubkey: 'a'.repeat(64),
|
||||||
botId: 'test-bot',
|
botId: 'test-bot',
|
||||||
amountSats: 100,
|
amountSats: 100,
|
||||||
cashuToken,
|
cashuToken,
|
||||||
|
|||||||
+18
-18
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { toError } from '../lib/utils.js'
|
import { toError } from '../lib/utils.js'
|
||||||
|
import { placeBetSchema, depositSchema, withdrawSchema, formatZodError } from '../lib/validators.js'
|
||||||
import { getDecodedToken } from '@cashu/cashu-ts'
|
import { getDecodedToken } from '@cashu/cashu-ts'
|
||||||
import { db, schema } from '../db/index.js'
|
import { db, schema } from '../db/index.js'
|
||||||
import { eq, desc } from 'drizzle-orm'
|
import { eq, desc } from 'drizzle-orm'
|
||||||
@@ -49,16 +50,13 @@ betsRouter.get('/odds/:fightId', async (c) => {
|
|||||||
|
|
||||||
// Place a bet
|
// Place a bet
|
||||||
betsRouter.post('/place', rateLimit(60_000, 10), async (c) => {
|
betsRouter.post('/place', rateLimit(60_000, 10), async (c) => {
|
||||||
const body = await c.req.json()
|
const parsed = placeBetSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
const { fightId, pubkey, botId, amountSats, cashuToken } = body
|
if (!parsed.success) {
|
||||||
|
return c.json({ error: formatZodError(parsed.error, {
|
||||||
if (!fightId || !pubkey || !botId || !amountSats || !cashuToken) {
|
amountSats: 'amountSats must be an integer between 1 and 1,000,000',
|
||||||
return c.json({ error: 'Missing required fields.' }, 400)
|
}, '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 { fightId, pubkey, botId, amountSats, cashuToken } = parsed.data
|
||||||
|
|
||||||
// Validate Cashu token format before any DB lookups
|
// Validate Cashu token format before any DB lookups
|
||||||
try {
|
try {
|
||||||
@@ -161,13 +159,14 @@ betsRouter.get('/history/:pubkey', async (c) => {
|
|||||||
|
|
||||||
// Lightning deposit — get invoice
|
// Lightning deposit — get invoice
|
||||||
betsRouter.post('/deposit', rateLimit(60_000, 5), async (c) => {
|
betsRouter.post('/deposit', rateLimit(60_000, 5), async (c) => {
|
||||||
const { amountSats, pubkey } = await c.req.json()
|
const parsed = depositSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!amountSats || !pubkey) {
|
if (!parsed.success) {
|
||||||
return c.json({ error: 'Missing amountSats or pubkey.' }, 400)
|
return c.json({ error: formatZodError(parsed.error, {
|
||||||
}
|
amountSats: 'Amount must be 100-1,000,000 sats.',
|
||||||
if (amountSats < 100 || amountSats > 1_000_000) {
|
pubkey: 'Missing amountSats or pubkey.',
|
||||||
return c.json({ error: 'Amount must be 100-1,000,000 sats.' }, 400)
|
}, 'Missing amountSats or pubkey.') }, 400)
|
||||||
}
|
}
|
||||||
|
const { amountSats, pubkey } = parsed.data
|
||||||
|
|
||||||
const invoice = await createDepositInvoice(amountSats, pubkey)
|
const invoice = await createDepositInvoice(amountSats, pubkey)
|
||||||
return c.json(invoice)
|
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
|
// Lightning withdraw — burn Cashu tokens, pay LN invoice
|
||||||
betsRouter.post('/withdraw', rateLimit(60_000, 3), async (c) => {
|
betsRouter.post('/withdraw', rateLimit(60_000, 3), async (c) => {
|
||||||
const { cashuToken, bolt11 } = await c.req.json()
|
const parsed = withdrawSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!cashuToken || !bolt11) {
|
if (!parsed.success) {
|
||||||
return c.json({ error: 'Missing cashuToken or bolt11 invoice.' }, 400)
|
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)
|
const result = await withdrawToLightning(cashuToken, bolt11)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { computeAchievements } from '../engine/achievements.js'
|
|||||||
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
||||||
import { testWebhook } from '../engine/webhook-test.js'
|
import { testWebhook } from '../engine/webhook-test.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
|
import { botNameSchema, httpUrlSchema } from '../lib/validators.js'
|
||||||
|
|
||||||
export const botsRouter = new Hono()
|
export const botsRouter = new Hono()
|
||||||
|
|
||||||
@@ -34,12 +35,8 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
|
|||||||
const body = await c.req.json()
|
const body = await c.req.json()
|
||||||
const { name, webhook_url, avatar_seed } = body
|
const { name, webhook_url, avatar_seed } = body
|
||||||
|
|
||||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
|
if (!botNameSchema.safeParse(name).success) {
|
||||||
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
|
return c.json({ error: 'Name must be 2-12 alphanumeric characters, hyphens, or underscores.' }, 400)
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
||||||
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force lowercase for case-insensitive uniqueness
|
// Force lowercase for case-insensitive uniqueness
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { z } from 'zod'
|
|
||||||
import { getAllChallengeTypes } from '../engine/challenges.js'
|
import { getAllChallengeTypes } from '../engine/challenges.js'
|
||||||
|
import { testWebhookSchema } from '../lib/validators.js'
|
||||||
export const docsRouter = new Hono()
|
export const docsRouter = new Hono()
|
||||||
|
|
||||||
docsRouter.get('/webhook', (c) => {
|
docsRouter.get('/webhook', (c) => {
|
||||||
@@ -166,17 +166,20 @@ docsRouter.get('/webhook', (c) => {
|
|||||||
// POST /test — interactive webhook tester (no auth required)
|
// POST /test — interactive webhook tester (no auth required)
|
||||||
docsRouter.post('/test', async (c) => {
|
docsRouter.post('/test', async (c) => {
|
||||||
const body = await c.req.json()
|
const body = await c.req.json()
|
||||||
const validTypes = getAllChallengeTypes()
|
const parsed = testWebhookSchema.safeParse(body)
|
||||||
const testSchema = z.object({
|
|
||||||
url: z.string().url(),
|
|
||||||
type: z.enum(validTypes as [string, ...string[]]).optional(),
|
|
||||||
})
|
|
||||||
const parsed = testSchema.safeParse(body)
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return c.json({ error: parsed.error.issues[0]?.message || 'Invalid request' }, 400)
|
return c.json({ error: parsed.error.issues[0]?.message || 'Invalid request' }, 400)
|
||||||
}
|
}
|
||||||
const { url, type } = parsed.data
|
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
|
// Validate URL protocol and host
|
||||||
let parsedUrl: URL
|
let parsedUrl: URL
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { logger } from '../lib/logger.js'
|
import { logger } from '../lib/logger.js'
|
||||||
import { streamSSE } from 'hono/streaming'
|
import { streamSSE } from 'hono/streaming'
|
||||||
@@ -14,16 +13,7 @@ import { getPendingChallenge, getPendingAnswers, submitHumanResponse } from '../
|
|||||||
import { getPendingPollChallenge, submitPollResponse, isPollingBot } from '../engine/poll-responses.js'
|
import { getPendingPollChallenge, submitPollResponse, isPollingBot } from '../engine/poll-responses.js'
|
||||||
import { authenticateBot } from '../middleware/bot-auth.js'
|
import { authenticateBot } from '../middleware/bot-auth.js'
|
||||||
import { checkAnswer } from '../engine/answers.js'
|
import { checkAnswer } from '../engine/answers.js'
|
||||||
|
import { respondSchema, reactSchema } from '../lib/validators.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),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isValidId = (id: string) => /^[a-zA-Z0-9_-]{1,64}$/.test(id)
|
const isValidId = (id: string) => /^[a-zA-Z0-9_-]{1,64}$/.test(id)
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,17 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
|
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
|
||||||
import { encrypt, decrypt } from '../engine/crypto.js'
|
import { encrypt, decrypt } from '../engine/crypto.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
|
import { connectWalletSchema, createInvoiceSchema, submitCashuSchema, disconnectWalletSchema, zapSchema, formatZodError } from '../lib/validators.js'
|
||||||
|
|
||||||
export const paymentsRouter = new Hono()
|
export const paymentsRouter = new Hono()
|
||||||
|
|
||||||
// POST /connect-wallet
|
// POST /connect-wallet
|
||||||
paymentsRouter.post('/connect-wallet', async (c) => {
|
paymentsRouter.post('/connect-wallet', async (c) => {
|
||||||
const { pubkey, method, connectionData } = await c.req.json<{
|
const parsed = connectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
pubkey: string
|
if (!parsed.success) {
|
||||||
method: 'nwc' | 'lnaddress' | 'cashu_mint'
|
return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey, method, or connectionData') }, 400)
|
||||||
connectionData: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
if (!pubkey || !method || !connectionData) {
|
|
||||||
return c.json({ error: 'Missing pubkey, method, or connectionData' }, 400)
|
|
||||||
}
|
}
|
||||||
|
const { pubkey, method, connectionData } = parsed.data
|
||||||
|
|
||||||
// Look up bot by publicKey
|
// Look up bot by publicKey
|
||||||
const botRows = await db.select({ id: schema.bots.id })
|
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
|
// POST /create-invoice — rate limited: 10 per minute per IP
|
||||||
paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
|
paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
|
||||||
const { botId, pubkey } = await c.req.json<{ botId: string; pubkey?: string }>()
|
const parsed = createInvoiceSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!botId) return c.json({ error: 'Missing botId' }, 400)
|
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
|
// In production, verify bot ownership
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@@ -185,8 +183,9 @@ paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
|
|||||||
|
|
||||||
// POST /submit-cashu
|
// POST /submit-cashu
|
||||||
paymentsRouter.post('/submit-cashu', async (c) => {
|
paymentsRouter.post('/submit-cashu', async (c) => {
|
||||||
const { botId, token } = await c.req.json<{ botId: string; token: string }>()
|
const parsed = submitCashuSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!botId || !token) return c.json({ error: 'Missing botId or token' }, 400)
|
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, {}, 'Missing botId or token') }, 400)
|
||||||
|
const { botId, token } = parsed.data
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await redeemCashuToken(token, botId)
|
const result = await redeemCashuToken(token, botId)
|
||||||
@@ -252,8 +251,9 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
|||||||
|
|
||||||
// DELETE /disconnect-wallet
|
// DELETE /disconnect-wallet
|
||||||
paymentsRouter.delete('/disconnect-wallet', async (c) => {
|
paymentsRouter.delete('/disconnect-wallet', async (c) => {
|
||||||
const { pubkey } = await c.req.json<{ pubkey: string }>()
|
const parsed = disconnectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!pubkey) return c.json({ error: 'Missing pubkey' }, 400)
|
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 })
|
const botRows = await db.select({ id: schema.bots.id })
|
||||||
.from(schema.bots)
|
.from(schema.bots)
|
||||||
@@ -275,16 +275,11 @@ paymentsRouter.delete('/disconnect-wallet', async (c) => {
|
|||||||
|
|
||||||
// POST /zap — zap sats to a fight winner
|
// POST /zap — zap sats to a fight winner
|
||||||
paymentsRouter.post('/zap', rateLimit(60_000, 10), async (c) => {
|
paymentsRouter.post('/zap', rateLimit(60_000, 10), async (c) => {
|
||||||
const { winnerId, fightId, amountSats } = await c.req.json<{
|
const parsed = zapSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
winnerId: string
|
if (!parsed.success) {
|
||||||
fightId: string
|
return c.json({ error: formatZodError(parsed.error, { amountSats: 'amountSats must be an integer between 1 and 1,000,000' }, 'Missing winnerId or fightId') }, 400)
|
||||||
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 { winnerId, fightId, amountSats } = parsed.data
|
||||||
const amount = amountSats
|
const amount = amountSats
|
||||||
|
|
||||||
// Verify the fight exists and this bot actually won
|
// Verify the fight exists and this bot actually won
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js'
|
import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js'
|
||||||
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
|
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
|
import { joinRankedSchema } from '../lib/validators.js'
|
||||||
|
|
||||||
export const queueRouter = new Hono()
|
export const queueRouter = new Hono()
|
||||||
|
|
||||||
@@ -53,11 +54,11 @@ queueRouter.get('/ranked-status', (c) => {
|
|||||||
// Join ranked queue — requires confirmed payment + bot ownership
|
// Join ranked queue — requires confirmed payment + bot ownership
|
||||||
queueRouter.post('/join-ranked/:botId', async (c) => {
|
queueRouter.post('/join-ranked/:botId', async (c) => {
|
||||||
const botId = c.req.param('botId')
|
const botId = c.req.param('botId')
|
||||||
const { paymentId, pubkey } = await c.req.json<{ paymentId: string; pubkey?: string }>()
|
const parsed = joinRankedSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
|
if (!parsed.success) {
|
||||||
if (!paymentId) {
|
return c.json({ error: parsed.error.issues[0]?.message || 'Missing paymentId' }, 400)
|
||||||
return c.json({ error: 'Missing paymentId' }, 400)
|
|
||||||
}
|
}
|
||||||
|
const { paymentId, pubkey } = parsed.data
|
||||||
|
|
||||||
// Verify bot ownership in production
|
// Verify bot ownership in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getTournamentBracket,
|
getTournamentBracket,
|
||||||
listTournaments,
|
listTournaments,
|
||||||
} from '../engine/tournaments.js'
|
} from '../engine/tournaments.js'
|
||||||
|
import { createTournamentSchema, joinTournamentSchema, startTournamentSchema, formatZodError } from '../lib/validators.js'
|
||||||
|
|
||||||
export const tournamentsRouter = new Hono()
|
export const tournamentsRouter = new Hono()
|
||||||
|
|
||||||
@@ -30,27 +31,21 @@ tournamentsRouter.get('/:id', async (c) => {
|
|||||||
|
|
||||||
// Create a tournament (admin only — creator pubkey required)
|
// Create a tournament (admin only — creator pubkey required)
|
||||||
tournamentsRouter.post('/', async (c) => {
|
tournamentsRouter.post('/', async (c) => {
|
||||||
const body = await c.req.json<{
|
const parsed = createTournamentSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
pubkey: string
|
if (!parsed.success) {
|
||||||
name: string
|
return c.json({ error: formatZodError(parsed.error, {
|
||||||
format?: 'single_elim' | 'round_robin'
|
name: 'Tournament name required (1-100 chars)',
|
||||||
size?: 8 | 16 | 32
|
size: 'Size must be 8, 16, or 32',
|
||||||
entrySats?: number
|
}, 'Invalid tournament data') }, 400)
|
||||||
}>()
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
|
||||||
if (!isCreatorPubkey(body.pubkey)) {
|
if (!isCreatorPubkey(body.pubkey)) {
|
||||||
return c.json({ error: 'Only the creator can create tournaments' }, 403)
|
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 format = body.format ?? 'single_elim'
|
||||||
const size = body.size ?? 8
|
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)
|
const id = createTournament(body.name, format, size, body.entrySats ?? 0)
|
||||||
return c.json({ id, name: body.name, format, size }, 201)
|
return c.json({ id, name: body.name, format, size }, 201)
|
||||||
@@ -59,9 +54,9 @@ tournamentsRouter.post('/', async (c) => {
|
|||||||
// Join a tournament
|
// Join a tournament
|
||||||
tournamentsRouter.post('/:id/join', async (c) => {
|
tournamentsRouter.post('/:id/join', async (c) => {
|
||||||
const tournamentId = c.req.param('id')
|
const tournamentId = c.req.param('id')
|
||||||
const body = await c.req.json<{ pubkey: string; paymentId?: string }>()
|
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)
|
||||||
if (!body.pubkey) return c.json({ error: 'pubkey required' }, 400)
|
const body = parsed.data
|
||||||
|
|
||||||
// Look up bot by pubkey
|
// Look up bot by pubkey
|
||||||
const bot = db.select().from(schema.bots)
|
const bot = db.select().from(schema.bots)
|
||||||
@@ -81,7 +76,9 @@ tournamentsRouter.post('/:id/join', async (c) => {
|
|||||||
// Start a tournament (admin only)
|
// Start a tournament (admin only)
|
||||||
tournamentsRouter.post('/:id/start', async (c) => {
|
tournamentsRouter.post('/:id/start', async (c) => {
|
||||||
const tournamentId = c.req.param('id')
|
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)) {
|
if (!isCreatorPubkey(body.pubkey)) {
|
||||||
return c.json({ error: 'Only the creator can start tournaments' }, 403)
|
return c.json({ error: 'Only the creator can start tournaments' }, 403)
|
||||||
|
|||||||
Reference in New Issue
Block a user