Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
203 lines
5.1 KiB
JavaScript
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'));
|
|
}
|