godcrm/backend/middleware/errorHandler.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

203 lines
5.1 KiB
JavaScript

// backend/middleware/errorHandler.js
// Global Error Handler - ADR-015
// Created: 2026-01-08
import { logger } from '../utils/logger.js';
import { sendErrorAlert } from '../services/TelegramService.js';
/**
* Application Error class with code and status
* Use this for operational errors that should be shown to users
*/
export class AppError extends Error {
/**
* @param {string} code - Error code
* @param {string} message - Error message
* @param {number} statusCode - HTTP status code
*/
constructor(code, message, statusCode = 500) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.isOperational = true;
this.timestamp = new Date().toISOString();
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Not Found Error (404)
*/
export class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super('NOT_FOUND', `${resource} not found`, 404);
}
}
/**
* Validation Error (400)
*/
export class ValidationError extends AppError {
constructor(message, details = []) {
super('VALIDATION_ERROR', message, 400);
this.details = details;
}
}
/**
* Unauthorized Error (401)
*/
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super('UNAUTHORIZED', message, 401);
}
}
/**
* Forbidden Error (403)
*/
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super('FORBIDDEN', message, 403);
}
}
/**
* Conflict Error (409)
*/
export class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super('CONFLICT', message, 409);
}
}
/**
* Rate Limit Error (429)
*/
export class RateLimitError extends AppError {
constructor(message = 'Too many requests') {
super('RATE_LIMITED', message, 429);
}
}
/**
* Database Error (500)
*/
export class DatabaseError extends AppError {
constructor(message = 'Database operation failed') {
super('DATABASE_ERROR', message, 500);
}
}
/**
* Global error handling middleware
* Must be registered LAST in Express middleware chain
*
* @param {Error} err - Error object
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
export function errorHandler(err, req, res, next) {
// Default values
// BUG FIX: body-parser (express.json) sets err.status, not err.statusCode
// Without checking err.status, JSON parse errors (SyntaxError) fall through to 500
let statusCode = err.statusCode || err.status || 500;
let code = err.code || 'INTERNAL_ERROR';
let message = err.message || 'An unexpected error occurred';
let details = err.details || null;
// Handle CORS errors — these are scanner/bot probes, not real 500s
if (err.message === 'Not allowed by CORS' || err.message === 'CORS not configured') {
statusCode = 403;
code = 'CORS_BLOCKED';
message = err.message;
err.isOperational = true;
}
// Handle body-parser SyntaxError (malformed JSON in request body)
// Mark as operational so production filter doesn't override status back to 500
if (err.type === 'entity.parse.failed' && err instanceof SyntaxError) {
statusCode = 400;
code = 'INVALID_JSON';
message = 'Malformed JSON in request body';
details = err.message;
err.isOperational = true;
}
// Log the error
const logContext = {
err,
requestId: req.id,
method: req.method,
path: req.path,
userId: req.user?.id
};
if (statusCode >= 500) {
logger.error(logContext, message);
} else {
logger.warn(logContext, message);
}
// Don't leak internal error details in production
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction && !err.isOperational) {
message = 'An unexpected error occurred';
code = 'INTERNAL_ERROR';
statusCode = 500;
details = null;
}
// Build error response
const errorResponse = {
success: false,
error: { code, message },
timestamp: new Date().toISOString()
};
if (details) {
errorResponse.error.details = details;
}
// Include stack trace in development
if (!isProduction && err.stack) {
errorResponse.error.stack = err.stack.split('\n').slice(0, 5);
}
res.status(statusCode).json(errorResponse);
// Send Telegram alert for 500+ errors (non-blocking)
if (statusCode >= 500) {
sendErrorAlert({
method: req.method,
path: req.path,
statusCode,
message: err.message || 'Unknown error',
userId: req.user?.id,
requestId: req.id,
stack: err.stack
}).catch(telegramErr => {
logger.warn({ err: telegramErr.message }, 'Failed to send Telegram error alert');
});
}
}
/**
* Async handler wrapper to catch errors in async route handlers
* @param {Function} fn - Async route handler
* @returns {import('express').RequestHandler}
*/
export function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
/**
* Not found handler for undefined routes
*/
export function notFoundHandler(req, res, next) {
next(new NotFoundError('Route'));
}