diff --git a/backend/src/middleware/validateRequest.js b/backend/src/middleware/validateRequest.js index 35b92094..37763e18 100644 --- a/backend/src/middleware/validateRequest.js +++ b/backend/src/middleware/validateRequest.js @@ -2,19 +2,41 @@ import { formatZodErrors } from '../schemas.js'; /** + * Validates incoming request payload against Zod schema. + * Enforces explicit validation, type coercion, and uniform error responses. + * Returns error boundaries with structured details for client debugging. + * * Creates middleware to validate `req.body` against a Zod schema. * Returns uniform 400 error shape on validation failure: * { error: "Validation failed", code: "VALIDATION_ERROR", details: [...] } * * @param {import('zod').ZodSchema} schema + * @param {Object} options + * @param {number} [options.maxPayloadSize=10485760] - max payload size in bytes (default 10MB) */ -export function validateBody(schema) { +export function validateBody(schema, options = {}) { + const { maxPayloadSize = 10 * 1024 * 1024 } = options; + return (req, res, next) => { + if (!req.body) { + req.body = {}; + } + + const payloadSize = JSON.stringify(req.body).length; + if (payloadSize > maxPayloadSize) { + return res.status(413).json({ + error: 'Payload too large', + code: 'PAYLOAD_TOO_LARGE', + details: [{ field: 'body', message: `Payload exceeds ${maxPayloadSize} bytes` }], + }); + } + const result = schema.safeParse(req.body); if (!result.success) { const details = result.error.issues.map((issue) => ({ - field: issue.path.join('.'), + field: issue.path.join('.') || 'root', message: issue.message, + code: issue.code, })); return res.status(400).json({ error: 'Validation failed', @@ -29,6 +51,9 @@ export function validateBody(schema) { } /** + * Validates query string parameters against Zod schema. + * Enforces type coercion and boundary checking. + * * Creates middleware to validate `req.query` against a Zod schema. * @param {import('zod').ZodSchema} schema */ @@ -37,8 +62,9 @@ export function validateQuery(schema) { const result = schema.safeParse(req.query); if (!result.success) { const details = result.error.issues.map((issue) => ({ - field: issue.path.join('.'), + field: issue.path.join('.') || 'root', message: issue.message, + code: issue.code, })); return res.status(400).json({ error: 'Validation failed', @@ -53,6 +79,9 @@ export function validateQuery(schema) { } /** + * Validates path parameters against Zod schema. + * Enforces defensive checks and type validation on URL segments. + * * Creates middleware to validate `req.params` against a Zod schema. * @param {import('zod').ZodSchema} schema */ @@ -61,8 +90,9 @@ export function validateParams(schema) { const result = schema.safeParse(req.params); if (!result.success) { const details = result.error.issues.map((issue) => ({ - field: issue.path.join('.'), + field: issue.path.join('.') || 'root', message: issue.message, + code: issue.code, })); return res.status(400).json({ error: 'Validation failed', diff --git a/backend/src/schemas/validationRegistry.js b/backend/src/schemas/validationRegistry.js new file mode 100644 index 00000000..a98109ce --- /dev/null +++ b/backend/src/schemas/validationRegistry.js @@ -0,0 +1,68 @@ +// @ts-check +import { z } from 'zod'; + +export const validationRegistry = { + request: { + pagination: z.object({ + limit: z.coerce.number().int().min(1).max(1000).default(10), + offset: z.coerce.number().int().min(0).default(0), + cursor: z.string().optional(), + }), + + identifiers: z.object({ + id: z.string().trim().min(1), + userId: z.string().trim().min(1), + email: z.string().email('Invalid email format'), + campaignId: z.string().trim().min(1), + }), + + filters: z.object({ + status: z.enum(['active', 'inactive', 'archived']).optional(), + category: z.string().optional(), + search: z.string().max(255).optional(), + }), + }, + + response: { + error: z.object({ + error: z.string(), + code: z.string(), + details: z.array(z.object({ + field: z.string().optional(), + message: z.string(), + code: z.string().optional(), + })).optional(), + }), + + success: z.object({ + data: z.unknown(), + meta: z.object({ + timestamp: z.string(), + version: z.string().optional(), + }).optional(), + }), + }, + + auth: { + credentials: z.object({ + email: z.string().email('Invalid email'), + password: z.string().min(8, 'Password must be at least 8 characters'), + }), + + token: z.object({ + token: z.string().min(1), + expiresAt: z.string().datetime().optional(), + type: z.enum(['Bearer', 'Basic']).default('Bearer'), + }), + }, +}; + +export const createValidationSchema = (registry, path) => { + const keys = path.split('.'); + let schema = registry; + for (const key of keys) { + schema = schema[key]; + if (!schema) throw new Error(`Schema not found at path: ${path}`); + } + return schema; +};