fiddy/apps/web/lib/server/errors.ts
2026-02-11 23:45:15 -08:00

108 lines
3.6 KiB
TypeScript

if (process.env.NODE_ENV !== "test") require("server-only");
import crypto from "node:crypto";
type ErrorContext = Record<string, unknown> | undefined;
type ErrorPayload = {
code: string;
message: string;
context?: ErrorContext;
};
const sensitiveKeys = new Set([
"password",
"token",
"authorization",
"cookie",
"session",
"sessionid",
"secret"
]);
const statusMap: Record<string, { status: number; message: string }> = {
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" }
};
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";
console.log()
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;
}
if (debugEnabled) {
const context = payload.context || {};
payload = { ...payload, context: { ...context, route } };
logApiError(payload);
}
return {
status,
body: {
requestId,
error: { code: payload.code, message: payload.message },
...(debugEnabled ? { debug: payload.context } : {})
}
};
}