111 lines
3.7 KiB
TypeScript
111 lines
3.7 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",
|
|
"invitecode",
|
|
"invite_code",
|
|
"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" },
|
|
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;
|
|
}
|
|
|
|
if (debugEnabled) {
|
|
const context = payload.context || {};
|
|
payload = { ...payload, context: { ...context, route } };
|
|
logApiError(payload);
|
|
}
|
|
|
|
return {
|
|
status,
|
|
body: {
|
|
requestId,
|
|
request_id: requestId,
|
|
error: { code: payload.code, message: payload.message },
|
|
...(debugEnabled ? { debug: payload.context } : {})
|
|
}
|
|
};
|
|
}
|