@bymax-one/nest-core 1.1.1 → 1.2.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/CHANGELOG.md +56 -1
- package/dist/index.cjs +65 -15
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.mjs +66 -16
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,59 @@ heading here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.2.1] - 2026-08-08
|
|
15
|
+
|
|
16
|
+
A patch: the envelope fix below changes a response status for a class of client errors, without
|
|
17
|
+
touching the module's API or any option.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **An error carrying a 4xx status it marked exposable keeps that status, instead of collapsing to 500.** Express's body pipeline throws `http-errors` instances before any handler runs — a payload
|
|
22
|
+
past the limit is `PayloadTooLargeError` (413), malformed JSON is a `SyntaxError` (400), an
|
|
23
|
+
unsupported media type is 415. None is a Nest `HttpException`, so each reached the generic
|
|
24
|
+
500 collapse: a client that sent too large a body, or malformed JSON, was told the server failed,
|
|
25
|
+
and a monitor counted a 5xx for a request that never entered the application. The filter now reads
|
|
26
|
+
the `expose: true` flag and the numeric status these carry and honours it — restricted to the 4xx
|
|
27
|
+
range, because a self-reported 5xx is still a server failure whose account of itself must not
|
|
28
|
+
surface, so it stays a generic 500.
|
|
29
|
+
|
|
30
|
+
## [1.2.0] - 2026-08-08
|
|
31
|
+
|
|
32
|
+
Both entries change what a caller receives, which is why this is a minor rather than a patch: an
|
|
33
|
+
application wiring `@bymax-one/nest-auth` starts seeing that library's own error codes where it
|
|
34
|
+
previously saw one collapsed `BYMAX_BAD_REQUEST`, and a deployment with a feature disabled starts
|
|
35
|
+
answering `404` where it answered `500`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **A feature disabled on the `forRootAsync` path answers `404` instead of `500`.** Route metadata
|
|
40
|
+
is fixed before the async options resolve, so the health and metrics controllers register
|
|
41
|
+
regardless and guard at request time. That guard threw a plain `Error`, which the envelope
|
|
42
|
+
renders as `BYMAX_INTERNAL_ERROR` — so every consumer registering asynchronously with
|
|
43
|
+
`metrics: { enabled: false }`, which is the ordinary configuration and the one that keeps the
|
|
44
|
+
optional `prom-client` peer unloaded, served an unauthenticated `/metrics` that answered a
|
|
45
|
+
server error to anyone who asked. It counted as a real failure in alerting, in error budgets and
|
|
46
|
+
in any uptime check pointed at the service, describing a state nothing was wrong with.
|
|
47
|
+
|
|
48
|
+
The route now reads as absent, which is what the caller would have seen had the framework been
|
|
49
|
+
able to skip the registration. Only the feature's _absence_ is normalised: a resolved path that
|
|
50
|
+
disagrees with the route the controller was registered at is a genuine misconfiguration and
|
|
51
|
+
still throws.
|
|
52
|
+
|
|
53
|
+
- **A domain error's `details` reach the caller, and a nested `{ error: { … } }` body is read as
|
|
54
|
+
readily as a flat one.** The filter passed an explicit `code` through but dropped the structured
|
|
55
|
+
context beside it, and recognised the fields only when they sat directly on the response.
|
|
56
|
+
|
|
57
|
+
`@bymax-one/nest-auth` builds `{ error: { code, message, details } }`, so a backend wiring both
|
|
58
|
+
libraries rendered every distinct auth failure identically — a duplicate e-mail, a password below
|
|
59
|
+
the policy floor, a missing field all arrived as `BYMAX_BAD_REQUEST` / `"Auth Exception"` with no
|
|
60
|
+
details. A client could not branch on the failure, and neither could whoever was debugging it.
|
|
61
|
+
|
|
62
|
+
A nested object is followed only when it carries a string `code`, since `error` is an ordinary
|
|
63
|
+
word for a response body to use; a flat code still wins over a nested one; and a `details` value
|
|
64
|
+
that is neither an array nor an object — including the `null` `AuthException` writes to mean
|
|
65
|
+
"none" — is omitted rather than reshaped, so the field stays present only when context exists.
|
|
66
|
+
|
|
14
67
|
## [1.1.1] - 2026-08-07
|
|
15
68
|
|
|
16
69
|
**Documentation and tooling.** `dist/` differs from `1.1.0` only in the text of the comments
|
|
@@ -264,4 +317,6 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
264
317
|
[1.0.1]: https://github.com/bymaxone/nest-core/compare/v1.0.0...v1.0.1
|
|
265
318
|
[1.0.0]: https://github.com/bymaxone/nest-core/releases/tag/v1.0.0
|
|
266
319
|
[1.1.1]: https://github.com/bymaxone/nest-core/compare/v1.1.0...v1.1.1
|
|
267
|
-
[
|
|
320
|
+
[1.2.1]: https://github.com/bymaxone/nest-core/compare/v1.2.0...v1.2.1
|
|
321
|
+
[1.2.0]: https://github.com/bymaxone/nest-core/compare/v1.1.1...v1.2.0
|
|
322
|
+
[Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.2.1...HEAD
|
package/dist/index.cjs
CHANGED
|
@@ -287,12 +287,45 @@ function buildErrorEnvelope(input) {
|
|
|
287
287
|
var INTERNAL_ERROR_STATUS = 500;
|
|
288
288
|
var INTERNAL_ERROR_MESSAGE = "Internal server error";
|
|
289
289
|
var VALIDATION_FAILED_MESSAGE = "Validation failed";
|
|
290
|
-
function
|
|
291
|
-
if (typeof response !== "object" || response === null
|
|
290
|
+
function resolveErrorCarrier(response) {
|
|
291
|
+
if (typeof response !== "object" || response === null) {
|
|
292
292
|
return void 0;
|
|
293
293
|
}
|
|
294
|
-
|
|
295
|
-
|
|
294
|
+
if (hasStringCode(response)) {
|
|
295
|
+
return response;
|
|
296
|
+
}
|
|
297
|
+
const nested = response.error;
|
|
298
|
+
if (typeof nested === "object" && nested !== null && hasStringCode(nested)) {
|
|
299
|
+
return nested;
|
|
300
|
+
}
|
|
301
|
+
return void 0;
|
|
302
|
+
}
|
|
303
|
+
function hasStringCode(value) {
|
|
304
|
+
return "code" in value && typeof value.code === "string";
|
|
305
|
+
}
|
|
306
|
+
function extractExplicitCode(carrier) {
|
|
307
|
+
return carrier.code;
|
|
308
|
+
}
|
|
309
|
+
function extractExplicitDetails(carrier) {
|
|
310
|
+
const details = carrier.details;
|
|
311
|
+
return typeof details === "object" && details !== null ? details : void 0;
|
|
312
|
+
}
|
|
313
|
+
var CLIENT_ERROR_MIN2 = 400;
|
|
314
|
+
var CLIENT_ERROR_MAX2 = 500;
|
|
315
|
+
function resolveExposedClientError(exception) {
|
|
316
|
+
if (typeof exception !== "object" || exception === null) {
|
|
317
|
+
return void 0;
|
|
318
|
+
}
|
|
319
|
+
const candidate = exception;
|
|
320
|
+
if (candidate.expose !== true) {
|
|
321
|
+
return void 0;
|
|
322
|
+
}
|
|
323
|
+
const status = Number.isInteger(candidate.status) ? candidate.status : candidate.statusCode;
|
|
324
|
+
if (typeof status !== "number" || !Number.isInteger(status) || status < CLIENT_ERROR_MIN2 || status >= CLIENT_ERROR_MAX2) {
|
|
325
|
+
return void 0;
|
|
326
|
+
}
|
|
327
|
+
const message = exception instanceof Error ? exception.message : "";
|
|
328
|
+
return { status, message: message === "" ? INTERNAL_ERROR_MESSAGE : message };
|
|
296
329
|
}
|
|
297
330
|
function isValidationResponse(response) {
|
|
298
331
|
return typeof response === "object" && response !== null && "message" in response && Array.isArray(response.message);
|
|
@@ -302,12 +335,13 @@ function toValidationDetails(violations) {
|
|
|
302
335
|
(violation) => typeof violation === "string" ? { issue: violation } : violation
|
|
303
336
|
);
|
|
304
337
|
}
|
|
305
|
-
function extractHttpMessage(response, exception) {
|
|
338
|
+
function extractHttpMessage(response, exception, carrier) {
|
|
306
339
|
if (typeof response === "string") {
|
|
307
340
|
return response;
|
|
308
341
|
}
|
|
309
|
-
|
|
310
|
-
|
|
342
|
+
const source = carrier ?? response;
|
|
343
|
+
if (typeof source === "object" && source !== null && "message" in source) {
|
|
344
|
+
const message = source.message;
|
|
311
345
|
if (typeof message === "string") {
|
|
312
346
|
return message;
|
|
313
347
|
}
|
|
@@ -407,6 +441,15 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
|
|
|
407
441
|
if (exception instanceof common.HttpException) {
|
|
408
442
|
return this.mapHttpException(exception, context);
|
|
409
443
|
}
|
|
444
|
+
const exposed = resolveExposedClientError(exception);
|
|
445
|
+
if (exposed !== void 0) {
|
|
446
|
+
return this.toEnvelope(
|
|
447
|
+
exposed.status,
|
|
448
|
+
codeForStatus(exposed.status),
|
|
449
|
+
exposed.message,
|
|
450
|
+
context
|
|
451
|
+
);
|
|
452
|
+
}
|
|
410
453
|
try {
|
|
411
454
|
this.onUnexpectedError(exception, context);
|
|
412
455
|
} catch {
|
|
@@ -414,9 +457,10 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
|
|
|
414
457
|
return this.mapUnknown(exception, context);
|
|
415
458
|
}
|
|
416
459
|
/**
|
|
417
|
-
* Map an `HttpException` to the envelope.
|
|
418
|
-
*
|
|
419
|
-
*
|
|
460
|
+
* Map an `HttpException` to the envelope. A domain error passes its own code,
|
|
461
|
+
* message and details through, whether it wrote them flat on the response or
|
|
462
|
+
* nested under `error`; the validation shape becomes `BYMAX_VALIDATION_FAILED`
|
|
463
|
+
* with structured details; everything else derives its code from the status.
|
|
420
464
|
*
|
|
421
465
|
* @param exception - The HTTP exception to format.
|
|
422
466
|
* @param context - The neutral request context.
|
|
@@ -425,9 +469,15 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
|
|
|
425
469
|
mapHttpException(exception, context) {
|
|
426
470
|
const status = exception.getStatus();
|
|
427
471
|
const response = exception.getResponse();
|
|
428
|
-
const
|
|
429
|
-
if (
|
|
430
|
-
return this.toEnvelope(
|
|
472
|
+
const carrier = resolveErrorCarrier(response);
|
|
473
|
+
if (carrier !== void 0) {
|
|
474
|
+
return this.toEnvelope(
|
|
475
|
+
status,
|
|
476
|
+
extractExplicitCode(carrier),
|
|
477
|
+
extractHttpMessage(response, exception, carrier),
|
|
478
|
+
context,
|
|
479
|
+
extractExplicitDetails(carrier)
|
|
480
|
+
);
|
|
431
481
|
}
|
|
432
482
|
if (isValidationResponse(response)) {
|
|
433
483
|
return this.toEnvelope(
|
|
@@ -682,8 +732,8 @@ var PassThroughInterceptor = class {
|
|
|
682
732
|
};
|
|
683
733
|
function assertAsyncFeatureEnabled(feature, enabled) {
|
|
684
734
|
if (!enabled) {
|
|
685
|
-
throw new
|
|
686
|
-
`[BymaxCoreModule] The "${feature}"
|
|
735
|
+
throw new common.NotFoundException(
|
|
736
|
+
`[BymaxCoreModule] The "${feature}" feature is disabled, so this route does not exist.`
|
|
687
737
|
);
|
|
688
738
|
}
|
|
689
739
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -464,9 +464,10 @@ declare class BymaxExceptionFilter implements ExceptionFilter {
|
|
|
464
464
|
*/
|
|
465
465
|
private buildEnvelope;
|
|
466
466
|
/**
|
|
467
|
-
* Map an `HttpException` to the envelope.
|
|
468
|
-
*
|
|
469
|
-
*
|
|
467
|
+
* Map an `HttpException` to the envelope. A domain error passes its own code,
|
|
468
|
+
* message and details through, whether it wrote them flat on the response or
|
|
469
|
+
* nested under `error`; the validation shape becomes `BYMAX_VALIDATION_FAILED`
|
|
470
|
+
* with structured details; everything else derives its code from the status.
|
|
470
471
|
*
|
|
471
472
|
* @param exception - The HTTP exception to format.
|
|
472
473
|
* @param context - The neutral request context.
|
package/dist/index.d.ts
CHANGED
|
@@ -464,9 +464,10 @@ declare class BymaxExceptionFilter implements ExceptionFilter {
|
|
|
464
464
|
*/
|
|
465
465
|
private buildEnvelope;
|
|
466
466
|
/**
|
|
467
|
-
* Map an `HttpException` to the envelope.
|
|
468
|
-
*
|
|
469
|
-
*
|
|
467
|
+
* Map an `HttpException` to the envelope. A domain error passes its own code,
|
|
468
|
+
* message and details through, whether it wrote them flat on the response or
|
|
469
|
+
* nested under `error`; the validation shape becomes `BYMAX_VALIDATION_FAILED`
|
|
470
|
+
* with structured details; everything else derives its code from the status.
|
|
470
471
|
*
|
|
471
472
|
* @param exception - The HTTP exception to format.
|
|
472
473
|
* @param context - The neutral request context.
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, HttpStatus } from '@nestjs/common';
|
|
1
|
+
import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, HttpStatus, NotFoundException } from '@nestjs/common';
|
|
2
2
|
import { HttpAdapterHost, DiscoveryService, Reflector, BaseExceptionFilter, APP_FILTER, APP_INTERCEPTOR, DiscoveryModule } from '@nestjs/core';
|
|
3
3
|
import { tap, catchError, throwError } from 'rxjs';
|
|
4
4
|
|
|
@@ -285,12 +285,45 @@ function buildErrorEnvelope(input) {
|
|
|
285
285
|
var INTERNAL_ERROR_STATUS = 500;
|
|
286
286
|
var INTERNAL_ERROR_MESSAGE = "Internal server error";
|
|
287
287
|
var VALIDATION_FAILED_MESSAGE = "Validation failed";
|
|
288
|
-
function
|
|
289
|
-
if (typeof response !== "object" || response === null
|
|
288
|
+
function resolveErrorCarrier(response) {
|
|
289
|
+
if (typeof response !== "object" || response === null) {
|
|
290
290
|
return void 0;
|
|
291
291
|
}
|
|
292
|
-
|
|
293
|
-
|
|
292
|
+
if (hasStringCode(response)) {
|
|
293
|
+
return response;
|
|
294
|
+
}
|
|
295
|
+
const nested = response.error;
|
|
296
|
+
if (typeof nested === "object" && nested !== null && hasStringCode(nested)) {
|
|
297
|
+
return nested;
|
|
298
|
+
}
|
|
299
|
+
return void 0;
|
|
300
|
+
}
|
|
301
|
+
function hasStringCode(value) {
|
|
302
|
+
return "code" in value && typeof value.code === "string";
|
|
303
|
+
}
|
|
304
|
+
function extractExplicitCode(carrier) {
|
|
305
|
+
return carrier.code;
|
|
306
|
+
}
|
|
307
|
+
function extractExplicitDetails(carrier) {
|
|
308
|
+
const details = carrier.details;
|
|
309
|
+
return typeof details === "object" && details !== null ? details : void 0;
|
|
310
|
+
}
|
|
311
|
+
var CLIENT_ERROR_MIN2 = 400;
|
|
312
|
+
var CLIENT_ERROR_MAX2 = 500;
|
|
313
|
+
function resolveExposedClientError(exception) {
|
|
314
|
+
if (typeof exception !== "object" || exception === null) {
|
|
315
|
+
return void 0;
|
|
316
|
+
}
|
|
317
|
+
const candidate = exception;
|
|
318
|
+
if (candidate.expose !== true) {
|
|
319
|
+
return void 0;
|
|
320
|
+
}
|
|
321
|
+
const status = Number.isInteger(candidate.status) ? candidate.status : candidate.statusCode;
|
|
322
|
+
if (typeof status !== "number" || !Number.isInteger(status) || status < CLIENT_ERROR_MIN2 || status >= CLIENT_ERROR_MAX2) {
|
|
323
|
+
return void 0;
|
|
324
|
+
}
|
|
325
|
+
const message = exception instanceof Error ? exception.message : "";
|
|
326
|
+
return { status, message: message === "" ? INTERNAL_ERROR_MESSAGE : message };
|
|
294
327
|
}
|
|
295
328
|
function isValidationResponse(response) {
|
|
296
329
|
return typeof response === "object" && response !== null && "message" in response && Array.isArray(response.message);
|
|
@@ -300,12 +333,13 @@ function toValidationDetails(violations) {
|
|
|
300
333
|
(violation) => typeof violation === "string" ? { issue: violation } : violation
|
|
301
334
|
);
|
|
302
335
|
}
|
|
303
|
-
function extractHttpMessage(response, exception) {
|
|
336
|
+
function extractHttpMessage(response, exception, carrier) {
|
|
304
337
|
if (typeof response === "string") {
|
|
305
338
|
return response;
|
|
306
339
|
}
|
|
307
|
-
|
|
308
|
-
|
|
340
|
+
const source = carrier ?? response;
|
|
341
|
+
if (typeof source === "object" && source !== null && "message" in source) {
|
|
342
|
+
const message = source.message;
|
|
309
343
|
if (typeof message === "string") {
|
|
310
344
|
return message;
|
|
311
345
|
}
|
|
@@ -405,6 +439,15 @@ var BymaxExceptionFilter = class {
|
|
|
405
439
|
if (exception instanceof HttpException) {
|
|
406
440
|
return this.mapHttpException(exception, context);
|
|
407
441
|
}
|
|
442
|
+
const exposed = resolveExposedClientError(exception);
|
|
443
|
+
if (exposed !== void 0) {
|
|
444
|
+
return this.toEnvelope(
|
|
445
|
+
exposed.status,
|
|
446
|
+
codeForStatus(exposed.status),
|
|
447
|
+
exposed.message,
|
|
448
|
+
context
|
|
449
|
+
);
|
|
450
|
+
}
|
|
408
451
|
try {
|
|
409
452
|
this.onUnexpectedError(exception, context);
|
|
410
453
|
} catch {
|
|
@@ -412,9 +455,10 @@ var BymaxExceptionFilter = class {
|
|
|
412
455
|
return this.mapUnknown(exception, context);
|
|
413
456
|
}
|
|
414
457
|
/**
|
|
415
|
-
* Map an `HttpException` to the envelope.
|
|
416
|
-
*
|
|
417
|
-
*
|
|
458
|
+
* Map an `HttpException` to the envelope. A domain error passes its own code,
|
|
459
|
+
* message and details through, whether it wrote them flat on the response or
|
|
460
|
+
* nested under `error`; the validation shape becomes `BYMAX_VALIDATION_FAILED`
|
|
461
|
+
* with structured details; everything else derives its code from the status.
|
|
418
462
|
*
|
|
419
463
|
* @param exception - The HTTP exception to format.
|
|
420
464
|
* @param context - The neutral request context.
|
|
@@ -423,9 +467,15 @@ var BymaxExceptionFilter = class {
|
|
|
423
467
|
mapHttpException(exception, context) {
|
|
424
468
|
const status = exception.getStatus();
|
|
425
469
|
const response = exception.getResponse();
|
|
426
|
-
const
|
|
427
|
-
if (
|
|
428
|
-
return this.toEnvelope(
|
|
470
|
+
const carrier = resolveErrorCarrier(response);
|
|
471
|
+
if (carrier !== void 0) {
|
|
472
|
+
return this.toEnvelope(
|
|
473
|
+
status,
|
|
474
|
+
extractExplicitCode(carrier),
|
|
475
|
+
extractHttpMessage(response, exception, carrier),
|
|
476
|
+
context,
|
|
477
|
+
extractExplicitDetails(carrier)
|
|
478
|
+
);
|
|
429
479
|
}
|
|
430
480
|
if (isValidationResponse(response)) {
|
|
431
481
|
return this.toEnvelope(
|
|
@@ -680,8 +730,8 @@ var PassThroughInterceptor = class {
|
|
|
680
730
|
};
|
|
681
731
|
function assertAsyncFeatureEnabled(feature, enabled) {
|
|
682
732
|
if (!enabled) {
|
|
683
|
-
throw new
|
|
684
|
-
`[BymaxCoreModule] The "${feature}"
|
|
733
|
+
throw new NotFoundException(
|
|
734
|
+
`[BymaxCoreModule] The "${feature}" feature is disabled, so this route does not exist.`
|
|
685
735
|
);
|
|
686
736
|
}
|
|
687
737
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bymax-one/nest-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing interceptor, pagination helpers, health endpoints with indicator discovery, an optional Prometheus metrics endpoint with a contribution contract, OpenAPI documents in development, and OpenTelemetry trace correlation.",
|
|
5
5
|
"author": "Bymax One <support@bymax.one>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -86,8 +86,8 @@
|
|
|
86
86
|
"lint": "eslint --no-error-on-unmatched-pattern src scripts test",
|
|
87
87
|
"lint:fix": "eslint --no-error-on-unmatched-pattern src scripts test --fix",
|
|
88
88
|
"mutation": "stryker run",
|
|
89
|
+
"mutation:full": "node -e \"require('node:fs').rmSync('reports/stryker-incremental.json',{force:true,recursive:true})\" && stryker run",
|
|
89
90
|
"mutation:dry-run": "stryker run --dryRunOnly",
|
|
90
|
-
"mutation:incremental": "stryker run --incremental",
|
|
91
91
|
"prepare": "husky",
|
|
92
92
|
"prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm check:mutants && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:published",
|
|
93
93
|
"release": "npm publish --provenance --access public",
|