From 575b3618b918fffe6677107c4cf4adb4da7d522e Mon Sep 17 00:00:00 2001 From: xxvii-xiaxia Date: Sat, 30 May 2026 12:07:53 +0100 Subject: [PATCH] feat: implement middleware, oracle controller, and validation schemas (#748 #749 #750 #755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #749: requireAdminJwt — verifies Bearer JWT, attaches decoded payload to req.admin, 401/403 on failure - #750: validate() factory — unified Zod middleware with target (body/params/query), 422 field-level errors - #748: OracleController::submitOracleResult — HMAC-SHA256 sig verification, Redis rate-limit (1/match_id/60s), 202 async response - #755: validation.schemas.ts — rewrote with correct imports, added all schemas (market filters, resolve, dispute, oracle submission) --- .../src/api/controllers/OracleController.ts | 120 +++---- backend/src/api/middleware/validate.ts | 27 +- .../middleware/requireAdminJwt.middleware.ts | 25 +- backend/src/schemas/validation.schemas.ts | 337 ++++-------------- 4 files changed, 146 insertions(+), 363 deletions(-) diff --git a/backend/src/api/controllers/OracleController.ts b/backend/src/api/controllers/OracleController.ts index a56589f5..c8155b0c 100644 --- a/backend/src/api/controllers/OracleController.ts +++ b/backend/src/api/controllers/OracleController.ts @@ -1,13 +1,10 @@ -// ============================================================ -// BOXMEOUT — Oracle Controller -// Protected by oracle API key middleware. -// ============================================================ - import type { Request, Response, NextFunction } from 'express'; -import { z } from 'zod'; +import { createHmac, timingSafeEqual } from 'crypto'; import { AppError } from '../../utils/AppError'; -import { validateBody } from '../middleware/validate'; +import { validate } from '../middleware/validate'; import * as OracleService from '../../oracle/OracleService'; +import { redis } from '../../config/redis'; +import { z } from 'zod'; // --------------------------------------------------------------------------- // Zod schema for POST /api/oracle/submit body @@ -15,38 +12,22 @@ import * as OracleService from '../../oracle/OracleService'; const submitOracleResultSchema = z.object({ match_id: z.string().min(1, 'match_id is required'), outcome: z.enum(['fighter_a', 'fighter_b', 'draw', 'no_contest'], { - errorMap: () => ({ - message: "outcome must be one of: fighter_a, fighter_b, draw, no_contest", - }), + errorMap: () => ({ message: 'outcome must be one of: fighter_a, fighter_b, draw, no_contest' }), }), - reported_at: z - .string() - .datetime({ message: 'reported_at must be a valid ISO 8601 datetime string' }), - signature: z - .string() - .regex(/^[0-9a-fA-F]+$/, 'signature must be a hex-encoded string') - .min(1, 'signature is required'), - oracle_address: z - .string() - .min(1, 'oracle_address is required'), + reported_at: z.string().datetime({ message: 'reported_at must be a valid ISO 8601 datetime string' }), + signature: z.string().regex(/^[0-9a-fA-F]+$/, 'signature must be a hex-encoded string').min(1), + oracle_address: z.string().min(1, 'oracle_address is required'), }); -// Export the validation middleware so the route can apply it before the handler -export const validateSubmitOracleResult = validateBody(submitOracleResultSchema); +export const validateSubmitOracleResult = validate(submitOracleResultSchema, 'body'); + +const RATE_LIMIT_TTL = 60; // seconds /** * POST /api/oracle/submit - * Body: { match_id, outcome, reported_at, signature, oracle_address } - * - * Receives a signed OracleReport from an authorized oracle. - * Steps: - * 1. Validate X-Oracle-Key header against ORACLE_API_KEY env var - * 2. Validate request body with Zod schema (applied as middleware before this handler) - * 3. Call OracleService.verifyOracleReport() — respond 401 if invalid - * 4. Call OracleService.submitFightResult() - * 5. Respond 200 with { tx_hash, report_id } - * - * Protected by oracle API key header: X-Oracle-Key + * 1. Verify HMAC-SHA256 signature using ORACLE_HMAC_SECRET + * 2. Rate-limit: 1 submission per match_id per 60 seconds (Redis) + * 3. Respond 202 immediately; call OracleService.submitFightResult() async */ export async function submitOracleResult( req: Request, @@ -54,53 +35,47 @@ export async function submitOracleResult( next: NextFunction, ): Promise { try { - // Step 1 — Validate X-Oracle-Key header - const apiKey = req.headers['x-oracle-key']; - const expectedKey = process.env.ORACLE_API_KEY; - - if (!expectedKey) { - // Misconfigured server — fail closed - return next(new AppError(500, 'Oracle API key is not configured')); - } - - if (!apiKey || apiKey !== expectedKey) { - return next(new AppError(401, 'Invalid or missing X-Oracle-Key header')); + const hmacSecret = process.env.ORACLE_HMAC_SECRET; + if (!hmacSecret) { + return next(new AppError(500, 'ORACLE_HMAC_SECRET is not configured')); } - // Step 2 — Body already validated and typed by validateSubmitOracleResult middleware const { match_id, outcome, reported_at, signature, oracle_address } = req.body as z.infer; - // Build a partial OracleReport for verification (id/accepted/tx_hash/created_at - // are not known yet — verifyOracleReport only needs the crypto fields) - const reportToVerify = { - match_id, - outcome, - reported_at: new Date(reported_at), - signature, - oracle_address, - }; + // Step 1 — Verify HMAC-SHA256 signature + // Canonical message: match_id|outcome|reported_at|oracle_address + const message = `${match_id}|${outcome}|${reported_at}|${oracle_address}`; + const expected = createHmac('sha256', hmacSecret).update(message).digest('hex'); - // Step 3 — Verify signature + whitelist - const isValid = await OracleService.verifyOracleReport( - reportToVerify as Parameters[0], - ); + let sigValid = false; + try { + sigValid = timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex')); + } catch { + sigValid = false; + } - if (!isValid) { - return next(new AppError(401, 'Oracle report signature is invalid or oracle is not whitelisted')); + if (!sigValid) { + return next(new AppError(401, 'Invalid HMAC signature')); } - // Step 4 — Submit the fight result on-chain and persist to DB - const savedReport = await OracleService.submitFightResult( - match_id, - outcome as OracleService.FightOutcome, - ); + // Step 2 — Rate-limit: 1 submission per match_id per 60 seconds + const rateLimitKey = `oracle:ratelimit:${match_id}`; + const existing = await redis.set(rateLimitKey, '1', 'EX', RATE_LIMIT_TTL, 'NX'); + if (existing === null) { + return next(new AppError(429, `Rate limit exceeded: match_id ${match_id} already submitted within 60 seconds`)); + } + + // Step 3 — Respond 202 immediately + res.status(202).json({ message: 'Accepted' }); - // Step 5 — Respond with tx_hash and report_id - res.status(200).json({ - tx_hash: savedReport.tx_hash, - report_id: savedReport.id, - }); + // Step 4 — Async resolution (fire-and-forget) + OracleService.submitFightResult(match_id, outcome as OracleService.FightOutcome).catch( + (err) => { + // Log but don't crash — response already sent + console.error({ err, match_id, outcome }, 'submitOracleResult: async submitFightResult failed'); + }, + ); } catch (err) { next(err); } @@ -108,10 +83,7 @@ export async function submitOracleResult( /** * GET /api/oracle/reports/:match_id - * - * Returns all oracle reports (accepted and rejected) for a fight. - * Public endpoint — used for transparency and dispute investigation. - * Responds 200 with OracleReport[]. + * Returns all oracle reports for a fight. */ export async function getOracleReports( _req: Request, diff --git a/backend/src/api/middleware/validate.ts b/backend/src/api/middleware/validate.ts index 16c94c4e..216d37b4 100644 --- a/backend/src/api/middleware/validate.ts +++ b/backend/src/api/middleware/validate.ts @@ -1,6 +1,8 @@ import type { Request, Response, NextFunction } from 'express'; import { ZodSchema, ZodError, ZodIssue } from 'zod'; +type Target = 'body' | 'params' | 'query'; + function formatErrors(err: ZodError) { return err.issues.map((e: ZodIssue) => ({ field: e.path.join('.'), @@ -8,27 +10,20 @@ function formatErrors(err: ZodError) { })); } -export function validateBody(schema: ZodSchema) { - return (req: Request, res: Response, next: NextFunction): void => { - const result = schema.safeParse(req.body); - if (!result.success) { - res.status(400).json({ errors: formatErrors(result.error) }); - return; - } - req.body = result.data; - next(); - }; -} - -export function validateQuery(schema: ZodSchema) { +export function validate(schema: ZodSchema, target: Target = 'body') { return (req: Request, res: Response, next: NextFunction): void => { - const result = schema.safeParse(req.query); + const result = schema.safeParse(req[target]); if (!result.success) { - res.status(400).json({ errors: formatErrors(result.error) }); + res.status(422).json({ errors: formatErrors(result.error) }); return; } // eslint-disable-next-line @typescript-eslint/no-explicit-any - req.query = result.data as any; + (req as any)[target] = result.data; next(); }; } + +// Convenience aliases kept for backward compatibility +export const validateBody = (schema: ZodSchema) => validate(schema, 'body'); +export const validateQuery = (schema: ZodSchema) => validate(schema, 'query'); +export const validateParams = (schema: ZodSchema) => validate(schema, 'params'); diff --git a/backend/src/middleware/requireAdminJwt.middleware.ts b/backend/src/middleware/requireAdminJwt.middleware.ts index 34c06e73..39a6efd5 100644 --- a/backend/src/middleware/requireAdminJwt.middleware.ts +++ b/backend/src/middleware/requireAdminJwt.middleware.ts @@ -2,6 +2,18 @@ import type { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import { AppError } from '../utils/AppError'; +export interface AdminJwtPayload extends jwt.JwtPayload { + role: 'admin'; +} + +declare global { + namespace Express { + interface Request { + admin?: AdminJwtPayload; + } + } +} + export function requireAdminJwt(req: Request, _res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { @@ -9,15 +21,20 @@ export function requireAdminJwt(req: Request, _res: Response, next: NextFunction } const token = authHeader.slice(7); - const secret = process.env.JWT_SECRET; + const secret = process.env.ADMIN_JWT_SECRET ?? process.env.JWT_SECRET; + + if (!secret) { + return next(new AppError(500, 'JWT secret is not configured')); + } try { - const payload = jwt.verify(token, secret!) as jwt.JwtPayload; + const payload = jwt.verify(token, secret) as AdminJwtPayload; if (payload.role !== 'admin') { return next(new AppError(403, 'Forbidden: admin role required')); } + req.admin = payload; next(); - } catch (err) { - next(new AppError(401, 'Invalid or expired token')); + } catch { + next(new AppError(403, 'Invalid or expired token')); } } diff --git a/backend/src/schemas/validation.schemas.ts b/backend/src/schemas/validation.schemas.ts index 2e3786e8..0f4c1f69 100644 --- a/backend/src/schemas/validation.schemas.ts +++ b/backend/src/schemas/validation.schemas.ts @@ -1,70 +1,46 @@ import { z } from 'zod'; -import { MarketCategory } from '@prisma/client'; -import { stellarService } from '../services/stellar.service.js'; +import { StrKey } from '@stellar/stellar-sdk'; // --- Sanitization helper --- -/** - * Strips HTML tags (including script tags with content) from a string. - * Used by sanitizedString() to clean user-provided text inputs. - */ export function stripHtml(val: string): string { - // Strip script tags and their content val = val.replace(/)<[^<]*)*<\/script>/gi, ''); - // Strip style tags and their content val = val.replace(/)<[^<]*)*<\/style>/gi, ''); - // Strip event handlers (e.g., onclick, onerror) val = val.replace(/\s+on\w+="[^"]*"/gi, ''); val = val.replace(/\s+on\w+='[^']*'/gi, ''); - // Strip javascript: pseudo-protocol val = val.replace(/javascript:[^"']*/gi, ''); - // Strip remaining HTML tags val = val.replace(/<[^>]*>/g, ''); - // Strip common HTML entities (e.g. & < ' ') val = val.replace(/&(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z]+);/g, ''); return val; } -/** - * Creates a Zod string schema that trims whitespace, strips HTML/script tags, - * then validates min/max length on the cleaned result. - */ export function sanitizedString(min: number, max: number) { - return z - .string() - .trim() - .transform(stripHtml) - .pipe(z.string().min(min).max(max)); + return z.string().trim().transform(stripHtml).pipe(z.string().min(min).max(max)); } // --- Shared primitives --- export const stellarAddress = z .string() - .refine((val) => stellarService.isValidPublicKey(val), { + .refine((val) => StrKey.isValidEd25519PublicKey(val), { message: 'Invalid Stellar public key format or checksum', }); -export const uuidParam = z.object({ - id: z.string().uuid(), -}); - -export const marketIdParam = z.object({ - marketId: z.string().uuid(), -}); +export const uuidParam = z.object({ id: z.string().uuid() }); +export const marketIdParam = z.object({ marketId: z.string().uuid() }); // --- Auth schemas --- export const emailSchema = z .string() .email('Invalid email format') - .min(5, 'Email must be at least 5 characters') - .max(254, 'Email must be less than 254 characters'); + .min(5) + .max(254); export const passwordSchema = z .string() - .min(8, 'Password must be at least 8 characters') - .max(128, 'Password must be less than 128 characters') + .min(8) + .max(128) .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') .regex(/[a-z]/, 'Password must contain at least one lowercase letter') .regex(/[0-9]/, 'Password must contain at least one number') @@ -81,9 +57,7 @@ export const emailLoginBody = z.object({ password: z.string().min(1, 'Password is required'), }); -export const challengeBody = z.object({ - publicKey: stellarAddress, -}); +export const challengeBody = z.object({ publicKey: stellarAddress }); export const loginBody = z.object({ publicKey: stellarAddress, @@ -101,11 +75,24 @@ export const logoutBody = z.object({ // --- Market schemas --- +const MARKET_STATUSES = ['open', 'locked', 'resolved', 'cancelled', 'disputed'] as const; +const MARKET_CATEGORIES = ['BOXING', 'MMA', 'KICKBOXING', 'OTHER'] as const; + +export const listMarketsQuery = z.object({ + status: z.enum(MARKET_STATUSES).optional(), + weight_class: z.string().min(1).optional(), + fighter: z.string().min(1).optional(), + dateFrom: z.string().datetime().optional(), + dateTo: z.string().datetime().optional(), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + export const createMarketBody = z .object({ title: sanitizedString(5, 200), description: sanitizedString(10, 5000), - category: z.nativeEnum(MarketCategory), + category: z.enum(MARKET_CATEGORIES), outcomeA: sanitizedString(1, 100), outcomeB: sanitizedString(1, 100), closingAt: z @@ -117,194 +104,26 @@ export const createMarketBody = z resolutionTime: z.string().datetime().optional(), }) .refine( - (data) => - !data.resolutionTime || - new Date(data.resolutionTime) > new Date(data.closingAt), - { - message: 'Resolution time must be after closing time', - path: ['resolutionTime'], - } + (data) => !data.resolutionTime || new Date(data.resolutionTime) > new Date(data.closingAt), + { message: 'Resolution time must be after closing time', path: ['resolutionTime'] }, ); -export const createPoolBody = z.object({ - initialLiquidity: z - .string() - .regex(/^\d+$/, 'Must be a numeric string') - .refine((val) => BigInt(val) > 0n, { - message: 'Initial liquidity must be greater than 0', - }), +export const resolveMarketBody = z.object({ + winning_outcome: z.enum(['fighter_a', 'fighter_b', 'draw', 'no_contest'], { + errorMap: () => ({ message: 'winning_outcome must be one of: fighter_a, fighter_b, draw, no_contest' }), + }), }); -// --- Prediction schemas --- +// --- Oracle submission schema --- -export const commitPredictionBody = z.object({ - predictedOutcome: z.number().int().min(0).max(1), - amountUsdc: z - .string() - .regex(/^\d+(\.\d{1,6})?$/, 'Invalid amount format') - .refine( - (val) => { - const num = parseFloat(val); - return num >= 1 && num <= 1_000_000; - }, - { message: 'Amount must be between 1 and 1,000,000' } - ), -}); - -export const buySharesBody = z.object({ - outcome: z.number().int().min(0).max(1), - amount: z - .string() - .regex(/^\d+$/, 'Amount must be a numeric string (USDC base units)') - .refine( - (val) => { - try { - return BigInt(val) > 0n; - } catch { - return false; - } - }, - { message: 'Amount must be greater than 0' } - ) - .refine( - (val) => { - try { - return BigInt(val) <= 1_000_000_000_000n; - } catch { - return false; - } - }, - { message: 'Amount exceeds maximum limit' } - ), - minShares: z - .string() - .regex(/^\d+$/, 'minShares must be a numeric string') - .optional(), -}); - -export const sellSharesBody = z.object({ - outcome: z.number().int().min(0).max(1), - shares: z - .string() - .regex(/^\d+$/, 'Shares must be a numeric string (base units)') - .refine( - (val) => { - try { - return BigInt(val) > 0n; - } catch { - return false; - } - }, - { message: 'Shares must be greater than 0' } - ), - minPayout: z - .string() - .regex(/^\d+$/, 'minPayout must be a numeric string') - .optional(), -}); - -export const addLiquidityBody = z.object({ - usdcAmount: z - .string() - .regex(/^\d+$/, 'usdcAmount must be a numeric string') - .refine( - (val) => { - try { - return BigInt(val) > 0n; - } catch { - return false; - } - }, - { message: 'usdcAmount must be greater than 0' } - ), -}); - -export const removeLiquidityBody = z.object({ - lpTokens: z - .string() - .regex(/^\d+$/, 'lpTokens must be a numeric string') - .refine( - (val) => { - try { - return BigInt(val) > 0n; - } catch { - return false; - } - }, - { message: 'lpTokens must be greater than 0' } - ), -}); - -export const revealPredictionBody = z.object({ - predictionId: z.string().uuid(), -}); - -// --- Oracle schemas --- - -export const attestBody = z.object({ - outcome: z.number().int().min(0).max(1), -}); - -// --- Treasury schemas --- - -export const distributeLeaderboardBody = z.object({ - recipients: z - .array( - z.object({ - address: stellarAddress, - amount: z - .string() - .regex(/^\d+$/, 'Must be a numeric string') - .refine( - (val) => { - try { - return BigInt(val) > 0n; - } catch { - return false; - } - }, - { - message: 'Amount must be greater than 0', - } - ), - }) - ) - .min(1) - .max(100), -}); - -export const distributeCreatorBody = z.object({ - marketId: z.string().uuid(), - creatorAddress: stellarAddress, - amount: z - .string() - .regex(/^\d+$/, 'Must be a numeric string') - .refine( - (val) => { - try { - return BigInt(val) > 0n; - } catch { - return false; - } - }, - { - message: 'Amount must be greater than 0', - } - ), -}); - -// --- Trading: user-signed transaction --- - -/** - * POST /api/trading/submit-tx - * signedXdr must be a non-empty base64 string (the Stellar SDK will reject - * anything that isn't valid XDR at the service layer). - */ -export const submitTxBody = z.object({ - signedXdr: z - .string() - .min(1, 'signedXdr is required') - .regex(/^[A-Za-z0-9+/]+=*$/, 'signedXdr must be a valid base64 string'), +export const oracleSubmitBody = z.object({ + match_id: z.string().min(1, 'match_id is required'), + outcome: z.enum(['fighter_a', 'fighter_b', 'draw', 'no_contest'], { + errorMap: () => ({ message: 'outcome must be one of: fighter_a, fighter_b, draw, no_contest' }), + }), + reported_at: z.string().datetime({ message: 'reported_at must be a valid ISO 8601 datetime string' }), + signature: z.string().regex(/^[0-9a-fA-F]+$/, 'signature must be a hex-encoded string').min(1), + oracle_address: z.string().min(1, 'oracle_address is required'), }); // --- Dispute schemas --- @@ -327,55 +146,39 @@ export const resolveDisputeBody = z newWinningOutcome: z.number().int().min(0).max(1).optional(), }) .refine( - (data) => { - if ( - data.action === 'RESOLVE_NEW_OUTCOME' && - data.newWinningOutcome === undefined - ) { - return false; - } - return true; - }, - { - message: - 'New winning outcome is required when action is RESOLVE_NEW_OUTCOME', - path: ['newWinningOutcome'], - } + (data) => !(data.action === 'RESOLVE_NEW_OUTCOME' && data.newWinningOutcome === undefined), + { message: 'New winning outcome is required when action is RESOLVE_NEW_OUTCOME', path: ['newWinningOutcome'] }, ); -// --- Wallet schemas --- +// --- Bet / trading schemas --- -export const getBalanceQuery = z.object({}).strict(); - -export const getTransactionsQuery = z.object({ - page: z - .string() - .regex(/^\d+$/, 'page must be a number') - .transform(Number) - .refine((val) => val >= 1, 'page must be >= 1') - .optional() - .default('1'), - limit: z +export const buySharesBody = z.object({ + outcome: z.number().int().min(0).max(1), + amount: z .string() - .regex(/^\d+$/, 'limit must be a number') - .transform(Number) - .refine((val) => val >= 1 && val <= 100, 'limit must be between 1 and 100') - .optional() - .default('20'), - type: z - .enum(['DEPOSIT', 'WITHDRAW', 'REWARD', 'REFUND']) - .optional(), - from: z + .regex(/^\d+$/, 'Amount must be a numeric string (USDC base units)') + .refine((val) => { try { return BigInt(val) > 0n; } catch { return false; } }, { message: 'Amount must be greater than 0' }) + .refine((val) => { try { return BigInt(val) <= 1_000_000_000_000n; } catch { return false; } }, { message: 'Amount exceeds maximum limit' }), + minShares: z.string().regex(/^\d+$/, 'minShares must be a numeric string').optional(), +}); + +export const sellSharesBody = z.object({ + outcome: z.number().int().min(0).max(1), + shares: z .string() - .datetime() - .optional(), - to: z + .regex(/^\d+$/, 'Shares must be a numeric string (base units)') + .refine((val) => { try { return BigInt(val) > 0n; } catch { return false; } }, { message: 'Shares must be greater than 0' }), + minPayout: z.string().regex(/^\d+$/, 'minPayout must be a numeric string').optional(), +}); + +export const submitTxBody = z.object({ + signedXdr: z .string() - .datetime() - .optional(), + .min(1, 'signedXdr is required') + .regex(/^[A-Za-z0-9+/]+=*$/, 'signedXdr must be a valid base64 string'), }); -// --- User profile update schema (issue #36) --- +// --- User profile --- export const updateProfileBody = z .object({ @@ -384,14 +187,10 @@ export const updateProfileBody = z .trim() .transform(stripHtml) .pipe( - z - .string() - .min(3, 'Username must be at least 3 characters') - .max(30, 'Username must be at most 30 characters') - .regex( - /^[a-zA-Z0-9_]+$/, - 'Username may only contain letters, numbers, and underscores' - ) + z.string() + .min(3) + .max(30) + .regex(/^[a-zA-Z0-9_]+$/, 'Username may only contain letters, numbers, and underscores'), ) .optional(), avatarUrl: z.string().url('avatarUrl must be a valid URL').optional(),