if (process.env.NODE_ENV !== "test") require("server-only"); import crypto from "node:crypto"; type ErrorContext = Record | undefined; type ErrorPayload = { code: string; message: string; context?: ErrorContext; }; const sensitiveKeys = new Set([ "password", "token", "invitecode", "invite_code", "authorization", "cookie", "session", "sessionid", "secret" ]); const statusMap: Record = { UNAUTHORIZED: { status: 401, message: "Unauthorized" }, FORBIDDEN: { status: 403, message: "Forbidden" }, NOT_FOUND: { status: 404, message: "Not found" }, NO_ACTIVE_GROUP: { status: 400, message: "No active group" }, INVALID_TAGS: { status: 400, message: "Invalid tags" }, INVALID_INVITE: { status: 404, message: "Invalid invite code" }, INVITE_NOT_FOUND: { status: 404, message: "Invite link not found" }, INVITE_REVOKED: { status: 410, message: "Invite link has been revoked" }, INVITE_EXPIRED: { status: 410, message: "Invite link has expired" }, INVITE_USED: { status: 409, message: "Invite link has already been used" }, JOIN_NOT_ACCEPTING: { status: 403, message: "Group is not accepting new members" }, JOIN_PENDING: { status: 409, message: "Join request pending approval" }, JOIN_REQUEST_NOT_FOUND: { status: 404, message: "Join request not found" }, NOT_MEMBER: { status: 404, message: "User is not a group member" }, OWNER_MUST_TRANSFER: { status: 403, message: "Owner must transfer ownership before leaving" }, CANNOT_LEAVE_LAST_MEMBER: { status: 403, message: "Cannot leave last remaining member" }, EMAIL_EXISTS: { status: 409, message: "Email already registered" }, INVALID_CREDENTIALS: { status: 401, message: "Invalid credentials" }, RATE_LIMITED: { status: 429, message: "Too many requests" } }; export class ApiError extends Error { code: string; context?: ErrorContext; constructor(code: string, context?: ErrorContext) { super(code); this.code = code; this.context = context; } } export function createRequestId() { return crypto.randomBytes(16).toString("hex"); } export function apiError(code: string, context?: ErrorContext): never { throw new ApiError(code, context); } function redactContext(context?: ErrorContext) { if (!context) return context; return Object.fromEntries( Object.entries(context).map(([key, value]) => { if (sensitiveKeys.has(key.toLowerCase())) return [key, "[redacted]"]; return [key, value]; }) ); } function logApiError(payload: ErrorPayload) { const safeContext = redactContext(payload.context); console.error("API_ERROR", { code: payload.code, message: payload.message, context: safeContext }); } export function toErrorResponse(e: unknown, route: string, requestId?: string) { const debugEnabled = process.env.DEBUG_API === "1"; let payload: ErrorPayload = { code: "SERVER_ERROR", message: "Server error" }; let status = 500; if (e instanceof ApiError) { const mapped = statusMap[e.code]; payload = { code: e.code, message: mapped?.message || "Server error", context: e.context }; status = mapped?.status || 500; } const baseContext = payload.context || {}; const logContext = { ...baseContext, route, requestId, status }; logApiError({ code: payload.code, message: payload.message, context: logContext }); if (debugEnabled) { payload = { ...payload, context: redactContext(logContext) }; } return { status, body: { requestId, request_id: requestId, error: { code: payload.code, message: payload.message }, ...(debugEnabled ? { debug: payload.context } : {}) } }; }