@nextrush/errors 3.0.7 → 4.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +321 -522
- package/dist/index.d.ts +123 -38
- package/dist/index.js +229 -19
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/audit-fixes.test.ts +181 -0
- package/src/__tests__/base.test.ts +2 -2
- package/src/__tests__/factory.test.ts +9 -2
- package/src/__tests__/header-validation.test.ts +43 -0
- package/src/__tests__/middleware.test.ts +120 -33
- package/src/__tests__/public-surface.test.ts +121 -0
- package/src/__tests__/security-audit-contribution.test.ts +40 -0
- package/src/base.ts +148 -3
- package/src/codes.ts +77 -0
- package/src/factory.ts +58 -1
- package/src/header-validation.ts +24 -0
- package/src/http-errors.ts +6 -0
- package/src/index.ts +7 -8
- package/src/middleware.ts +71 -43
- package/src/validation.ts +2 -1
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import type { Context } from '@nextrush/types';
|
|
6
6
|
import { describe, expect, it, vi } from 'vitest';
|
|
7
7
|
import { BadRequestError, InternalServerError, NotFoundError } from '../http-errors';
|
|
8
|
-
import {
|
|
8
|
+
import { errorHandler, notFoundHandler } from '../middleware';
|
|
9
|
+
import { ValidationError } from '../validation';
|
|
9
10
|
|
|
10
11
|
function createMockContext(): Context {
|
|
11
12
|
const ctx = {
|
|
@@ -119,6 +120,88 @@ describe('errorHandler', () => {
|
|
|
119
120
|
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
120
121
|
expect(jsonCall.stack).toBeUndefined();
|
|
121
122
|
});
|
|
123
|
+
|
|
124
|
+
// SEC-14: includeStack: true must be a no-op in production, not honored
|
|
125
|
+
// unconditionally — a stack trace is a map of internal paths/dependency
|
|
126
|
+
// versions, and the previous behavior handed that map to any client the
|
|
127
|
+
// moment a deploy forgot to flip includeStack off.
|
|
128
|
+
describe('production guard (SEC-14)', () => {
|
|
129
|
+
it('ignores includeStack: true in production and logs exactly one warning', async () => {
|
|
130
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
131
|
+
const handler = errorHandler({ includeStack: true, isProduction: true });
|
|
132
|
+
const ctx = createMockContext();
|
|
133
|
+
|
|
134
|
+
await handler(ctx, async () => {
|
|
135
|
+
throw new BadRequestError('Invalid');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
139
|
+
expect(jsonCall.stack).toBeUndefined();
|
|
140
|
+
// The default 4xx logger also calls console.warn — isolate the
|
|
141
|
+
// SEC-14 guard's own warning by content rather than call count.
|
|
142
|
+
const sec14Warnings = warnSpy.mock.calls.filter((call) =>
|
|
143
|
+
String(call[0]).includes('includeStack')
|
|
144
|
+
);
|
|
145
|
+
expect(sec14Warnings).toHaveLength(1);
|
|
146
|
+
warnSpy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('warns only once across multiple requests in production, not once per request', async () => {
|
|
150
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
151
|
+
const handler = errorHandler({ includeStack: true, isProduction: true });
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < 3; i++) {
|
|
154
|
+
const ctx = createMockContext();
|
|
155
|
+
await handler(ctx, async () => {
|
|
156
|
+
throw new BadRequestError('Invalid');
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const sec14Warnings = warnSpy.mock.calls.filter((call) =>
|
|
161
|
+
String(call[0]).includes('includeStack')
|
|
162
|
+
);
|
|
163
|
+
expect(sec14Warnings).toHaveLength(1);
|
|
164
|
+
warnSpy.mockRestore();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('preserves development behavior — stack is present when isProduction is false', async () => {
|
|
168
|
+
const handler = errorHandler({ includeStack: true, isProduction: false });
|
|
169
|
+
const ctx = createMockContext();
|
|
170
|
+
|
|
171
|
+
await handler(ctx, async () => {
|
|
172
|
+
throw new BadRequestError('Invalid');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
176
|
+
expect(jsonCall.stack).toBeDefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('preserves development behavior when isProduction is omitted (default)', async () => {
|
|
180
|
+
const handler = errorHandler({ includeStack: true });
|
|
181
|
+
const ctx = createMockContext();
|
|
182
|
+
|
|
183
|
+
await handler(ctx, async () => {
|
|
184
|
+
throw new BadRequestError('Invalid');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
188
|
+
expect(jsonCall.stack).toBeDefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('a plain (non-HttpError) Error never exposes its message in production, with or without includeStack', async () => {
|
|
192
|
+
const handler = errorHandler({ includeStack: true, isProduction: true });
|
|
193
|
+
const ctx = createMockContext();
|
|
194
|
+
|
|
195
|
+
await handler(ctx, async () => {
|
|
196
|
+
throw new Error('internal db connection string: postgres://secret');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
200
|
+
expect(jsonCall.stack).toBeUndefined();
|
|
201
|
+
expect(JSON.stringify(jsonCall)).not.toContain('postgres://secret');
|
|
202
|
+
expect(jsonCall.message).toBe('Internal Server Error');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
122
205
|
});
|
|
123
206
|
|
|
124
207
|
describe('logger option', () => {
|
|
@@ -177,6 +260,42 @@ describe('errorHandler', () => {
|
|
|
177
260
|
});
|
|
178
261
|
});
|
|
179
262
|
|
|
263
|
+
describe('ValidationError serialization (regression)', () => {
|
|
264
|
+
it('includes `issues` in the response body for a ValidationError', async () => {
|
|
265
|
+
const handler = errorHandler();
|
|
266
|
+
const ctx = createMockContext();
|
|
267
|
+
|
|
268
|
+
await handler(ctx, async () => {
|
|
269
|
+
throw new ValidationError([
|
|
270
|
+
{ path: 'body.email', message: 'Invalid email address' },
|
|
271
|
+
{ path: 'body.name', message: 'Name is required' },
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
276
|
+
expect(jsonCall.issues).toEqual([
|
|
277
|
+
{ path: 'body.email', message: 'Invalid email address' },
|
|
278
|
+
{ path: 'body.name', message: 'Name is required' },
|
|
279
|
+
]);
|
|
280
|
+
expect(jsonCall.code).toBe('VALIDATION_ERROR');
|
|
281
|
+
expect(ctx.status).toBe(400);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('never leaks the raw `received` value for a ValidationError', async () => {
|
|
285
|
+
const handler = errorHandler();
|
|
286
|
+
const ctx = createMockContext();
|
|
287
|
+
|
|
288
|
+
await handler(ctx, async () => {
|
|
289
|
+
throw new ValidationError([
|
|
290
|
+
{ path: 'body.password', message: 'Invalid', received: 'super-secret-value' },
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
295
|
+
expect(JSON.stringify(jsonCall)).not.toContain('super-secret-value');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
180
299
|
describe('handlers option', () => {
|
|
181
300
|
it('should use custom handler for specific error type', async () => {
|
|
182
301
|
const customHandler = vi.fn();
|
|
@@ -303,38 +422,6 @@ describe('notFoundHandler', () => {
|
|
|
303
422
|
});
|
|
304
423
|
});
|
|
305
424
|
|
|
306
|
-
describe('catchAsync', () => {
|
|
307
|
-
it('should pass through successful handlers', async () => {
|
|
308
|
-
const innerHandler = vi.fn().mockResolvedValue(undefined);
|
|
309
|
-
const handler = catchAsync(innerHandler);
|
|
310
|
-
const ctx = createMockContext();
|
|
311
|
-
|
|
312
|
-
await handler(ctx, noop);
|
|
313
|
-
|
|
314
|
-
expect(innerHandler).toHaveBeenCalledWith(ctx, noop);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it('should re-throw errors', async () => {
|
|
318
|
-
const error = new NotFoundError('Not found');
|
|
319
|
-
const innerHandler = vi.fn().mockRejectedValue(error);
|
|
320
|
-
const handler = catchAsync(innerHandler);
|
|
321
|
-
const ctx = createMockContext();
|
|
322
|
-
|
|
323
|
-
await expect(handler(ctx, noop)).rejects.toThrow(error);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it('should pass next to inner handler', async () => {
|
|
327
|
-
const innerHandler = vi.fn().mockResolvedValue(undefined);
|
|
328
|
-
const handler = catchAsync(innerHandler);
|
|
329
|
-
const ctx = createMockContext();
|
|
330
|
-
const next = vi.fn();
|
|
331
|
-
|
|
332
|
-
await handler(ctx, next);
|
|
333
|
-
|
|
334
|
-
expect(innerHandler).toHaveBeenCalledWith(ctx, next);
|
|
335
|
-
});
|
|
336
|
-
});
|
|
337
|
-
|
|
338
425
|
describe('Integration scenarios', () => {
|
|
339
426
|
it('should work with errorHandler and notFoundHandler together', async () => {
|
|
340
427
|
const errHandler = errorHandler();
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/errors - Public API surface test
|
|
3
|
+
*
|
|
4
|
+
* Locks the exported symbol set from `src/index.ts`. If this test fails, the
|
|
5
|
+
* public API has changed. Intentional changes require an explicit update to
|
|
6
|
+
* the expected list below, plus a changeset for a published package.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
9
|
+
import * as errorsApi from '../index';
|
|
10
|
+
import type { ErrorHandlerOptions } from '../index';
|
|
11
|
+
import type { HttpErrorOptions } from '../index';
|
|
12
|
+
import type { ValidationIssue } from '../index';
|
|
13
|
+
|
|
14
|
+
describe('Public API surface (runtime exports)', () => {
|
|
15
|
+
it('exports exactly the intended runtime symbols', () => {
|
|
16
|
+
const actualExports = Object.keys(errorsApi).sort();
|
|
17
|
+
|
|
18
|
+
// SEALED: intentional public runtime API surface.
|
|
19
|
+
const expectedRuntime = [
|
|
20
|
+
// Base classes + helper
|
|
21
|
+
'HttpError',
|
|
22
|
+
'NextRushError',
|
|
23
|
+
'getHttpStatusMessage',
|
|
24
|
+
|
|
25
|
+
// Header validation
|
|
26
|
+
'HeaderValidationError',
|
|
27
|
+
|
|
28
|
+
// Central error-code registry
|
|
29
|
+
'ERROR_CODES',
|
|
30
|
+
'GENERIC_ERROR_CODE',
|
|
31
|
+
'VALIDATION_ERROR_CODE',
|
|
32
|
+
'codeForStatus',
|
|
33
|
+
|
|
34
|
+
// HTTP errors — 4xx
|
|
35
|
+
'BadRequestError',
|
|
36
|
+
'ConflictError',
|
|
37
|
+
'ExpectationFailedError',
|
|
38
|
+
'FailedDependencyError',
|
|
39
|
+
'ForbiddenError',
|
|
40
|
+
'GoneError',
|
|
41
|
+
'ImATeapotError',
|
|
42
|
+
'LengthRequiredError',
|
|
43
|
+
'LockedError',
|
|
44
|
+
'MethodNotAllowedError',
|
|
45
|
+
'NotAcceptableError',
|
|
46
|
+
'NotFoundError',
|
|
47
|
+
'PayloadTooLargeError',
|
|
48
|
+
'PaymentRequiredError',
|
|
49
|
+
'PreconditionFailedError',
|
|
50
|
+
'PreconditionRequiredError',
|
|
51
|
+
'ProxyAuthRequiredError',
|
|
52
|
+
'RangeNotSatisfiableError',
|
|
53
|
+
'RequestHeaderFieldsTooLargeError',
|
|
54
|
+
'RequestTimeoutError',
|
|
55
|
+
'TooEarlyError',
|
|
56
|
+
'TooManyRequestsError',
|
|
57
|
+
'UnauthorizedError',
|
|
58
|
+
'UnavailableForLegalReasonsError',
|
|
59
|
+
'UnprocessableEntityError',
|
|
60
|
+
'UnsupportedMediaTypeError',
|
|
61
|
+
'UpgradeRequiredError',
|
|
62
|
+
'UriTooLongError',
|
|
63
|
+
|
|
64
|
+
// HTTP errors — 5xx
|
|
65
|
+
'BadGatewayError',
|
|
66
|
+
'GatewayTimeoutError',
|
|
67
|
+
'HttpVersionNotSupportedError',
|
|
68
|
+
'InsufficientStorageError',
|
|
69
|
+
'InternalServerError',
|
|
70
|
+
'LoopDetectedError',
|
|
71
|
+
'NetworkAuthRequiredError',
|
|
72
|
+
'NotExtendedError',
|
|
73
|
+
'NotImplementedError',
|
|
74
|
+
'ServiceUnavailableError',
|
|
75
|
+
'VariantAlsoNegotiatesError',
|
|
76
|
+
|
|
77
|
+
// Validation errors
|
|
78
|
+
'InvalidEmailError',
|
|
79
|
+
'InvalidUrlError',
|
|
80
|
+
'LengthError',
|
|
81
|
+
'PatternError',
|
|
82
|
+
'RangeValidationError',
|
|
83
|
+
'RequiredFieldError',
|
|
84
|
+
'TypeMismatchError',
|
|
85
|
+
'ValidationError',
|
|
86
|
+
|
|
87
|
+
// Factory functions
|
|
88
|
+
'badGateway',
|
|
89
|
+
'badRequest',
|
|
90
|
+
'conflict',
|
|
91
|
+
'createError',
|
|
92
|
+
'forbidden',
|
|
93
|
+
'gatewayTimeout',
|
|
94
|
+
'getErrorStatus',
|
|
95
|
+
'getSafeErrorMessage',
|
|
96
|
+
'internalError',
|
|
97
|
+
'isHttpError',
|
|
98
|
+
'methodNotAllowed',
|
|
99
|
+
'notFound',
|
|
100
|
+
'serviceUnavailable',
|
|
101
|
+
'tooManyRequests',
|
|
102
|
+
'unauthorized',
|
|
103
|
+
'unprocessableEntity',
|
|
104
|
+
|
|
105
|
+
// Middleware
|
|
106
|
+
'errorHandler',
|
|
107
|
+
'notFoundHandler',
|
|
108
|
+
].sort();
|
|
109
|
+
|
|
110
|
+
expect(actualExports).toEqual(expectedRuntime);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Public API surface (type-only exports)', () => {
|
|
115
|
+
it('the type-only surface stays importable from the barrel', () => {
|
|
116
|
+
// Compile-time only: removing/renaming any of these in src/index.ts fails
|
|
117
|
+
// this file to type-check.
|
|
118
|
+
type Surface = [HttpErrorOptions, ValidationIssue, ErrorHandlerOptions];
|
|
119
|
+
expectTypeOf<Surface>().not.toBeNever();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/errors - Boot-time security audit contribution (task 8.1/8.2)
|
|
3
|
+
*
|
|
4
|
+
* `errorHandler()` tags its returned middleware with a {@link SECURITY_AUDIT}
|
|
5
|
+
* check so `Application.ready()` warns, in production, when `includeStack:
|
|
6
|
+
* true` was configured but `isProduction` was never wired to this instance —
|
|
7
|
+
* the per-request guard (task 7.8) only fires when the caller explicitly
|
|
8
|
+
* passes `isProduction: true`, so an app that forgets to thread it never
|
|
9
|
+
* gets the per-request warning even in a real production deployment. The
|
|
10
|
+
* boot audit catches exactly that omission.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { SECURITY_AUDIT, type SecurityAuditCheck } from '@nextrush/types';
|
|
14
|
+
import { errorHandler } from '../middleware';
|
|
15
|
+
|
|
16
|
+
function auditOf(mw: unknown): SecurityAuditCheck {
|
|
17
|
+
const check = (mw as Record<typeof SECURITY_AUDIT, SecurityAuditCheck>)[SECURITY_AUDIT];
|
|
18
|
+
expect(typeof check).toBe('function');
|
|
19
|
+
return check;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('errorHandler() — security-boundaries boot audit contribution', () => {
|
|
23
|
+
it('reports a warn-level verdict for includeStack: true with isProduction never wired', () => {
|
|
24
|
+
const mw = errorHandler({ includeStack: true });
|
|
25
|
+
const verdict = auditOf(mw)();
|
|
26
|
+
|
|
27
|
+
expect(verdict.level).toBe('warn');
|
|
28
|
+
expect(verdict).toMatchObject({ message: expect.stringContaining('includeStack') });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports ok when includeStack: true is paired with an explicit isProduction', () => {
|
|
32
|
+
const mw = errorHandler({ includeStack: true, isProduction: true });
|
|
33
|
+
expect(auditOf(mw)()).toEqual({ level: 'ok' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('reports ok for the default options (includeStack: false)', () => {
|
|
37
|
+
const mw = errorHandler();
|
|
38
|
+
expect(auditOf(mw)()).toEqual({ level: 'ok' });
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/base.ts
CHANGED
|
@@ -6,10 +6,52 @@
|
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { codeForStatus } from './codes';
|
|
10
|
+
|
|
11
|
+
/** Options accepted by {@link NextRushError.hydrate} when reconstructing errors. */
|
|
12
|
+
interface HydrateOptions {
|
|
13
|
+
status?: number;
|
|
14
|
+
code?: string;
|
|
15
|
+
details?: Record<string, unknown>;
|
|
16
|
+
requestId?: string;
|
|
17
|
+
traceId?: string;
|
|
18
|
+
timestamp?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
const V8Error = Error as ErrorConstructor & {
|
|
10
22
|
captureStackTrace?: (targetObject: object, constructorOpt?: Function) => void;
|
|
11
23
|
};
|
|
12
24
|
|
|
25
|
+
/** Maximum depth walked when serializing a `cause` chain (audit E-2). */
|
|
26
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Serialize an error `cause` chain into a plain, JSON-safe object.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* Walks nested `cause` links up to {@link MAX_CAUSE_DEPTH}, guards against
|
|
33
|
+
* cyclic chains via a visited set, and only surfaces `name`/`message`/`code`
|
|
34
|
+
* so no unexpected enumerable properties (or secrets on ad-hoc error objects)
|
|
35
|
+
* leak. Returns `undefined` when there is nothing meaningful to serialize.
|
|
36
|
+
*/
|
|
37
|
+
function serializeCause(cause: unknown, seen: Set<unknown>, depth: number): unknown {
|
|
38
|
+
if (cause === undefined || cause === null || depth >= MAX_CAUSE_DEPTH) return undefined;
|
|
39
|
+
if (typeof cause !== 'object') return { message: String(cause) };
|
|
40
|
+
if (seen.has(cause)) return undefined; // cycle guard
|
|
41
|
+
seen.add(cause);
|
|
42
|
+
|
|
43
|
+
const err = cause as { name?: unknown; message?: unknown; code?: unknown; cause?: unknown };
|
|
44
|
+
const out: Record<string, unknown> = {};
|
|
45
|
+
if (typeof err.name === 'string') out.name = err.name;
|
|
46
|
+
if (typeof err.message === 'string') out.message = err.message;
|
|
47
|
+
if (typeof err.code === 'string') out.code = err.code;
|
|
48
|
+
|
|
49
|
+
const nested = serializeCause(err.cause, seen, depth + 1);
|
|
50
|
+
if (nested !== undefined) out.cause = nested;
|
|
51
|
+
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
13
55
|
/**
|
|
14
56
|
* Base error class for all NextRush errors
|
|
15
57
|
*/
|
|
@@ -29,6 +71,15 @@ export class NextRushError extends Error {
|
|
|
29
71
|
/** Original error that caused this error */
|
|
30
72
|
readonly cause?: unknown;
|
|
31
73
|
|
|
74
|
+
/** Correlation/request identifier for tracing across boundaries (audit E-5). */
|
|
75
|
+
readonly requestId?: string;
|
|
76
|
+
|
|
77
|
+
/** Distributed-trace identifier (audit E-5). */
|
|
78
|
+
readonly traceId?: string;
|
|
79
|
+
|
|
80
|
+
/** ISO-8601 timestamp of when the error was created, when supplied. */
|
|
81
|
+
readonly timestamp?: string;
|
|
82
|
+
|
|
32
83
|
constructor(
|
|
33
84
|
message: string,
|
|
34
85
|
options: {
|
|
@@ -37,15 +88,25 @@ export class NextRushError extends Error {
|
|
|
37
88
|
expose?: boolean;
|
|
38
89
|
details?: Record<string, unknown>;
|
|
39
90
|
cause?: unknown;
|
|
91
|
+
requestId?: string;
|
|
92
|
+
traceId?: string;
|
|
93
|
+
timestamp?: string;
|
|
40
94
|
} = {}
|
|
41
95
|
) {
|
|
42
|
-
|
|
96
|
+
// Pass cause to the native Error constructor so `util.inspect` / logging
|
|
97
|
+
// tooling walks the chain natively, in addition to our own `cause` field.
|
|
98
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
43
99
|
this.name = this.constructor.name;
|
|
44
100
|
this.status = options.status ?? 500;
|
|
45
101
|
this.code = options.code ?? 'INTERNAL_ERROR';
|
|
46
102
|
this.expose = options.expose ?? this.status < 500;
|
|
47
|
-
|
|
103
|
+
// Freeze a shallow snapshot so the error owns immutable details and the
|
|
104
|
+
// caller's object cannot be mutated through the error (audit E-6).
|
|
105
|
+
this.details = options.details ? Object.freeze({ ...options.details }) : undefined;
|
|
48
106
|
this.cause = options.cause;
|
|
107
|
+
this.requestId = options.requestId;
|
|
108
|
+
this.traceId = options.traceId;
|
|
109
|
+
this.timestamp = options.timestamp;
|
|
49
110
|
|
|
50
111
|
// Maintain proper stack trace for V8
|
|
51
112
|
// Skip for common client errors (4xx with expose=true) to reduce overhead.
|
|
@@ -71,9 +132,66 @@ export class NextRushError extends Error {
|
|
|
71
132
|
json.details = this.details;
|
|
72
133
|
}
|
|
73
134
|
|
|
135
|
+
// Surface the cause chain for diagnosability, but only on exposed errors —
|
|
136
|
+
// a non-exposed 5xx cause may contain internal detail (audit E-2). Server
|
|
137
|
+
// -side logging should read `error.cause` directly, which is never gated.
|
|
138
|
+
if (this.expose && this.cause !== undefined) {
|
|
139
|
+
const serialized = serializeCause(this.cause, new Set(), 0);
|
|
140
|
+
if (serialized !== undefined) {
|
|
141
|
+
json.cause = serialized;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Correlation identifiers are safe to surface and are the primary handle
|
|
146
|
+
// for cross-service debugging (audit E-5). Only emitted when set, so the
|
|
147
|
+
// default serialized shape is unchanged.
|
|
148
|
+
if (this.requestId !== undefined) json.requestId = this.requestId;
|
|
149
|
+
if (this.traceId !== undefined) json.traceId = this.traceId;
|
|
150
|
+
if (this.timestamp !== undefined) json.timestamp = this.timestamp;
|
|
151
|
+
|
|
74
152
|
return json;
|
|
75
153
|
}
|
|
76
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Reconstruct a {@link NextRushError} from a serialized {@link toJSON} payload
|
|
157
|
+
* (audit E-7).
|
|
158
|
+
*
|
|
159
|
+
* @remarks
|
|
160
|
+
* Enables cross-service transport: a downstream service can rebuild a typed
|
|
161
|
+
* error (with a working `instanceof NextRushError`) from the JSON it received,
|
|
162
|
+
* rather than being left with an opaque plain object. `message` falls back to
|
|
163
|
+
* a generic string when the source error was not exposed.
|
|
164
|
+
*
|
|
165
|
+
* @param json - A payload previously produced by {@link toJSON}.
|
|
166
|
+
* @returns A reconstructed {@link NextRushError}.
|
|
167
|
+
*/
|
|
168
|
+
static fromJSON(json: Record<string, unknown>): NextRushError {
|
|
169
|
+
return NextRushError.hydrate(json, (message, options) => new NextRushError(message, options));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Shared hydration logic for {@link fromJSON} across the hierarchy.
|
|
174
|
+
* @internal
|
|
175
|
+
*/
|
|
176
|
+
protected static hydrate<T extends NextRushError>(
|
|
177
|
+
json: Record<string, unknown>,
|
|
178
|
+
build: (message: string, options: HydrateOptions) => T
|
|
179
|
+
): T {
|
|
180
|
+
const status = typeof json.status === 'number' ? json.status : 500;
|
|
181
|
+
const message = typeof json.message === 'string' ? json.message : getHttpStatusMessage(status);
|
|
182
|
+
return build(message, {
|
|
183
|
+
status,
|
|
184
|
+
code: typeof json.code === 'string' ? json.code : undefined,
|
|
185
|
+
details:
|
|
186
|
+
json.details !== null && typeof json.details === 'object'
|
|
187
|
+
? (json.details as Record<string, unknown>)
|
|
188
|
+
: undefined,
|
|
189
|
+
requestId: typeof json.requestId === 'string' ? json.requestId : undefined,
|
|
190
|
+
traceId: typeof json.traceId === 'string' ? json.traceId : undefined,
|
|
191
|
+
timestamp: typeof json.timestamp === 'string' ? json.timestamp : undefined,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
77
195
|
/**
|
|
78
196
|
* Create a response-safe version of the error
|
|
79
197
|
*/
|
|
@@ -149,14 +267,41 @@ export class HttpError extends NextRushError {
|
|
|
149
267
|
expose?: boolean;
|
|
150
268
|
details?: Record<string, unknown>;
|
|
151
269
|
cause?: unknown;
|
|
270
|
+
requestId?: string;
|
|
271
|
+
traceId?: string;
|
|
272
|
+
timestamp?: string;
|
|
152
273
|
} = {}
|
|
153
274
|
) {
|
|
154
275
|
super(message ?? getHttpStatusMessage(status), {
|
|
155
276
|
status,
|
|
156
|
-
code: options.code ??
|
|
277
|
+
code: options.code ?? codeForStatus(status),
|
|
157
278
|
expose: options.expose ?? status < 500,
|
|
158
279
|
details: options.details,
|
|
159
280
|
cause: options.cause,
|
|
281
|
+
requestId: options.requestId,
|
|
282
|
+
traceId: options.traceId,
|
|
283
|
+
timestamp: options.timestamp,
|
|
160
284
|
});
|
|
161
285
|
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reconstruct an {@link HttpError} from a serialized {@link NextRushError.toJSON}
|
|
289
|
+
* payload (audit E-7).
|
|
290
|
+
*
|
|
291
|
+
* @param json - A payload previously produced by `toJSON()`.
|
|
292
|
+
* @returns A reconstructed {@link HttpError}.
|
|
293
|
+
*/
|
|
294
|
+
static override fromJSON(json: Record<string, unknown>): HttpError {
|
|
295
|
+
return NextRushError.hydrate(
|
|
296
|
+
json,
|
|
297
|
+
(message, options) =>
|
|
298
|
+
new HttpError(options.status ?? 500, message, {
|
|
299
|
+
code: options.code,
|
|
300
|
+
details: options.details,
|
|
301
|
+
requestId: options.requestId,
|
|
302
|
+
traceId: options.traceId,
|
|
303
|
+
timestamp: options.timestamp,
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
}
|
|
162
307
|
}
|
package/src/codes.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/errors - Central Error Code Registry
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth mapping HTTP status codes to their canonical,
|
|
5
|
+
* machine-readable error codes (audit E-4). Both the typed error classes and
|
|
6
|
+
* the {@link createError} factory resolve codes through this registry, so a
|
|
7
|
+
* given status maps to exactly one code regardless of construction path.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Canonical error code for each supported HTTP status.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* Keep this in sync with the typed classes in `http-errors.ts`. The
|
|
17
|
+
* `audit-fixes` test suite asserts `createError(status).code === ERROR_CODES[status]`
|
|
18
|
+
* for every entry, so drift between a class and this registry fails CI.
|
|
19
|
+
*/
|
|
20
|
+
export const ERROR_CODES: Readonly<Record<number, string>> = Object.freeze({
|
|
21
|
+
400: 'BAD_REQUEST',
|
|
22
|
+
401: 'UNAUTHORIZED',
|
|
23
|
+
402: 'PAYMENT_REQUIRED',
|
|
24
|
+
403: 'FORBIDDEN',
|
|
25
|
+
404: 'NOT_FOUND',
|
|
26
|
+
405: 'METHOD_NOT_ALLOWED',
|
|
27
|
+
406: 'NOT_ACCEPTABLE',
|
|
28
|
+
407: 'PROXY_AUTH_REQUIRED',
|
|
29
|
+
408: 'REQUEST_TIMEOUT',
|
|
30
|
+
409: 'CONFLICT',
|
|
31
|
+
410: 'GONE',
|
|
32
|
+
411: 'LENGTH_REQUIRED',
|
|
33
|
+
412: 'PRECONDITION_FAILED',
|
|
34
|
+
413: 'PAYLOAD_TOO_LARGE',
|
|
35
|
+
414: 'URI_TOO_LONG',
|
|
36
|
+
415: 'UNSUPPORTED_MEDIA_TYPE',
|
|
37
|
+
416: 'RANGE_NOT_SATISFIABLE',
|
|
38
|
+
417: 'EXPECTATION_FAILED',
|
|
39
|
+
418: 'IM_A_TEAPOT',
|
|
40
|
+
422: 'UNPROCESSABLE_ENTITY',
|
|
41
|
+
423: 'LOCKED',
|
|
42
|
+
424: 'FAILED_DEPENDENCY',
|
|
43
|
+
425: 'TOO_EARLY',
|
|
44
|
+
426: 'UPGRADE_REQUIRED',
|
|
45
|
+
428: 'PRECONDITION_REQUIRED',
|
|
46
|
+
429: 'TOO_MANY_REQUESTS',
|
|
47
|
+
431: 'REQUEST_HEADER_FIELDS_TOO_LARGE',
|
|
48
|
+
451: 'UNAVAILABLE_FOR_LEGAL_REASONS',
|
|
49
|
+
500: 'INTERNAL_SERVER_ERROR',
|
|
50
|
+
501: 'NOT_IMPLEMENTED',
|
|
51
|
+
502: 'BAD_GATEWAY',
|
|
52
|
+
503: 'SERVICE_UNAVAILABLE',
|
|
53
|
+
504: 'GATEWAY_TIMEOUT',
|
|
54
|
+
505: 'HTTP_VERSION_NOT_SUPPORTED',
|
|
55
|
+
506: 'VARIANT_ALSO_NEGOTIATES',
|
|
56
|
+
507: 'INSUFFICIENT_STORAGE',
|
|
57
|
+
508: 'LOOP_DETECTED',
|
|
58
|
+
510: 'NOT_EXTENDED',
|
|
59
|
+
511: 'NETWORK_AUTH_REQUIRED',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the canonical error code for an HTTP status.
|
|
64
|
+
*
|
|
65
|
+
* @param status - HTTP status code.
|
|
66
|
+
* @returns The registered canonical code, or `HTTP_<status>` for statuses with
|
|
67
|
+
* no dedicated class.
|
|
68
|
+
*/
|
|
69
|
+
export function codeForStatus(status: number): string {
|
|
70
|
+
return ERROR_CODES[status] ?? `HTTP_${status}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Generic internal-error code used when no status-specific code applies. */
|
|
74
|
+
export const GENERIC_ERROR_CODE = 'INTERNAL_ERROR';
|
|
75
|
+
|
|
76
|
+
/** Canonical code for validation failures. */
|
|
77
|
+
export const VALIDATION_ERROR_CODE = 'VALIDATION_ERROR';
|