import { ZodError } from "zod"; import { ServiceError } from "@/server/services/context"; import { ForbiddenError } from "@/server/rbac"; import { ModuleDisabledError } from "@/server/modules"; /** * JSON response helpers for /api/v1 route handlers (spec §29.2). * Error format: `{ error: { code, message, details? } }`; list format: * `{ data: [...], pagination: { page, pageSize, total } }`. * Internal error details never leave the server (CWE-209). */ export type ApiErrorCode = | "unauthorized" | "forbidden" | "not_found" | "invalid" | "conflict" | "blocked" | "payload_too_large" | "internal"; const STATUS: Record = { unauthorized: 401, forbidden: 403, not_found: 404, invalid: 422, conflict: 409, blocked: 409, payload_too_large: 413, internal: 500, }; export class ApiError extends Error { constructor( public code: ApiErrorCode, message: string, public details?: unknown, ) { super(message); this.name = "ApiError"; } } export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response { return Response.json( { error: { code, message, ...(details !== undefined ? { details } : {}) } }, { status: STATUS[code], headers: { "Cache-Control": "no-store" } }, ); } /** Map any thrown error to a JSON error response. */ export function toErrorResponse(err: unknown): Response { if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details); if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details); if (err instanceof ZodError) { return errorResponse( "invalid", "validation failed", err.issues.map((i) => ({ path: i.path.join("."), code: i.code })), ); } if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden"); if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled"); if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found"); console.error("[api] unhandled error", err); return errorResponse("internal", "internal error"); } export function json(data: unknown, init?: { status?: number }): Response { return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } }); } export function paginated(items: T[], total: number, page: number, pageSize: number): Response { return json({ data: items, pagination: { page, pageSize, total } }); } /** `?page&pageSize` with sane bounds (pageSize 1..100, default 25). */ export function parsePagination(url: URL | string, defaults = { pageSize: 25 }): { page: number; pageSize: number } { const u = typeof url === "string" ? new URL(url) : url; const page = Math.max(1, Math.floor(Number(u.searchParams.get("page")) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(u.searchParams.get("pageSize")) || defaults.pageSize))); return { page, pageSize }; } /** Wrap a handler so every thrown error becomes a JSON error response. */ export function withApi(handler: (...args: A) => Promise) { return async (...args: A): Promise => { try { return await handler(...args); } catch (err) { return toErrorResponse(err); } }; } /** Read a JSON body; malformed JSON → 422. */ export async function readJson(req: Request): Promise { try { return await req.json(); } catch { throw new ApiError("invalid", "malformed JSON body"); } }