/** * Typed application error. Every non-2xx response is produced from an * ApiError (directly or by mapping a known exception) into the canonical * envelope { error: { code, message, details?, requestId } }. */ export type ErrorCode = | "VALIDATION_ERROR" | "UNAUTHORIZED" | "INVALID_TOKEN" | "TOKEN_EXPIRED" | "FORBIDDEN" | "SCOPE_DENIED" | "NOT_FOUND" | "CONFLICT" | "PRECONDITION_FAILED" | "GONE" | "UNPROCESSABLE_ENTITY" | "RATE_LIMITED" | "PAYLOAD_TOO_LARGE" | "QUOTA_EXCEEDED" | "AI_BUDGET_EXCEEDED" | "AI_NOT_CONFIGURED" | "SEARCH_UNAVAILABLE" | "STORAGE_ERROR" // File pipeline (Phase 9 — docs/tasks/phase-09-files.md TASK-0101/0102/0104). | "FILE_TOO_LARGE" | "MIME_MISMATCH" | "UPLOAD_INCOMPLETE" | "UPLOAD_EXPIRED" | "FILE_QUARANTINED" | "SERVICE_UNAVAILABLE" | "NOT_IMPLEMENTED" | "UNSUPPORTED_MEDIA_TYPE" // Phase 10 (TASK-0108): the public submit's 415 content-type guard // Automation (Phase 11 — docs/tasks/phase-11-automation.md TASK-0110): // incoming webhook signature verification failures (401, code carries the // reason so clients can debug without leaking which check failed first). | "WEBHOOK_SIGNATURE_INVALID" | "WEBHOOK_TIMESTAMP_STALE" | "WEBHOOK_RATE_LIMITED" // 429 — per-webhookId 60/min (Redis window) | "WEBHOOK_REPLAY_DETECTED" // 401 — TASK-0130: same t+body-hash nonce seen within the 10-min window // TASK-0134 — formula-engine DoS guard (docs/api/authentication.md §9.1): // a syntactically valid formula that exceeds the 100k-op budget or the // 50 ms wall-clock budget. 422 UNPROCESSABLE_ENTITY (syntactically valid, // too much work). Distinct from the save-time PARSE/TYPE/UNKNOWN_FUNCTION // codes (400) and from RUNTIME (null-prop / output-size) which stay 400. | "FORMULA_TIMEOUT" // TASK-0129 — RFC 6749 OAuth2 token-endpoint error codes (docs/api/ // authentication.md §8.2). Carried in the canonical envelope's `code` field // (the app-wide pattern). The RFC's flat `{error, error_description}` body // is a recorded divergence (C-13.7) — the envelope is superset-safe for // clients that read `error.code` / `error.message`. | "INVALID_GRANT" | "INVALID_CLIENT" | "INVALID_SCOPE" | "INTERNAL_ERROR"; export interface ErrorDetail { field?: string; issue?: string; reason?: string; received?: unknown; retryAfterSeconds?: number; [key: string]: unknown; } export class ApiError extends Error { constructor( public readonly code: ErrorCode, public readonly httpStatus: number, message: string, public readonly details?: ErrorDetail[] ) { super(message); this.name = "ApiError"; } static validation(message = "Request validation failed.", details?: ErrorDetail[]): ApiError { return new ApiError("VALIDATION_ERROR", 400, message, details); } static unauthorized(message = "Authentication required.", details?: ErrorDetail[]): ApiError { return new ApiError("UNAUTHORIZED", 401, message, details); } static forbidden(message = "Permission denied.", details?: ErrorDetail[]): ApiError { return new ApiError("FORBIDDEN", 403, message, details); } static notFound(message = "Resource not found."): ApiError { return new ApiError("NOT_FOUND", 404, message); } static conflict(message = "Resource conflict.", details?: ErrorDetail[]): ApiError { return new ApiError("CONFLICT", 409, message, details); } /** 422 — structurally valid request that fails domain validation * (TASK-0110: per-type trigger/condition/action config failures, cron * matrix, status machine, sub-resource caps). */ static unprocessable(message = "Request is not processable.", details?: ErrorDetail[]): ApiError { return new ApiError("UNPROCESSABLE_ENTITY", 422, message, details); } static webhookSignatureInvalid(message = "Invalid webhook signature."): ApiError { return new ApiError("WEBHOOK_SIGNATURE_INVALID", 401, message, [{ reason: "signature_invalid" }]); } static webhookTimestampStale(message = "Webhook timestamp outside the ±5 minute window."): ApiError { return new ApiError("WEBHOOK_TIMESTAMP_STALE", 401, message, [{ reason: "timestamp_expired" }]); } static webhookRateLimited(message = "Webhook rate limit exceeded (60/min).", retryAfterSeconds = 60): ApiError { return new ApiError("WEBHOOK_RATE_LIMITED", 429, message, [{ retryAfterSeconds }]); } /** TASK-0130: the same signature+timestamp+body was already accepted * within the replay window (Redis nonce, 10 min TTL). */ static webhookReplayDetected(message = "Webhook replay detected (duplicate signature nonce)."): ApiError { return new ApiError("WEBHOOK_REPLAY_DETECTED", 401, message, [{ reason: "replay_detected" }]); } static internal(message = "Internal server error."): ApiError { return new ApiError("INTERNAL_ERROR", 500, message); } }