/** * Gateway error handler (H-13). * * Goal: never leak err.message, err.stack, file paths, SQL fragments, * upstream API error bodies, or credentials to clients. Translate any * unknown error into a {status, client-safe payload, full log line}. * * Design: * - Recognized error types → preserve their (already safe) code + message * (e.g. PayloadTooLargeError = CR-7, RecallFailure = H-15, SeedValidationError) * - Anything else (5xx) → generic "Internal server error" + traceId * - Server log: traceId + full err.message + stack (for debugging) * - Client log: only the safe message + traceId so the user can quote it * to support team for log correlation * * Note: this module is deliberately leaf-level (no imports from server.ts / * v2-router.ts) so both can import it without circular deps. Use duck-typing * for PayloadTooLargeError detection to avoid pulling server.ts in. */ import { randomUUID } from "node:crypto"; import { RecallFailure } from "../core/hooks/recall-errors.js"; export interface ClientFacingError { /** Stable business code: HTTP status (4xx/5xx) for unknown errors, RecallError.code for recall failures. */ code: number; /** Already-sanitized human-readable message safe for clients. */ message: string; /** UUID for log correlation; user quotes this when reporting issues. */ trace_id: string; /** Whether retry is sensible. */ retryable?: boolean; } export interface ClassifiedError { /** HTTP status code to send. */ status: number; /** Payload safe to send to the client. */ client: ClientFacingError; /** Full description (including stack/cause) for the server log only. */ logLine: string; } /** * Classify any thrown error into a (status, client payload, log line) triple. * * Recognized: * - PayloadTooLargeError (CR-7) — detected via duck-typing on statusCode === 413 * - RecallFailure (H-15) — code from RecallError taxonomy * - SeedValidationError — 400 with generic message * - Anything else — 500 Internal server error */ export function classifyError(err: unknown): ClassifiedError { const trace_id = randomUUID(); // 1. PayloadTooLargeError (CR-7) — duck-typed to avoid circular import on gateway/server.ts. const statusCode = (err as { statusCode?: unknown })?.statusCode; if (statusCode === 413) { const msg = err instanceof Error ? err.message : "Payload too large"; return { status: 413, client: { code: 413, message: msg, trace_id, retryable: false }, logLine: `[${trace_id}] PayloadTooLargeError: ${msg}`, }; } // 2. RecallFailure (H-15) — the recall layer has already produced a safe message. // (Gateway handlers normally translate this into RecallResult.error before reaching // the global catch, but if a code path forgets, this fallback ensures no raw leak.) if (err instanceof RecallFailure) { const re = err.recallError; const causeStr = err.cause instanceof Error ? (err.cause.stack ?? err.cause.message) : err.cause !== undefined ? String(err.cause) : "(no cause)"; return { status: re.category === "config" ? 503 : 500, client: { code: re.code, message: re.message, trace_id, retryable: re.retryable }, logLine: `[${trace_id}] RecallFailure code=${re.code} category=${re.category} cause=${sanitize(causeStr)}`, }; } // 3. SeedValidationError — known user-input class if (err && typeof err === "object" && (err as { name?: string }).name === "SeedValidationError") { const errMsg = err instanceof Error ? err.message : String(err); return { status: 400, client: { code: 400, message: "Invalid seed input", trace_id, retryable: false }, logLine: `[${trace_id}] SeedValidationError: ${sanitize(errMsg)}`, }; } // 4. Generic 5xx — strictly hide err.message from client const errMsg = err instanceof Error ? err.message : String(err); const errStack = err instanceof Error ? err.stack : undefined; return { status: 500, client: { code: 500, message: "Internal server error", trace_id, retryable: true }, logLine: `[${trace_id}] UnhandledError: ${sanitize(errMsg)}\n${sanitize(errStack ?? "(no stack)")}`, }; } // ============================ // Sanitize: strip secrets from a string before it enters logs. // ============================ /** * Best-effort secret redaction for log strings (M-2 / H-13). * * Removes / masks: * - OpenAI / DeepSeek / Anthropic style API keys (sk-*, sk-ant-*) * - HTTP Authorization headers (Bearer, Basic) * - JSON fields named SecretKey / apiKey / password / token / authorization * * This is a heuristic safety net, not a substitute for not logging secrets * in the first place. Apply to err.message / err.stack / any object dumped * to the log via JSON.stringify. */ export function sanitize(input: string): string { if (typeof input !== "string") return String(input); return input // sk-ant-xxx — Anthropic (must come before the generic sk-* rule below // since "sk-ant-..." also matches /sk-[A-Za-z0-9_-]{16,}/) .replace(/sk-ant-[A-Za-z0-9_-]{16,}/g, "sk-ant-***") // sk-xxxxx (16+ chars) — OpenAI, DeepSeek, etc. .replace(/sk-[A-Za-z0-9_-]{16,}/g, "sk-***") // Bearer / Basic auth headers .replace(/(Bearer|Basic)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 ***") // JSON-like "field": "value" for sensitive fields (case-insensitive, both single/double quotes) .replace( /("(?:SecretKey|apiKey|api_key|password|token|authorization|TmpSecretId|TmpSecretKey|TmpToken)"\s*:\s*)"[^"]*"/gi, '$1"***"', ) .replace( /('(?:SecretKey|apiKey|api_key|password|token|authorization|TmpSecretId|TmpSecretKey|TmpToken)'\s*:\s*)'[^']*'/gi, "$1'***'", ); } /** * Recursively sanitize an object/array — replaces values of known-sensitive keys with '***' * and runs `sanitize()` on string values to catch ad-hoc secrets that aren't keyed. * * Useful for `logger.debug(sanitizeObject({cosConfig, request}))`. */ export function sanitizeObject(obj: unknown): unknown { if (typeof obj === "string") return sanitize(obj); if (Array.isArray(obj)) return obj.map(sanitizeObject); if (obj && typeof obj === "object") { const r: Record = {}; for (const [k, v] of Object.entries(obj as Record)) { if (/secretkey|apikey|api_key|password|token|authorization|TmpSecret/i.test(k)) { r[k] = "***"; } else { r[k] = sanitizeObject(v); } } return r; } return obj; }