From d36f53d63733f15d3d99742c2d13d8e64a63c8cc Mon Sep 17 00:00:00 2001 From: Victor Isiguzor Uzoma <121663416+victorisiguzoruzoma874@users.noreply.github.com> Date: Wed, 27 May 2026 23:01:48 +0000 Subject: [PATCH] feat(upload): add request body size limits and schema validation - Add validateRequestSize utility to check content-length before buffering - Implement schema validation for upload metadata with strict constraints - Add support for optional metadata fields: filename, description, tags, category - Reject oversized requests with 413 Payload Too Large - Reject malformed metadata with 400 Bad Request and detailed errors - Add comprehensive test coverage for size and schema validation - Document upload constraints in endpoint JSDoc Co-Authored-By: Claude Haiku 4.5 --- .../src/app/api/branding/upload/route.test.ts | 100 ++++++++++ .../src/app/api/branding/upload/route.ts | 144 ++++++++++++-- .../src/lib/api/validate-request-size.test.ts | 105 +++++++++++ .../src/lib/api/validate-request-size.ts | 72 +++++++ .../lib/validation/schema-validator.test.ts | 178 ++++++++++++++++++ .../src/lib/validation/schema-validator.ts | 117 ++++++++++++ 6 files changed, 696 insertions(+), 20 deletions(-) create mode 100644 apps/backend/src/lib/api/validate-request-size.test.ts create mode 100644 apps/backend/src/lib/api/validate-request-size.ts create mode 100644 apps/backend/src/lib/validation/schema-validator.test.ts create mode 100644 apps/backend/src/lib/validation/schema-validator.ts diff --git a/apps/backend/src/app/api/branding/upload/route.test.ts b/apps/backend/src/app/api/branding/upload/route.test.ts index ce13c15e..3a1fa9a3 100644 --- a/apps/backend/src/app/api/branding/upload/route.test.ts +++ b/apps/backend/src/app/api/branding/upload/route.test.ts @@ -82,4 +82,104 @@ describe('POST /api/branding/upload', () => { expect(res.status).toBe(422); expect((await res.json()).code).toBe('UNSAFE_SVG'); }); + + it('returns 413 when content-length exceeds size limit', async () => { + const { POST } = await import('./route'); + const req = new NextRequest('http://localhost/api/branding/upload', { + method: 'POST', + headers: { + 'content-length': String(6 * 1024 * 1024), // 6MB, exceeds 5MB limit + }, + }); + const res = await POST(req, { params: {} }); + expect(res.status).toBe(413); + expect((await res.json()).error).toContain('exceeds maximum'); + }); + + it('returns 413 when content-length is missing', async () => { + const { POST } = await import('./route'); + const req = new NextRequest('http://localhost/api/branding/upload', { + method: 'POST', + headers: {}, + }); + const res = await POST(req, { params: {} }); + expect(res.status).toBe(413); + }); + + it('accepts valid metadata in JSON format', async () => { + const { POST } = await import('./route'); + const form = new FormData(); + const file = new File([PNG_MAGIC], 'logo.png', { type: 'image/png' }); + form.append('file', file); + form.append('metadata', JSON.stringify({ + filename: 'logo.png', + description: 'Main logo', + tags: ['logo', 'brand'], + category: 'branding', + })); + + const req = new NextRequest('http://localhost/api/branding/upload', { method: 'POST' }); + (req as any).formData = async () => form; + + const res = await POST(req, { params: {} }); + expect(res.status).toBe(200); + }); + + it('returns 400 for invalid metadata JSON', async () => { + const { POST } = await import('./route'); + const form = new FormData(); + const file = new File([PNG_MAGIC], 'logo.png', { type: 'image/png' }); + form.append('file', file); + form.append('metadata', '{invalid json}'); + + const req = new NextRequest('http://localhost/api/branding/upload', { method: 'POST' }); + (req as any).formData = async () => form; + + const res = await POST(req, { params: {} }); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('JSON'); + }); + + it('returns 400 for invalid metadata schema', async () => { + const { POST } = await import('./route'); + const form = new FormData(); + const file = new File([PNG_MAGIC], 'logo.png', { type: 'image/png' }); + form.append('file', file); + form.append('metadata', JSON.stringify({ + filename: 123, // Invalid: should be string + tags: 'not-array', // Invalid: should be array + })); + + const req = new NextRequest('http://localhost/api/branding/upload', { method: 'POST' }); + (req as any).formData = async () => form; + + const res = await POST(req, { params: {} }); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('Validation'); + }); + + it('returns 400 when metadata is not a JSON string', async () => { + const { POST } = await import('./route'); + const form = new FormData(); + const file = new File([PNG_MAGIC], 'logo.png', { type: 'image/png' }); + form.append('file', file); + form.append('metadata', 123); // Not a string + + const req = new NextRequest('http://localhost/api/branding/upload', { method: 'POST' }); + (req as any).formData = async () => form; + + const res = await POST(req, { params: {} }); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('JSON string'); + }); + + it('returns 413 when file size exceeds limit in body validation', async () => { + const { POST } = await import('./route'); + const big = new Uint8Array(6 * 1024 * 1024); // 6MB + big.set(PNG_MAGIC); + const file = new File([big], 'logo.png', { type: 'image/png' }); + const res = await POST(makeMultipartRequest(file), { params: {} }); + expect(res.status).toBe(413); + expect((await res.json()).error).toContain('exceeds maximum'); + }); }); diff --git a/apps/backend/src/app/api/branding/upload/route.ts b/apps/backend/src/app/api/branding/upload/route.ts index 53d3ae0d..3f54feef 100644 --- a/apps/backend/src/app/api/branding/upload/route.ts +++ b/apps/backend/src/app/api/branding/upload/route.ts @@ -1,35 +1,139 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/with-auth'; import { validateBrandingFile } from '@/lib/customization/validate-branding-file'; +import { + validateContentLength, + DEFAULT_MAX_BRANDING_FILE_SIZE, + formatBytes, +} from '@/lib/api/validate-request-size'; +import { + validateUploadMetadata, + formatValidationErrors, +} from '@/lib/validation/schema-validator'; /** * POST /api/branding/upload - * Accepts a multipart/form-data upload with a single "file" field. - * Validates type, extension, size, and content safety before accepting. * - * On success returns { url } — currently a placeholder; wire to your storage - * provider (e.g. Supabase Storage) in a follow-up. + * Upload and validate branding files with strict size and schema validation. + * Validates file size, type, format, and optional metadata before processing. + * + * Request body: multipart/form-data + * file (required) The file to upload + * metadata (optional) JSON metadata with optional fields: + * - filename: string (max 255 chars) + * - description: string (max 1000 chars) + * - tags: string[] (max 20 tags, each 1-50 chars) + * - category: one of "branding", "content", "config", "other" + * + * Responses: + * 200 — File validated successfully + * 400 — Missing required fields or invalid format + * 401 — Not authenticated + * 413 — Request body exceeds size limit + * 422 — File validation failed (invalid type, size, or safety) + * 500 — Unexpected server error + * + * Issue: #607 + * Branch: feat/issue-071-upload-size-schema-validation */ export const POST = withAuth(async (req: NextRequest) => { - let formData: FormData; try { - formData = await req.formData(); - } catch { - return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 }); - } + // Validate request size before buffering entire body + const contentLength = req.headers.get('content-length'); + const sizeValidation = validateContentLength( + contentLength, + DEFAULT_MAX_BRANDING_FILE_SIZE, + ); - const file = formData.get('file'); - if (!(file instanceof File)) { - return NextResponse.json({ error: 'Missing "file" field' }, { status: 400 }); - } + if (!sizeValidation.valid) { + return NextResponse.json( + { error: sizeValidation.message }, + { status: 413 }, + ); + } - const buffer = new Uint8Array(await file.arrayBuffer()); - const result = validateBrandingFile(file.name, file.type, file.size, buffer); + let formData: FormData; + try { + formData = await req.formData(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to parse form data'; + console.error('[branding-upload] form parsing error:', err); + return NextResponse.json( + { error: 'Expected multipart/form-data' }, + { status: 400 }, + ); + } - if (!result.valid) { - return NextResponse.json({ error: result.error, code: result.code }, { status: 422 }); - } + // Validate file field + const file = formData.get('file'); + if (!(file instanceof File)) { + return NextResponse.json( + { error: 'Missing or invalid "file" field' }, + { status: 400 }, + ); + } + + // Additional file size check (safety measure) + if (file.size > DEFAULT_MAX_BRANDING_FILE_SIZE) { + return NextResponse.json( + { + error: `File exceeds maximum size of ${formatBytes(DEFAULT_MAX_BRANDING_FILE_SIZE)}. Received: ${formatBytes(file.size)}.`, + }, + { status: 413 }, + ); + } - // TODO: upload buffer to Supabase Storage / S3 and return the real URL - return NextResponse.json({ url: null, message: 'File validated successfully. Storage not yet wired.' }, { status: 200 }); + // Validate optional metadata + let metadata; + const metadataField = formData.get('metadata'); + if (metadataField) { + if (typeof metadataField !== 'string') { + return NextResponse.json( + { error: 'Metadata field must be a JSON string' }, + { status: 400 }, + ); + } + + try { + metadata = JSON.parse(metadataField); + } catch (err: unknown) { + return NextResponse.json( + { error: 'Invalid metadata: must be valid JSON' }, + { status: 400 }, + ); + } + + const metadataValidation = validateUploadMetadata(metadata); + if (!metadataValidation.valid) { + return NextResponse.json( + { error: formatValidationErrors(metadataValidation.errors) }, + { status: 400 }, + ); + } + } + + // Validate file (type, extension, content safety) + const buffer = new Uint8Array(await file.arrayBuffer()); + const fileValidation = validateBrandingFile(file.name, file.type, file.size, buffer); + + if (!fileValidation.valid) { + return NextResponse.json( + { error: fileValidation.error, code: fileValidation.code }, + { status: 422 }, + ); + } + + // TODO: upload buffer to Supabase Storage / S3 and return the real URL + return NextResponse.json( + { + url: null, + message: 'File validated successfully. Storage not yet wired.', + }, + { status: 200 }, + ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Upload failed'; + console.error('[branding-upload] unexpected error:', err); + return NextResponse.json({ error: msg }, { status: 500 }); + } }); diff --git a/apps/backend/src/lib/api/validate-request-size.test.ts b/apps/backend/src/lib/api/validate-request-size.test.ts new file mode 100644 index 00000000..4362ccc2 --- /dev/null +++ b/apps/backend/src/lib/api/validate-request-size.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { + checkRequestSize, + formatBytes, + validateContentLength, + DEFAULT_MAX_REQUEST_SIZE, + DEFAULT_MAX_BRANDING_FILE_SIZE, +} from './validate-request-size'; + +describe('validate-request-size', () => { + describe('checkRequestSize', () => { + it('should return size for valid content-length', () => { + const size = checkRequestSize('1024', DEFAULT_MAX_REQUEST_SIZE); + expect(size).toBe(1024); + }); + + it('should return null for missing content-length', () => { + const size = checkRequestSize(null, DEFAULT_MAX_REQUEST_SIZE); + expect(size).toBeNull(); + }); + + it('should return null when size exceeds limit', () => { + const size = checkRequestSize( + String(DEFAULT_MAX_REQUEST_SIZE + 1), + DEFAULT_MAX_REQUEST_SIZE, + ); + expect(size).toBeNull(); + }); + + it('should return null for invalid content-length', () => { + const size = checkRequestSize('invalid', DEFAULT_MAX_REQUEST_SIZE); + expect(size).toBeNull(); + }); + + it('should accept size equal to limit', () => { + const size = checkRequestSize(String(DEFAULT_MAX_REQUEST_SIZE), DEFAULT_MAX_REQUEST_SIZE); + expect(size).toBe(DEFAULT_MAX_REQUEST_SIZE); + }); + }); + + describe('formatBytes', () => { + it('should format bytes correctly', () => { + expect(formatBytes(0)).toBe('0 bytes'); + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1024 * 1024)).toBe('1 MB'); + expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GB'); + }); + + it('should round to 2 decimal places', () => { + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5 MB'); + }); + + it('should handle large files', () => { + const result = formatBytes(5 * 1024 * 1024); + expect(result).toContain('MB'); + expect(parseFloat(result)).toBe(5); + }); + }); + + describe('validateContentLength', () => { + it('should validate correct content-length', () => { + const result = validateContentLength('1024', DEFAULT_MAX_REQUEST_SIZE); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.size).toBe(1024); + } + }); + + it('should reject missing content-length', () => { + const result = validateContentLength(null, DEFAULT_MAX_REQUEST_SIZE); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toContain('Content-Length'); + } + }); + + it('should reject invalid content-length', () => { + const result = validateContentLength('invalid', DEFAULT_MAX_REQUEST_SIZE); + expect(result.valid).toBe(false); + }); + + it('should reject oversized content-length', () => { + const result = validateContentLength( + String(DEFAULT_MAX_BRANDING_FILE_SIZE + 1), + DEFAULT_MAX_BRANDING_FILE_SIZE, + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toContain('exceeds maximum'); + } + }); + + it('should include formatted sizes in error message', () => { + const result = validateContentLength( + String(10 * 1024 * 1024), + DEFAULT_MAX_BRANDING_FILE_SIZE, + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.message).toContain('MB'); + } + }); + }); +}); diff --git a/apps/backend/src/lib/api/validate-request-size.ts b/apps/backend/src/lib/api/validate-request-size.ts new file mode 100644 index 00000000..10d378c0 --- /dev/null +++ b/apps/backend/src/lib/api/validate-request-size.ts @@ -0,0 +1,72 @@ +/** + * Request body size validation utilities. + * Prevents resource exhaustion by rejecting oversized payloads early. + */ + +export const DEFAULT_MAX_REQUEST_SIZE = 50 * 1024 * 1024; // 50MB +export const DEFAULT_MAX_FORM_DATA_SIZE = 100 * 1024 * 1024; // 100MB +export const DEFAULT_MAX_JSON_SIZE = 10 * 1024 * 1024; // 10MB +export const DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB +export const DEFAULT_MAX_BRANDING_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +export interface SizeLimit { + bytes: number; + label: string; +} + +/** + * Check if a request body size exceeds the limit + * Returns the response size in bytes, or null if limit exceeded + */ +export function checkRequestSize( + contentLength: string | null, + maxBytes: number, +): number | null { + if (!contentLength) return null; + + const size = parseInt(contentLength, 10); + if (isNaN(size) || size > maxBytes) { + return null; + } + + return size; +} + +/** + * Format bytes as human-readable size (e.g., "5 MB", "100 KB") + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 bytes'; + + const k = 1024; + const sizes = ['bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * Validate content-length header and return error response if limit exceeded + */ +export function validateContentLength( + contentLength: string | null, + maxBytes: number, +): { valid: true; size: number } | { valid: false; message: string } { + if (!contentLength) { + return { valid: false, message: 'Missing Content-Length header' }; + } + + const size = parseInt(contentLength, 10); + if (isNaN(size)) { + return { valid: false, message: 'Invalid Content-Length header' }; + } + + if (size > maxBytes) { + return { + valid: false, + message: `Request body exceeds maximum size of ${formatBytes(maxBytes)}. Received: ${formatBytes(size)}.`, + }; + } + + return { valid: true, size }; +} diff --git a/apps/backend/src/lib/validation/schema-validator.test.ts b/apps/backend/src/lib/validation/schema-validator.test.ts new file mode 100644 index 00000000..5296ec15 --- /dev/null +++ b/apps/backend/src/lib/validation/schema-validator.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import { + validateUploadMetadata, + formatValidationErrors, + type ValidationError, +} from './schema-validator'; + +describe('schema-validator', () => { + describe('validateUploadMetadata', () => { + it('should accept empty object', () => { + const result = validateUploadMetadata({}); + expect(result.valid).toBe(true); + }); + + it('should accept valid filename', () => { + const result = validateUploadMetadata({ filename: 'test.png' }); + expect(result.valid).toBe(true); + }); + + it('should reject non-string filename', () => { + const result = validateUploadMetadata({ filename: 123 }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'filename')).toBe(true); + } + }); + + it('should reject empty filename', () => { + const result = validateUploadMetadata({ filename: '' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'filename')).toBe(true); + } + }); + + it('should reject filename exceeding 255 characters', () => { + const longName = 'a'.repeat(256); + const result = validateUploadMetadata({ filename: longName }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'filename')).toBe(true); + } + }); + + it('should accept valid description', () => { + const result = validateUploadMetadata({ + description: 'A nice branding asset', + }); + expect(result.valid).toBe(true); + }); + + it('should reject description exceeding 1000 characters', () => { + const longDesc = 'a'.repeat(1001); + const result = validateUploadMetadata({ description: longDesc }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'description')).toBe(true); + } + }); + + it('should accept valid tags array', () => { + const result = validateUploadMetadata({ tags: ['logo', 'brand'] }); + expect(result.valid).toBe(true); + }); + + it('should reject non-array tags', () => { + const result = validateUploadMetadata({ tags: 'logo,brand' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'tags')).toBe(true); + } + }); + + it('should reject more than 20 tags', () => { + const tags = Array(21).fill('tag'); + const result = validateUploadMetadata({ tags }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'tags')).toBe(true); + } + }); + + it('should reject non-string tags', () => { + const result = validateUploadMetadata({ tags: [123, 'valid'] }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field.startsWith('tags'))).toBe(true); + } + }); + + it('should reject empty tags', () => { + const result = validateUploadMetadata({ tags: [''] }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field.startsWith('tags'))).toBe(true); + } + }); + + it('should reject tags exceeding 50 characters', () => { + const result = validateUploadMetadata({ tags: ['a'.repeat(51)] }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field.startsWith('tags'))).toBe(true); + } + }); + + it('should accept valid category', () => { + const result = validateUploadMetadata({ category: 'branding' }); + expect(result.valid).toBe(true); + }); + + it('should accept all valid categories', () => { + const categories = ['branding', 'content', 'config', 'other']; + for (const category of categories) { + const result = validateUploadMetadata({ category }); + expect(result.valid).toBe(true); + } + }); + + it('should reject invalid category', () => { + const result = validateUploadMetadata({ category: 'invalid' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'category')).toBe(true); + } + }); + + it('should reject non-string category', () => { + const result = validateUploadMetadata({ category: 123 }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.some((e) => e.field === 'category')).toBe(true); + } + }); + + it('should reject non-object metadata', () => { + const result = validateUploadMetadata('not an object'); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors[0].field).toBe('metadata'); + } + }); + + it('should collect multiple validation errors', () => { + const result = validateUploadMetadata({ + filename: 123, + tags: 'not-array', + category: 'invalid', + }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.errors.length).toBeGreaterThanOrEqual(3); + } + }); + }); + + describe('formatValidationErrors', () => { + it('should format single error', () => { + const errors: ValidationError[] = [ + { field: 'filename', message: 'Cannot be empty' }, + ]; + const result = formatValidationErrors(errors); + expect(result).toContain('filename'); + expect(result).toContain('Cannot be empty'); + }); + + it('should format multiple errors', () => { + const errors: ValidationError[] = [ + { field: 'filename', message: 'Cannot be empty' }, + { field: 'tags', message: 'Must be an array' }, + ]; + const result = formatValidationErrors(errors); + expect(result).toContain('Validation errors'); + expect(result).toContain('filename'); + expect(result).toContain('tags'); + }); + }); +}); diff --git a/apps/backend/src/lib/validation/schema-validator.ts b/apps/backend/src/lib/validation/schema-validator.ts new file mode 100644 index 00000000..ed245fe6 --- /dev/null +++ b/apps/backend/src/lib/validation/schema-validator.ts @@ -0,0 +1,117 @@ +/** + * Schema validation utilities for request metadata. + * Validates that structured data conforms to expected types and constraints. + */ + +export interface ValidationError { + field: string; + message: string; +} + +export interface ValidationResult { + valid: true; +} | { + valid: false; + errors: ValidationError[]; +}; + +/** + * Validate upload metadata schema + */ +export interface UploadMetadata { + filename?: string; + description?: string; + tags?: string[]; + category?: string; +} + +export function validateUploadMetadata(metadata: unknown): ValidationResult { + const errors: ValidationError[] = []; + + if (typeof metadata !== 'object' || metadata === null) { + return { + valid: false, + errors: [{ field: 'metadata', message: 'Metadata must be an object' }], + }; + } + + const obj = metadata as Record; + + // Validate filename if present + if ('filename' in obj) { + if (typeof obj.filename !== 'string') { + errors.push({ field: 'filename', message: 'Filename must be a string' }); + } else if (obj.filename.trim().length === 0) { + errors.push({ field: 'filename', message: 'Filename cannot be empty' }); + } else if (obj.filename.length > 255) { + errors.push({ field: 'filename', message: 'Filename exceeds 255 characters' }); + } + } + + // Validate description if present + if ('description' in obj) { + if (typeof obj.description !== 'string') { + errors.push({ field: 'description', message: 'Description must be a string' }); + } else if (obj.description.length > 1000) { + errors.push({ field: 'description', message: 'Description exceeds 1000 characters' }); + } + } + + // Validate tags if present + if ('tags' in obj) { + if (!Array.isArray(obj.tags)) { + errors.push({ field: 'tags', message: 'Tags must be an array' }); + } else { + if (obj.tags.length > 20) { + errors.push({ field: 'tags', message: 'Cannot have more than 20 tags' }); + } + for (let i = 0; i < obj.tags.length; i++) { + const tag = obj.tags[i]; + if (typeof tag !== 'string') { + errors.push({ + field: `tags[${i}]`, + message: 'Each tag must be a string', + }); + } else if (tag.length === 0 || tag.length > 50) { + errors.push({ + field: `tags[${i}]`, + message: 'Each tag must be 1-50 characters', + }); + } + } + } + } + + // Validate category if present + if ('category' in obj) { + if (typeof obj.category !== 'string') { + errors.push({ field: 'category', message: 'Category must be a string' }); + } else { + const validCategories = ['branding', 'content', 'config', 'other']; + if (!validCategories.includes(obj.category)) { + errors.push({ + field: 'category', + message: `Category must be one of: ${validCategories.join(', ')}`, + }); + } + } + } + + if (errors.length > 0) { + return { valid: false, errors }; + } + + return { valid: true }; +} + +/** + * Format validation errors into a user-friendly message + */ +export function formatValidationErrors(errors: ValidationError[]): string { + if (errors.length === 1) { + return `${errors[0].field}: ${errors[0].message}`; + } + + const lines = errors.map((e) => ` • ${e.field}: ${e.message}`); + return 'Validation errors:\n' + lines.join('\n'); +}