diff --git a/src/middlewares/body-parse-error.middleware.test.ts b/src/middlewares/body-parse-error.middleware.test.ts index 368edc4..bfed25d 100644 --- a/src/middlewares/body-parse-error.middleware.test.ts +++ b/src/middlewares/body-parse-error.middleware.test.ts @@ -82,7 +82,9 @@ describe('bodyParseErrorMiddleware', () => { expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ success: false, - message: 'Invalid JSON in request body', + error: expect.objectContaining({ + message: 'Invalid JSON in request body', + }), }) ); expect(next).not.toHaveBeenCalled(); diff --git a/src/middlewares/body-parse-error.middleware.ts b/src/middlewares/body-parse-error.middleware.ts index bc8712a..e48c594 100644 --- a/src/middlewares/body-parse-error.middleware.ts +++ b/src/middlewares/body-parse-error.middleware.ts @@ -3,6 +3,7 @@ import { logger } from '../utils/logger.utils'; import { getClientIp } from '../utils/client-ip.utils'; import { ErrorCode } from '../constants/error.constants'; import { sanitizeLogFieldValue } from '../utils/log-field-sanitizer.utils'; +import { buildErrorResponse } from '../utils/api-response.utils'; const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); @@ -52,17 +53,17 @@ export const bodyParseErrorMiddleware = ( }); if (isEntityTooLarge) { - res.status(413).json({ - success: false, - code: ErrorCode.BAD_REQUEST, - message: 'Request payload too large', - }); + res + .status(413) + .json( + buildErrorResponse(ErrorCode.BAD_REQUEST, 'Request payload too large') + ); return; } - res.status(400).json({ - success: false, - code: ErrorCode.BAD_REQUEST, - message: 'Invalid JSON in request body', - }); + res + .status(400) + .json( + buildErrorResponse(ErrorCode.BAD_REQUEST, 'Invalid JSON in request body') + ); }; diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts index 824e81e..84838ad 100644 --- a/src/middlewares/error.middleware.ts +++ b/src/middlewares/error.middleware.ts @@ -117,11 +117,9 @@ export const errorHandler: ErrorRequestHandler = ( route: `${req.method} ${sanitizeLogFieldValue(req.originalUrl)}`, requestId: req.requestId, }); - res.status(401).json({ - success: false, - code: ErrorCode.JWT_ERROR, - message: 'Invalid or expired token', - }); + res.status(401).json( + buildErrorResponse(ErrorCode.JWT_ERROR, 'Invalid or expired token') + ); return; } @@ -132,11 +130,9 @@ export const errorHandler: ErrorRequestHandler = ( route: `${req.method} ${sanitizeLogFieldValue(req.originalUrl)}`, requestId: req.requestId, }); - res.status(401).json({ - success: false, - code: ErrorCode.JWT_ERROR, - message: 'Token has expired', - }); + res.status(401).json( + buildErrorResponse(ErrorCode.JWT_ERROR, 'Token has expired') + ); return; } @@ -157,22 +153,26 @@ export const errorHandler: ErrorRequestHandler = ( break; } - res.status(400).json({ - success: false, - code: ErrorCode.PRISMA_ERROR, - message, - ...(envConfig.MODE === 'development' && { error: err.message }), - }); + res.status(400).json( + buildErrorResponse( + ErrorCode.PRISMA_ERROR, + message, + envConfig.MODE === 'development' + ? [{ message: err.message }] + : undefined + ) + ); return; } // Handle custom API errors if (err instanceof ApiError) { - res.status(err.statusCode).json({ - success: false, - code: err.errorCode || ErrorCode.INTERNAL_ERROR, - message: err.message, - }); + res.status(err.statusCode).json( + buildErrorResponse( + err.errorCode || ErrorCode.INTERNAL_ERROR, + err.message + ) + ); return; } @@ -188,21 +188,17 @@ export const errorHandler: ErrorRequestHandler = ( contentLength: req.headers['content-length'], limitBytes: err.limit, }); - res.status(413).json({ - success: false, - code: ErrorCode.BAD_REQUEST, - message: 'Request payload too large', - }); + res.status(413).json( + buildErrorResponse(ErrorCode.BAD_REQUEST, 'Request payload too large') + ); return; } // Handle syntax errors (malformed JSON) if (err instanceof SyntaxError && 'body' in err) { - res.status(400).json({ - success: false, - code: ErrorCode.BAD_REQUEST, - message: 'Invalid JSON format', - }); + res.status(400).json( + buildErrorResponse(ErrorCode.BAD_REQUEST, 'Invalid JSON format') + ); return; } diff --git a/src/modules/admin/admin.controllers.ts b/src/modules/admin/admin.controllers.ts index f28a064..735aed9 100644 --- a/src/modules/admin/admin.controllers.ts +++ b/src/modules/admin/admin.controllers.ts @@ -4,6 +4,7 @@ import { sendValidationError, sendCreatorParamNotFound, sendForbidden, + sendError, } from '../../utils/api-response.utils'; import { prisma } from '../../utils/prisma.utils'; import { emitAuditEvent } from '../../utils/audit.utils'; @@ -166,19 +167,18 @@ export const httpReplayIndexerEvents: AsyncController = async ( }); if (!lock.acquired) { - return res.status(409).json({ - success: false, - error: { - code: ErrorCode.CONFLICT, - message: 'Indexer replay job is already running', - details: [ - { - field: 'indexerReplayLock', - message: `Lock is held by ${lock.holder || 'another worker'} until ${lock.expiresAt || 'unknown time'}`, - }, - ], - }, - }); + return sendError( + res, + 409, + ErrorCode.CONFLICT, + 'Indexer replay job is already running', + [ + { + field: 'indexerReplayLock', + message: `Lock is held by ${lock.holder || 'another worker'} until ${lock.expiresAt || 'unknown time'}`, + }, + ] + ); } const replayInitiated = { diff --git a/src/modules/auth/auth.controllers.ts b/src/modules/auth/auth.controllers.ts index 68e9725..2b1077b 100644 --- a/src/modules/auth/auth.controllers.ts +++ b/src/modules/auth/auth.controllers.ts @@ -5,6 +5,7 @@ import { SendMailAsync } from '../../utils/mail.utils'; import { HTTP_STATUS, logger } from '../../utils/logger.utils'; import bcrypt from 'bcrypt'; import { refreshAccessToken } from './token-refresh.utils'; +import { buildErrorResponse, ErrorCode } from '../../utils/api-response.utils'; export const httpRegisterUserWithPassword: AsyncController = async ( req, @@ -153,21 +154,27 @@ export const httpRefreshToken: AsyncController = async (req, res, next) => { : undefined) ?? req.body?.token; if (!token) { - return res.status(HTTP_STATUS.UNAUTHORIZED).json({ - success: false, - code: 'invalid_token', - message: 'No token provided', - }); + return res + .status(HTTP_STATUS.UNAUTHORIZED) + .json( + buildErrorResponse(ErrorCode.JWT_ERROR, 'No token provided', [ + { message: 'invalid_token' }, + ]) + ); } const result = refreshAccessToken(token); if (!result.success) { - return res.status(result.status).json({ - success: false, - code: result.code, - message: 'Token could not be refreshed', - }); + return res + .status(result.status) + .json( + buildErrorResponse( + ErrorCode.JWT_ERROR, + 'Token could not be refreshed', + [{ message: result.code }] + ) + ); } return res.status(HTTP_STATUS.OK).json({ diff --git a/src/modules/health/health.controllers.ts b/src/modules/health/health.controllers.ts index c93a8a6..1a745f8 100644 --- a/src/modules/health/health.controllers.ts +++ b/src/modules/health/health.controllers.ts @@ -3,7 +3,7 @@ import { prisma } from '../../utils/prisma.utils'; import { envConfig } from '../../config'; import { indexerHeartbeat } from '../../utils/heartbeat.service'; import { checkIndexerCursorStalenessFromStore } from '../../utils/indexer-cursor-staleness.utils'; -import { sendSuccess } from '../../utils/api-response.utils'; +import { sendSuccess, sendError, ErrorCode } from '../../utils/api-response.utils'; import { PUBLIC_ENDPOINT_CACHE_SECONDS } from '../../constants/public-endpoint-cache.constants'; import { logger } from '../../utils/logger.utils'; @@ -159,11 +159,11 @@ export const healthCheck = async (_: Request, res: Response): Promise => { res.status(overallHealthy ? 200 : 503).json(healthData); } catch (error) { logger.error({ error }, 'Health check failed'); - res.status(500).json({ - success: false, - message: 'Health check failed', - error: error instanceof Error ? error.message : 'Unknown error', - }); + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Health check failed', [ + { + message: error instanceof Error ? error.message : 'Unknown error', + }, + ]); } }; diff --git a/src/modules/ledger/ledger.controllers.test.ts b/src/modules/ledger/ledger.controllers.test.ts index 7c0b993..ec415f8 100644 --- a/src/modules/ledger/ledger.controllers.test.ts +++ b/src/modules/ledger/ledger.controllers.test.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { httpGetLedgerStatus } from './ledger.controllers'; import { prisma } from '../../utils/prisma.utils'; -import { sendSuccess } from '../../utils/api-response.utils'; +import { sendSuccess, sendError } from '../../utils/api-response.utils'; // Mock prisma and api-response utils jest.mock('../../utils/prisma.utils', () => ({ @@ -14,6 +14,8 @@ jest.mock('../../utils/prisma.utils', () => ({ jest.mock('../../utils/api-response.utils', () => ({ sendSuccess: jest.fn(), + sendError: jest.fn(), + ErrorCode: { INTERNAL_ERROR: 'INTERNAL_ERROR' }, })); jest.mock('../../utils/timestamp-headers.utils', () => ({ @@ -90,12 +92,11 @@ describe('Ledger Controller', () => { mockResponse as Response ); - expect(statusFn).toHaveBeenCalledWith(500); - expect(jsonFn).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - message: 'Failed to fetch ledger status', - }) + expect(sendError).toHaveBeenCalledWith( + mockResponse, + 500, + 'INTERNAL_ERROR', + 'Failed to fetch ledger status' ); }); }); diff --git a/src/modules/ledger/ledger.controllers.ts b/src/modules/ledger/ledger.controllers.ts index c7f9643..9116a7b 100644 --- a/src/modules/ledger/ledger.controllers.ts +++ b/src/modules/ledger/ledger.controllers.ts @@ -1,6 +1,10 @@ import { Request, Response } from 'express'; import { prisma } from '../../utils/prisma.utils'; -import { sendSuccess } from '../../utils/api-response.utils'; +import { + sendSuccess, + sendError, + ErrorCode, +} from '../../utils/api-response.utils'; import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; import { logger } from '../../utils/logger.utils'; @@ -40,9 +44,11 @@ export const httpGetLedgerStatus = async ( }); } catch (error) { logger.error({ error }, 'Failed to fetch ledger status'); - res.status(500).json({ - success: false, - message: 'Failed to fetch ledger status', - }); + sendError( + res, + 500, + ErrorCode.INTERNAL_ERROR, + 'Failed to fetch ledger status' + ); } }; diff --git a/src/utils/api-response.utils.ts b/src/utils/api-response.utils.ts index 36bbc74..3ca7778 100644 --- a/src/utils/api-response.utils.ts +++ b/src/utils/api-response.utils.ts @@ -33,6 +33,18 @@ interface ApiErrorResponse { }; } +/** + * Thrown by {@link buildErrorResponse} when called with an empty string + * error code, since an empty code can't be used by clients to distinguish + * error types. + */ +export class InvalidErrorCode extends Error { + constructor() { + super('buildErrorResponse: error code must not be an empty string'); + this.name = 'InvalidErrorCode'; + } +} + /** * Builds a structured error response body, embedding the request ID from the * current async-local-storage context when available. The `requestId` field is @@ -47,6 +59,7 @@ interface ApiErrorResponse { * @param message - Human-readable error message * @param details - Optional per-field validation details * @returns Structured error response body ready to pass to `res.json()` + * @throws {InvalidErrorCode} when `code` is an empty string * * @example * res.status(400).json(buildErrorResponse(ErrorCode.VALIDATION_ERROR, 'Bad input')); @@ -56,6 +69,9 @@ export function buildErrorResponse( message: string, details?: Array<{ field?: string; message: string }> ): ApiErrorResponse { + if (!code) { + throw new InvalidErrorCode(); + } const requestId = requestContextStorage.getStore()?.requestId; const body: ApiErrorResponse = { success: false, diff --git a/src/utils/test/api-response.utils.test.ts b/src/utils/test/api-response.utils.test.ts index 3c9bb3f..2a226d2 100644 --- a/src/utils/test/api-response.utils.test.ts +++ b/src/utils/test/api-response.utils.test.ts @@ -6,6 +6,8 @@ import { buildErrorResponse, zodIssuesToDetails, ErrorCode, + ErrorCodeType, + InvalidErrorCode, } from '../api-response.utils'; import { requestContextStorage } from '../als.utils'; @@ -135,6 +137,12 @@ describe('buildErrorResponse', () => { expect(body.error).not.toHaveProperty('details'); }); + it('throws InvalidErrorCode when the code is an empty string', () => { + expect(() => buildErrorResponse('' as ErrorCodeType, 'message')).toThrow( + InvalidErrorCode + ); + }); + it('requestId in response matches the requestId in the server log context', () => { // Simulates the traceability requirement: the same requestId that appears // in the error response body is the one stored in the ALS context (which