@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
package/src/factory.ts
CHANGED
|
@@ -11,35 +11,92 @@ import {
|
|
|
11
11
|
BadGatewayError,
|
|
12
12
|
BadRequestError,
|
|
13
13
|
ConflictError,
|
|
14
|
+
ExpectationFailedError,
|
|
15
|
+
FailedDependencyError,
|
|
14
16
|
ForbiddenError,
|
|
15
17
|
GatewayTimeoutError,
|
|
18
|
+
GoneError,
|
|
19
|
+
HttpVersionNotSupportedError,
|
|
20
|
+
ImATeapotError,
|
|
21
|
+
InsufficientStorageError,
|
|
16
22
|
InternalServerError,
|
|
23
|
+
LengthRequiredError,
|
|
24
|
+
LockedError,
|
|
25
|
+
LoopDetectedError,
|
|
17
26
|
MethodNotAllowedError,
|
|
27
|
+
NetworkAuthRequiredError,
|
|
28
|
+
NotAcceptableError,
|
|
29
|
+
NotExtendedError,
|
|
18
30
|
NotFoundError,
|
|
19
31
|
NotImplementedError,
|
|
32
|
+
PayloadTooLargeError,
|
|
33
|
+
PaymentRequiredError,
|
|
34
|
+
PreconditionFailedError,
|
|
35
|
+
PreconditionRequiredError,
|
|
36
|
+
ProxyAuthRequiredError,
|
|
37
|
+
RangeNotSatisfiableError,
|
|
38
|
+
RequestHeaderFieldsTooLargeError,
|
|
39
|
+
RequestTimeoutError,
|
|
20
40
|
ServiceUnavailableError,
|
|
41
|
+
TooEarlyError,
|
|
21
42
|
TooManyRequestsError,
|
|
22
43
|
UnauthorizedError,
|
|
44
|
+
UnavailableForLegalReasonsError,
|
|
23
45
|
UnprocessableEntityError,
|
|
46
|
+
UnsupportedMediaTypeError,
|
|
47
|
+
UpgradeRequiredError,
|
|
48
|
+
UriTooLongError,
|
|
49
|
+
VariantAlsoNegotiatesError,
|
|
24
50
|
type HttpErrorOptions,
|
|
25
51
|
} from './http-errors';
|
|
26
52
|
|
|
27
53
|
/**
|
|
28
|
-
* HTTP status code to error class mapping
|
|
54
|
+
* HTTP status code to error class mapping.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* Covers every status that has a dedicated typed class so {@link createError}
|
|
58
|
+
* returns the correctly-coded instance (audit E-3). `405` is handled separately
|
|
59
|
+
* because {@link MethodNotAllowedError} has a divergent constructor signature.
|
|
29
60
|
*/
|
|
30
61
|
const ERROR_MAP: Record<number, new (message?: string, options?: HttpErrorOptions) => HttpError> = {
|
|
31
62
|
400: BadRequestError,
|
|
32
63
|
401: UnauthorizedError,
|
|
64
|
+
402: PaymentRequiredError,
|
|
33
65
|
403: ForbiddenError,
|
|
34
66
|
404: NotFoundError,
|
|
67
|
+
406: NotAcceptableError,
|
|
68
|
+
407: ProxyAuthRequiredError,
|
|
69
|
+
408: RequestTimeoutError,
|
|
35
70
|
409: ConflictError,
|
|
71
|
+
410: GoneError,
|
|
72
|
+
411: LengthRequiredError,
|
|
73
|
+
412: PreconditionFailedError,
|
|
74
|
+
413: PayloadTooLargeError,
|
|
75
|
+
414: UriTooLongError,
|
|
76
|
+
415: UnsupportedMediaTypeError,
|
|
77
|
+
416: RangeNotSatisfiableError,
|
|
78
|
+
417: ExpectationFailedError,
|
|
79
|
+
418: ImATeapotError,
|
|
36
80
|
422: UnprocessableEntityError,
|
|
81
|
+
423: LockedError,
|
|
82
|
+
424: FailedDependencyError,
|
|
83
|
+
425: TooEarlyError,
|
|
84
|
+
426: UpgradeRequiredError,
|
|
85
|
+
428: PreconditionRequiredError,
|
|
37
86
|
429: TooManyRequestsError,
|
|
87
|
+
431: RequestHeaderFieldsTooLargeError,
|
|
88
|
+
451: UnavailableForLegalReasonsError,
|
|
38
89
|
500: InternalServerError,
|
|
39
90
|
501: NotImplementedError,
|
|
40
91
|
502: BadGatewayError,
|
|
41
92
|
503: ServiceUnavailableError,
|
|
42
93
|
504: GatewayTimeoutError,
|
|
94
|
+
505: HttpVersionNotSupportedError,
|
|
95
|
+
506: VariantAlsoNegotiatesError,
|
|
96
|
+
507: InsufficientStorageError,
|
|
97
|
+
508: LoopDetectedError,
|
|
98
|
+
510: NotExtendedError,
|
|
99
|
+
511: NetworkAuthRequiredError,
|
|
43
100
|
};
|
|
44
101
|
|
|
45
102
|
/**
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/errors - Header Validation Error
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { NextRushError } from './base';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thrown when a header field name or value fails RFC 9110 grammar
|
|
11
|
+
* validation (field-name token grammar, field-value grammar — no control
|
|
12
|
+
* characters, no leading/trailing whitespace, no obs-fold).
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* A rejected write here means the application (or a framework internal) is
|
|
16
|
+
* constructing an invalid header, not that a client sent bad input — so this
|
|
17
|
+
* is a 500-class programming error, not a validation-issue-list shape like
|
|
18
|
+
* {@link ValidationError}.
|
|
19
|
+
*/
|
|
20
|
+
export class HeaderValidationError extends NextRushError {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message, { status: 500, code: 'HEADER_VALIDATION_ERROR', expose: false });
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/http-errors.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface HttpErrorOptions {
|
|
|
16
16
|
expose?: boolean;
|
|
17
17
|
details?: Record<string, unknown>;
|
|
18
18
|
cause?: unknown;
|
|
19
|
+
/** Correlation/request identifier (audit E-5). */
|
|
20
|
+
requestId?: string;
|
|
21
|
+
/** Distributed-trace identifier (audit E-5). */
|
|
22
|
+
traceId?: string;
|
|
23
|
+
/** ISO-8601 creation timestamp (audit E-5). */
|
|
24
|
+
timestamp?: string;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
// =============================================================================
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// Base classes
|
|
10
10
|
export { HttpError, NextRushError, getHttpStatusMessage } from './base';
|
|
11
11
|
|
|
12
|
+
// Header validation
|
|
13
|
+
export { HeaderValidationError } from './header-validation';
|
|
14
|
+
|
|
15
|
+
// Central error-code registry
|
|
16
|
+
export { ERROR_CODES, GENERIC_ERROR_CODE, VALIDATION_ERROR_CODE, codeForStatus } from './codes';
|
|
17
|
+
|
|
12
18
|
// HTTP errors - 4xx
|
|
13
19
|
export {
|
|
14
20
|
BadRequestError,
|
|
@@ -91,11 +97,4 @@ export {
|
|
|
91
97
|
} from './factory';
|
|
92
98
|
|
|
93
99
|
// Middleware
|
|
94
|
-
export {
|
|
95
|
-
catchAsync,
|
|
96
|
-
errorHandler,
|
|
97
|
-
notFoundHandler,
|
|
98
|
-
type ErrorContext,
|
|
99
|
-
type ErrorHandlerOptions,
|
|
100
|
-
type ErrorMiddleware,
|
|
101
|
-
} from './middleware';
|
|
100
|
+
export { errorHandler, notFoundHandler, type ErrorHandlerOptions } from './middleware';
|
package/src/middleware.ts
CHANGED
|
@@ -7,29 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Context, Middleware, Next } from '@nextrush/types';
|
|
10
|
+
import { SECURITY_AUDIT, type SecurityAuditVerdict } from '@nextrush/types';
|
|
10
11
|
import { HttpError, NextRushError, getHttpStatusMessage } from './base';
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
* Minimal context interface for error handling.
|
|
14
|
-
*
|
|
15
|
-
* @deprecated Use `Context` from `@nextrush/types` instead.
|
|
16
|
-
* Kept for backward compatibility — will be removed in v4.
|
|
17
|
-
*/
|
|
18
|
-
export interface ErrorContext {
|
|
19
|
-
method: string;
|
|
20
|
-
path: string;
|
|
21
|
-
status: number;
|
|
22
|
-
json: (data: unknown) => void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Error handler middleware function type.
|
|
27
|
-
*
|
|
28
|
-
* @deprecated Use `Middleware` from `@nextrush/types` instead.
|
|
29
|
-
* Kept for backward compatibility — will be removed in v4.
|
|
30
|
-
*/
|
|
31
|
-
export type ErrorMiddleware = Middleware;
|
|
32
|
-
|
|
33
13
|
/**
|
|
34
14
|
* Error handler options
|
|
35
15
|
*/
|
|
@@ -37,6 +17,21 @@ export interface ErrorHandlerOptions {
|
|
|
37
17
|
/** Include stack trace in development */
|
|
38
18
|
includeStack?: boolean;
|
|
39
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Whether the application is running in production (SEC-14).
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* `@nextrush/errors` has no access to `app.isProduction` on its own — the
|
|
25
|
+
* caller threads it through explicitly, mirroring `@nextrush/core`'s
|
|
26
|
+
* `writeDefaultErrorResponse(opts.isProduction)`. When `true`, a truthy
|
|
27
|
+
* `includeStack` is ignored (fail closed) and a single warning is logged
|
|
28
|
+
* once per process, rather than once per request. Defaults to `false` so
|
|
29
|
+
* existing callers that don't pass this option keep today's behavior.
|
|
30
|
+
*
|
|
31
|
+
* @default false
|
|
32
|
+
*/
|
|
33
|
+
isProduction?: boolean;
|
|
34
|
+
|
|
40
35
|
/** Custom error logger */
|
|
41
36
|
logger?: (error: Error, ctx: Context) => void;
|
|
42
37
|
|
|
@@ -75,9 +70,14 @@ function defaultLogger(error: Error, ctx: Context): void {
|
|
|
75
70
|
* ```
|
|
76
71
|
*/
|
|
77
72
|
export function errorHandler(options: ErrorHandlerOptions = {}): Middleware {
|
|
78
|
-
const { includeStack = false, logger = defaultLogger, transform, handlers } = options;
|
|
73
|
+
const { includeStack = false, isProduction = false, logger = defaultLogger, transform, handlers } = options;
|
|
79
74
|
|
|
80
|
-
|
|
75
|
+
// SEC-14: warn once per process, not once per request — a per-request
|
|
76
|
+
// warning on a hot error path floods logs for no added value once the
|
|
77
|
+
// misconfiguration is known.
|
|
78
|
+
let warnedIncludeStackInProduction = false;
|
|
79
|
+
|
|
80
|
+
const handler: Middleware = async (ctx: Context, next: Next): Promise<void> => {
|
|
81
81
|
try {
|
|
82
82
|
await next();
|
|
83
83
|
} catch (error) {
|
|
@@ -116,6 +116,12 @@ export function errorHandler(options: ErrorHandlerOptions = {}): Middleware {
|
|
|
116
116
|
|
|
117
117
|
if (transform) {
|
|
118
118
|
body = transform(err, ctx);
|
|
119
|
+
} else if (err instanceof NextRushError) {
|
|
120
|
+
// Delegate to the error's own toJSON() — this is the single source of
|
|
121
|
+
// truth for what a given error type serializes to (e.g. ValidationError
|
|
122
|
+
// adds `issues` while stripping `received`). Duplicating that shape here
|
|
123
|
+
// would silently drift out of sync with subclass overrides.
|
|
124
|
+
body = err.toJSON();
|
|
119
125
|
} else {
|
|
120
126
|
body = {
|
|
121
127
|
error: expose ? err.name : getHttpStatusMessage(status),
|
|
@@ -127,8 +133,21 @@ export function errorHandler(options: ErrorHandlerOptions = {}): Middleware {
|
|
|
127
133
|
if (expose && details) {
|
|
128
134
|
body.details = details;
|
|
129
135
|
}
|
|
136
|
+
}
|
|
130
137
|
|
|
131
|
-
|
|
138
|
+
if (includeStack && err.stack) {
|
|
139
|
+
// SEC-14: fail closed — a stack trace is ignored in production
|
|
140
|
+
// regardless of the caller's configuration, since it maps internal
|
|
141
|
+
// paths, package layout, and dependency versions for the client.
|
|
142
|
+
if (isProduction) {
|
|
143
|
+
if (!warnedIncludeStackInProduction) {
|
|
144
|
+
warnedIncludeStackInProduction = true;
|
|
145
|
+
console.warn(
|
|
146
|
+
'[@nextrush/errors] includeStack: true was ignored because isProduction is true. ' +
|
|
147
|
+
'Stack traces are never emitted in production regardless of configuration.'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
132
151
|
body.stack = err.stack.split('\n').map((line) => line.trim());
|
|
133
152
|
}
|
|
134
153
|
}
|
|
@@ -136,6 +155,34 @@ export function errorHandler(options: ErrorHandlerOptions = {}): Middleware {
|
|
|
136
155
|
ctx.json(body);
|
|
137
156
|
}
|
|
138
157
|
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Boot-time verdict for `includeStack: true` (task 8.1): the per-request
|
|
161
|
+
* guard (SEC-14, line ~140 above) only fires when `isProduction` is
|
|
162
|
+
* explicitly passed to this call — an app that never threads
|
|
163
|
+
* `app.options.env` through never gets that guard's warning even in a real
|
|
164
|
+
* production deployment. This is the gap the boot audit closes.
|
|
165
|
+
*/
|
|
166
|
+
function auditVerdict(): SecurityAuditVerdict {
|
|
167
|
+
if (includeStack && !isProduction) {
|
|
168
|
+
return {
|
|
169
|
+
level: 'warn',
|
|
170
|
+
message:
|
|
171
|
+
'errorHandler({ includeStack: true }) was constructed without isProduction — the ' +
|
|
172
|
+
'per-request guard that ignores includeStack in production never fires unless ' +
|
|
173
|
+
"isProduction is explicitly threaded through (e.g. isProduction: app.isProduction). " +
|
|
174
|
+
'Wire it, or this instance will leak stack traces in production.',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { level: 'ok' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
Object.defineProperty(handler, SECURITY_AUDIT, {
|
|
181
|
+
value: auditVerdict,
|
|
182
|
+
enumerable: false,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return handler;
|
|
139
186
|
}
|
|
140
187
|
|
|
141
188
|
/**
|
|
@@ -162,22 +209,3 @@ export function notFoundHandler(message = 'Not Found'): Middleware {
|
|
|
162
209
|
};
|
|
163
210
|
}
|
|
164
211
|
|
|
165
|
-
/**
|
|
166
|
-
* Catch async errors wrapper for route handlers
|
|
167
|
-
*
|
|
168
|
-
* @example
|
|
169
|
-
* ```typescript
|
|
170
|
-
* router.get('/users/:id', catchAsync(async (ctx) => {
|
|
171
|
-
* const user = await db.users.findById(ctx.params.id);
|
|
172
|
-
* if (!user) throw new NotFoundError('User not found');
|
|
173
|
-
* ctx.json(user);
|
|
174
|
-
* }));
|
|
175
|
-
* ```
|
|
176
|
-
*
|
|
177
|
-
* @deprecated This wrapper is redundant — async errors propagate naturally
|
|
178
|
-
* through the middleware chain and are caught by `errorHandler()`. Use the
|
|
179
|
-
* handler directly instead.
|
|
180
|
-
*/
|
|
181
|
-
export function catchAsync(handler: (ctx: Context, next: Next) => Promise<void>): Middleware {
|
|
182
|
-
return handler;
|
|
183
|
-
}
|
package/src/validation.ts
CHANGED
|
@@ -37,7 +37,8 @@ export class ValidationError extends NextRushError {
|
|
|
37
37
|
code: 'VALIDATION_ERROR',
|
|
38
38
|
expose: true,
|
|
39
39
|
});
|
|
40
|
-
|
|
40
|
+
// Freeze a snapshot so issues are immutable after construction (audit E-6).
|
|
41
|
+
this.issues = Object.freeze([...issues]) as ValidationIssue[];
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/**
|