@bymax-one/nest-core 1.6.6 → 1.6.7

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 CHANGED
@@ -11,6 +11,35 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.6.7] - 2026-11-12
15
+
16
+ A patch release. Two floors that every backend re-implemented move into the library; both are additive
17
+ and nothing changes for an application that does not turn them on.
18
+
19
+ **Apply to a derived backend:** `pnpm up @bymax-one/nest-core`, then replace a local body-depth
20
+ middleware with `bodyDepth: { enabled: true }` and a local helmet, parser and timeout block with
21
+ `await applyHttpFloors(app)` from `@bymax-one/nest-core/http` (add `helmet`). Call it right after
22
+ `applyAccessLog` and before the cookie parser, trusted proxies and `applyBymaxOpenApi`.
23
+
24
+ ### Added
25
+
26
+ - **`bodyDepth` refuses a request body nested deeper than a limit with a `400`.** A body of about
27
+ 2,000 nested arrays is 4 KB, and the recursion in a validator or transformer overflows the stack
28
+ on it, so the filter answered `500` for a request that was only malformed, with no credential
29
+ needed (measured with Nest's `ValidationPipe`). The new `BymaxBodyDepthMiddleware` checks
30
+ the body Nest's parsers already produced, before the application's validation, and answers
31
+ `BYMAX_BAD_REQUEST` ("Request body is nested too deeply"). **Off by default** (`enabled: false`,
32
+ `maxDepth: 32`) because turning it on rejects bodies that are accepted today. Express only; it is
33
+ not mounted on Fastify, whose middleware runs before the body is parsed.
34
+ - **`applyHttpFloors` in the new `./http` subpath.** One bootstrap call for `helmet` without HSTS
35
+ (options pass through, `helmet: false` leaves it out), JSON and urlencoded parsers that refuse a
36
+ compressed body with `415` instead of decompressing it, and the header and request timeouts
37
+ (15 s and 30 s), enforced within a second: it also sets the server's connection sweep to one
38
+ second (`connectionsCheckingIntervalMs`), since Node's default of thirty would let a 15 s timeout
39
+ cut anywhere up to 45 s; it throws when the server is already listening. `helmet` is an optional peer, loaded only when the helper runs. Express only.
40
+ - The root bundle's brotli budget moves from 17.25 to 17.75 KiB for the body-depth code; `./http`
41
+ has a budget of 1 KiB.
42
+
14
43
  ## [1.6.6] - 2026-11-07
15
44
 
16
45
  A patch release. The error envelope no longer returns the parser's own text for a request refused before
@@ -1336,4 +1365,5 @@ have regressed from. They are kept because the reasoning is worth having.
1336
1365
  [1.6.4]: https://github.com/bymaxone/nest-core/compare/v1.6.3...v1.6.4
1337
1366
  [1.6.5]: https://github.com/bymaxone/nest-core/compare/v1.6.4...v1.6.5
1338
1367
  [1.6.6]: https://github.com/bymaxone/nest-core/compare/v1.6.5...v1.6.6
1339
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.6...HEAD
1368
+ [1.6.7]: https://github.com/bymaxone/nest-core/compare/v1.6.6...v1.6.7
1369
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.7...HEAD
package/README.md CHANGED
@@ -126,6 +126,7 @@ never loads its peer, which the release gate asserts against the packed tarball.
126
126
  | `./pagination` | `normalizePageQuery`, `buildPageResult`, `normalizeCursorQuery`, `buildCursorResult`, `encodeCursor`, `decodeCursor` and their types — pure functions, no NestJS provider involved |
127
127
  | `./health` | `IHealthIndicator`, `HealthResponse`, the indicator contracts and the `@BymaxHealthIndicator()` marker, so a package that only implements an indicator does not import the module |
128
128
  | `./metrics` | `IMetricsContributor` and the `@BymaxMetricsContributor()` marker, so a package that only publishes metrics imports neither the module nor its DI tokens. The one subpath whose types name `prom-client`, which anyone implementing the contract already depends on |
129
+ | `./http` | `applyHttpFloors`, the one bootstrap call for the HTTP-level floors: security headers, compressed request bodies refused, and the header and request timeouts — separate so an application that never calls it never loads `helmet` |
129
130
  | `./openapi` | `applyBymaxOpenApi`, the one bootstrap call that builds and mounts the OpenAPI document — separate so an application that never documents its API never loads the code that does |
130
131
 
131
132
  Each subpath ships ESM and CommonJS with its own `.d.ts` and `.d.cts`, so
@@ -138,13 +139,14 @@ pnpm add @bymax-one/nest-core @nestjs/common @nestjs/core reflect-metadata rxjs
138
139
  ```
139
140
 
140
141
  Add `prom-client` if you enable the metrics feature, `@nestjs/swagger` if you
141
- enable the OpenAPI feature, and `@opentelemetry/api` if you enable trace
142
- correlation. All three are optional peer dependencies: none is required, or ever
143
- loaded, unless you turn its feature on.
142
+ enable the OpenAPI feature, `@opentelemetry/api` if you enable trace
143
+ correlation, and `helmet` if you call `applyHttpFloors`. Each is an optional peer
144
+ dependency: none is required, or ever loaded, unless you turn its feature on.
144
145
 
145
146
  ```bash
146
147
  pnpm add prom-client
147
148
  pnpm add @opentelemetry/api
149
+ pnpm add helmet
148
150
  pnpm add -D @nestjs/swagger
149
151
  ```
150
152
 
@@ -232,6 +234,28 @@ and the classification table: [Production is a closed door](#production-is-a-clo
232
234
  | `enabled` | `boolean` | `true` | Applies the request-timing middleware to every route. |
233
235
  | `slowRequestThresholdMs` | `number` | unset | Samples above this duration are flagged `slow: true`. Absent means never slow. |
234
236
 
237
+ ### `bodyDepth`
238
+
239
+ | Option | Type | Default | Description |
240
+ | ---------- | --------- | ------- | -------------------------------------------------------------------------------------------------- |
241
+ | `enabled` | `boolean` | `false` | Refuses a request body nested deeper than `maxDepth` with a `400` through the envelope. See below. |
242
+ | `maxDepth` | `number` | `32` | The deepest nesting a body may have. A flat object or array is depth 1. A positive safe integer. |
243
+
244
+ **Off by default, and worth turning on.** It is off because turning it on rejects bodies that
245
+ are accepted today, and a patch release must not start doing that. Turn it on: a body of about
246
+ 2,000 nested arrays is only 4 KB, and the recursion in a validator or transformer
247
+ (`class-validator`, `class-transformer`) overflows the stack on it. The filter then answers
248
+ `500` for a request that was only malformed — measured with Nest's `ValidationPipe` on an
249
+ application route, needing no credential, so it is also a way to make a healthy service look
250
+ broken to a monitor. This package ships no validation of its own, so the limit sits in front of
251
+ yours: the check runs on the body Nest's parsers already produced and answers
252
+ `400 BYMAX_BAD_REQUEST` ("Request body is nested too deeply") before routing, so it protects
253
+ every route, this package's own included, before any pipe runs.
254
+
255
+ It covers the Express adapter. It does nothing when the application disables Nest's body
256
+ parsers, or parses bodies after module middleware has run, and it is not mounted on Fastify,
257
+ whose middleware runs before the body is parsed.
258
+
235
259
  ### `health`
236
260
 
237
261
  | Option | Type | Default | Description |
@@ -1681,6 +1705,55 @@ same `@Global()`-module pattern is how every pluggable token in this package
1681
1705
  is overridden: `BYMAX_TIMING_SINK` and `BYMAX_HEALTH_INDICATORS` follow it
1682
1706
  identically.
1683
1707
 
1708
+ ## 🧱 HTTP Floors
1709
+
1710
+ `applyHttpFloors` (from `@bymax-one/nest-core/http`) is the HTTP-level protection that every
1711
+ backend wants and none should tune per deployment. One call, before `app.listen()`:
1712
+
1713
+ ```typescript
1714
+ import { NestFactory } from '@nestjs/core'
1715
+ import { applyHttpFloors } from '@bymax-one/nest-core/http'
1716
+ import { AppModule } from './app.module'
1717
+
1718
+ async function bootstrap(): Promise<void> {
1719
+ const app = await NestFactory.create(AppModule)
1720
+ await applyHttpFloors(app)
1721
+ await app.listen(3000)
1722
+ }
1723
+ void bootstrap()
1724
+ ```
1725
+
1726
+ | What | Default |
1727
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1728
+ | Security headers | `helmet()` with `strictTransportSecurity: false` (TLS is normally ended in front of the app, which owns HSTS). Pass `helmet: { ... }` to tune it, for example the CSP of a browser-serving backend, or `helmet: false` to leave it out |
1729
+ | Request bodies | The JSON and urlencoded parsers refuse a compressed body (`Content-Encoding`) with `415` instead of decompressing it, on every route and before routing. Size limits and the extended form syntax keep the parsers' defaults |
1730
+ | `headersTimeoutMs` | `15000`: the longest a client may take to send its complete header block. Node's own default is 60 s, which leaves a header block sent a byte at a time open as a cheap denial of service |
1731
+ | `requestTimeoutMs` | `30000`: the longest a single request, headers and body together, may take. Node's default is 5 minutes. Must not be below `headersTimeoutMs` |
1732
+
1733
+ `keepAliveTimeout` is left at Node's 5 s: raising it only lengthens idle-socket retention.
1734
+
1735
+ Node enforces both timeouts from a sweep over the open connections, not from a timer per
1736
+ connection, and the sweep runs every 30 s by default: measured, a 1 s header timeout left a
1737
+ dribbled header block open for 30 s. The helper therefore also sets the server's
1738
+ `connectionsCheckingInterval` to one second (or the header timeout, if that is shorter), so a
1739
+ connection is cut within a second of its timeout expiring. Nest creates the server without
1740
+ exposing its options, so this is a property set before `listen()`: Node reads it when the server
1741
+ starts listening, and the helper throws if the server is already listening. The sweep walks every
1742
+ open connection each time, so a server holding very many sockets can set
1743
+ `connectionsCheckingIntervalMs` higher, at the cost of cutting up to that long after the timeout.
1744
+
1745
+ **Order is part of the contract.** Call it right after `applyAccessLog` from
1746
+ `@bymax-one/nest-logger`, and before everything that reads the request: helmet is mounted first,
1747
+ so even a request the body parsers refuse carries the headers, and the parsers are registered
1748
+ here so Nest's own defaults, which accept gzip, are not (an application that needs a raw body on a route mounts that parser before calling it). Cookie parsing, the trusted-proxy
1749
+ setting and `applyBymaxOpenApi` stay with you and go after it:
1750
+ `applyAccessLog` → `applyHttpFloors` → cookie parser → trusted proxies → `applyBymaxOpenApi`.
1751
+
1752
+ Express only; on another adapter it throws before applying anything. `helmet` is an optional
1753
+ peer, loaded only when the helper runs, and a missing one fails with the install command. An
1754
+ unusable timeout (zero, fractional, or a header timeout above the request timeout) throws
1755
+ rather than leaving a socket limit switched off.
1756
+
1684
1757
  ## 🏗️ Architecture
1685
1758
 
1686
1759
  ```
@@ -1836,6 +1909,8 @@ the deployment nobody configured is the one most likely to be exposed.
1836
1909
  | Health output | The response names which indicator is down and nothing more; the reason goes to the logger. `exposeIndicatorErrors` (default `false`) puts it back in the response for debugging |
1837
1910
  | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
1838
1911
  | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
1912
+ | Body nesting | Opt-in `bodyDepth` refuses a body nested deeper than `maxDepth` with a `400`, so a few kilobytes of nested arrays cannot overflow a recursive validator into a `500` |
1913
+ | HTTP floors | `applyHttpFloors`: compressed request bodies refused with `415`, header and request timeouts of 15 s and 30 s, `helmet` headers without HSTS |
1839
1914
  | Request accounting | Every closed request is counted, including the ones a guard rejected (`401`/`403`/`429`) and the ones that matched no route (`404`), so an attack in progress moves the graph |
1840
1915
  | Route labels | Taken from the matched template; an unmatched request records the constant `<unmatched>`, never the requested path, so a scan cannot grow the metric registry without bound |
1841
1916
  | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
@@ -1909,27 +1984,28 @@ in the sections above.
1909
1984
 
1910
1985
  ### `.` (root)
1911
1986
 
1912
- | Export | Kind | Description |
1913
- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1914
- | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
1915
- | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
1916
- | `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
1917
- | `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
1918
- | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_UNEXPECTED_ERROR_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT` | tokens | The DI tokens; see the [token table](#-di-tokens). |
1919
- | `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
1920
- | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1921
- | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
1922
- | `BymaxExceptionFilter` | class | The envelope exception filter. |
1923
- | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
1924
- | `IUnexpectedErrorSink`, `UnexpectedErrorReport` | types | Receives the cause behind each collapsed `500 BYMAX_INTERNAL_ERROR`; see [Recording the cause of a 500](#recording-the-cause-of-a-500). |
1925
- | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
1926
- | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
1927
- | `BymaxTimingMiddleware` | class | The request-timing middleware, applied to every route when timing is enabled. |
1928
- | `UNMATCHED_ROUTE` | constant | The bounded label recorded when a request matched no route (`<unmatched>`). |
1929
- | `TimingInterceptor` | class | **Deprecated, registered nowhere.** `BymaxTimingMiddleware` replaced it as the recorder because guards run before interceptors, so it never saw a request a guard rejected; wiring it by hand next to the middleware records a second sample for every request that reaches a handler. Kept so code that wired it by hand keeps compiling. |
1930
- | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
1931
- | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
1932
- | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
1987
+ | Export | Kind | Description |
1988
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1989
+ | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
1990
+ | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `BodyDepthOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
1991
+ | `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
1992
+ | `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
1993
+ | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_UNEXPECTED_ERROR_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT` | tokens | The DI tokens; see the [token table](#-di-tokens). |
1994
+ | `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
1995
+ | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1996
+ | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
1997
+ | `BymaxExceptionFilter` | class | The envelope exception filter. |
1998
+ | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
1999
+ | `IUnexpectedErrorSink`, `UnexpectedErrorReport` | types | Receives the cause behind each collapsed `500 BYMAX_INTERNAL_ERROR`; see [Recording the cause of a 500](#recording-the-cause-of-a-500). |
2000
+ | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
2001
+ | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
2002
+ | `BymaxTimingMiddleware` | class | The request-timing middleware, applied to every route when timing is enabled. |
2003
+ | `BymaxBodyDepthMiddleware` | class | Refuses a body nested deeper than `bodyDepth.maxDepth` with a `400`; applied to every route, after the timing middleware, only when `bodyDepth.enabled` is true. |
2004
+ | `UNMATCHED_ROUTE` | constant | The bounded label recorded when a request matched no route (`<unmatched>`). |
2005
+ | `TimingInterceptor` | class | **Deprecated, registered nowhere.** `BymaxTimingMiddleware` replaced it as the recorder because guards run before interceptors, so it never saw a request a guard rejected; wiring it by hand next to the middleware records a second sample for every request that reaches a handler. Kept so code that wired it by hand keeps compiling. |
2006
+ | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
2007
+ | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
2008
+ | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
1933
2009
 
1934
2010
  ### `./pagination`
1935
2011
 
@@ -1963,6 +2039,13 @@ in the sections above.
1963
2039
  | `IMetricsContributor` | type | The contract: `registerMetrics(registry)`. |
1964
2040
  | `MetricsRegistry` | type | The `prom-client` registry the scrape endpoint serves. |
1965
2041
 
2042
+ ### `./http`
2043
+
2044
+ | Export | Kind | Description |
2045
+ | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------------- |
2046
+ | `applyHttpFloors` | function | Applies the headers, the body-parser floor and the timeouts; call it before `listen()`. |
2047
+ | `HttpFloorsOptions`, `HttpFloorsHelmetOptions` | types | The `helmet`, `headersTimeoutMs` and `requestTimeoutMs` options. |
2048
+
1966
2049
  ### `./openapi`
1967
2050
 
1968
2051
  | Export | Kind | Description |
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ // src/optional-peer.ts
4
+ function isMissingModuleError(cause) {
5
+ const code = cause.code;
6
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
7
+ }
8
+
9
+ // src/http/http.loader.ts
10
+ var MISSING_PEER_MESSAGE = "applyHttpFloors needs the optional peer helmet, which is not installed. Run: pnpm add helmet";
11
+ async function loadHelmet() {
12
+ try {
13
+ const loaded = await import('helmet');
14
+ return loaded.default;
15
+ } catch (cause) {
16
+ if (isMissingModuleError(cause)) {
17
+ throw new Error(MISSING_PEER_MESSAGE, { cause });
18
+ }
19
+ throw cause;
20
+ }
21
+ }
22
+
23
+ // src/http/http-floors.ts
24
+ var DEFAULT_HEADERS_TIMEOUT_MS = 15e3;
25
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
26
+ var DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS = 1e3;
27
+ function assertPositiveInteger(name, value) {
28
+ if (!Number.isSafeInteger(value) || value < 1) {
29
+ throw new Error(`${name} must be a positive integer, received ${String(value)}`);
30
+ }
31
+ }
32
+ async function applyHttpFloors(app, options = {}) {
33
+ const headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS;
34
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
35
+ assertPositiveInteger("headersTimeoutMs", headersTimeoutMs);
36
+ assertPositiveInteger("requestTimeoutMs", requestTimeoutMs);
37
+ if (headersTimeoutMs > requestTimeoutMs) {
38
+ throw new Error(
39
+ `headersTimeoutMs (${headersTimeoutMs}) must not exceed requestTimeoutMs (${requestTimeoutMs})`
40
+ );
41
+ }
42
+ const checkingIntervalMs = options.connectionsCheckingIntervalMs ?? Math.min(DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS, headersTimeoutMs);
43
+ assertPositiveInteger("connectionsCheckingIntervalMs", checkingIntervalMs);
44
+ const express = app;
45
+ if (express.getHttpAdapter().getType?.() !== "express") {
46
+ throw new Error("applyHttpFloors supports the Express adapter only");
47
+ }
48
+ const server = express.getHttpServer();
49
+ if (server.listening) {
50
+ throw new Error("applyHttpFloors must run before listen(): the server is already listening");
51
+ }
52
+ const given = options.helmet ?? {};
53
+ if (given !== false) {
54
+ const helmet = await loadHelmet();
55
+ express.use(helmet("hsts" in given ? given : { strictTransportSecurity: false, ...given }));
56
+ }
57
+ express.useBodyParser("json", { inflate: false });
58
+ express.useBodyParser("urlencoded", { inflate: false, extended: true });
59
+ server.headersTimeout = headersTimeoutMs;
60
+ server.requestTimeout = requestTimeoutMs;
61
+ server.connectionsCheckingInterval = checkingIntervalMs;
62
+ }
63
+
64
+ exports.applyHttpFloors = applyHttpFloors;
@@ -0,0 +1,59 @@
1
+ import { INestApplication } from '@nestjs/common';
2
+
3
+ /**
4
+ * @fileoverview `applyHttpFloors`, the HTTP-level protections every backend wants and none should
5
+ * tune per deployment: security headers without HSTS, compressed request bodies refused, and
6
+ * timeouts that bound how long a client may hold a socket, enforced to within a second.
7
+ *
8
+ * Order is part of the contract. Call it right after the access log is installed
9
+ * (`applyAccessLog` from `@bymax-one/nest-logger`) and before anything that reads the request:
10
+ * helmet is mounted first so even a request the body parsers refuse carries the headers, and the
11
+ * parsers are registered here so Nest's defaults (which accept gzip) are not. Cookie parsing, the
12
+ * trusted-proxy setting and OpenAPI stay with the caller and go after this call.
13
+ *
14
+ * Express only. `helmet` is an optional peer, loaded when this runs.
15
+ * @layer Bootstrap
16
+ */
17
+
18
+ /** The `helmet()` options object; any option `helmet` accepts. */
19
+ type HttpFloorsHelmetOptions = Readonly<Record<string, unknown>>;
20
+ /** Options for {@link applyHttpFloors}. */
21
+ interface HttpFloorsOptions {
22
+ /**
23
+ * Options handed to `helmet()`, over `{ strictTransportSecurity: false }` (TLS is normally
24
+ * terminated in front of the application, which owns HSTS). A browser-serving backend tunes
25
+ * `contentSecurityPolicy` here. `false` leaves helmet out entirely.
26
+ */
27
+ readonly helmet?: HttpFloorsHelmetOptions | false;
28
+ /**
29
+ * Longest a client may take to send its complete header block before the socket is closed.
30
+ * Positive integer, at most `requestTimeoutMs`. Default: `15000`.
31
+ */
32
+ readonly headersTimeoutMs?: number;
33
+ /**
34
+ * Longest a single request may take, headers and body together. Positive integer.
35
+ * Default: `30000`.
36
+ */
37
+ readonly requestTimeoutMs?: number;
38
+ /**
39
+ * How often the server sweeps its open connections for expired timeouts. Positive integer.
40
+ * Default: `1000`, or `headersTimeoutMs` when that is shorter. Node enforces both timeouts from
41
+ * this sweep rather than from a timer per connection, and its own default is `30000`, so a
42
+ * `headersTimeoutMs` of 15 s would cut anywhere between 15 and 45 s. The sweep walks every open
43
+ * connection each time, so a server holding very many sockets may set it higher, at the cost
44
+ * of cutting up to that long after the timeout.
45
+ */
46
+ readonly connectionsCheckingIntervalMs?: number;
47
+ }
48
+ /**
49
+ * Apply the HTTP floors to an Express application. Call it before `app.init()` or `listen`.
50
+ *
51
+ * @param app - The Nest application, created on the Express adapter.
52
+ * @param options - Overrides for the helmet options and the two timeouts.
53
+ * @throws Error When a timeout is unusable, the application is not on Express, or `helmet` is
54
+ * needed and not installed. Nothing is applied in those cases.
55
+ */
56
+ declare function applyHttpFloors(app: INestApplication, options?: HttpFloorsOptions): Promise<void>;
57
+
58
+ export { applyHttpFloors };
59
+ export type { HttpFloorsHelmetOptions, HttpFloorsOptions };
@@ -0,0 +1,59 @@
1
+ import { INestApplication } from '@nestjs/common';
2
+
3
+ /**
4
+ * @fileoverview `applyHttpFloors`, the HTTP-level protections every backend wants and none should
5
+ * tune per deployment: security headers without HSTS, compressed request bodies refused, and
6
+ * timeouts that bound how long a client may hold a socket, enforced to within a second.
7
+ *
8
+ * Order is part of the contract. Call it right after the access log is installed
9
+ * (`applyAccessLog` from `@bymax-one/nest-logger`) and before anything that reads the request:
10
+ * helmet is mounted first so even a request the body parsers refuse carries the headers, and the
11
+ * parsers are registered here so Nest's defaults (which accept gzip) are not. Cookie parsing, the
12
+ * trusted-proxy setting and OpenAPI stay with the caller and go after this call.
13
+ *
14
+ * Express only. `helmet` is an optional peer, loaded when this runs.
15
+ * @layer Bootstrap
16
+ */
17
+
18
+ /** The `helmet()` options object; any option `helmet` accepts. */
19
+ type HttpFloorsHelmetOptions = Readonly<Record<string, unknown>>;
20
+ /** Options for {@link applyHttpFloors}. */
21
+ interface HttpFloorsOptions {
22
+ /**
23
+ * Options handed to `helmet()`, over `{ strictTransportSecurity: false }` (TLS is normally
24
+ * terminated in front of the application, which owns HSTS). A browser-serving backend tunes
25
+ * `contentSecurityPolicy` here. `false` leaves helmet out entirely.
26
+ */
27
+ readonly helmet?: HttpFloorsHelmetOptions | false;
28
+ /**
29
+ * Longest a client may take to send its complete header block before the socket is closed.
30
+ * Positive integer, at most `requestTimeoutMs`. Default: `15000`.
31
+ */
32
+ readonly headersTimeoutMs?: number;
33
+ /**
34
+ * Longest a single request may take, headers and body together. Positive integer.
35
+ * Default: `30000`.
36
+ */
37
+ readonly requestTimeoutMs?: number;
38
+ /**
39
+ * How often the server sweeps its open connections for expired timeouts. Positive integer.
40
+ * Default: `1000`, or `headersTimeoutMs` when that is shorter. Node enforces both timeouts from
41
+ * this sweep rather than from a timer per connection, and its own default is `30000`, so a
42
+ * `headersTimeoutMs` of 15 s would cut anywhere between 15 and 45 s. The sweep walks every open
43
+ * connection each time, so a server holding very many sockets may set it higher, at the cost
44
+ * of cutting up to that long after the timeout.
45
+ */
46
+ readonly connectionsCheckingIntervalMs?: number;
47
+ }
48
+ /**
49
+ * Apply the HTTP floors to an Express application. Call it before `app.init()` or `listen`.
50
+ *
51
+ * @param app - The Nest application, created on the Express adapter.
52
+ * @param options - Overrides for the helmet options and the two timeouts.
53
+ * @throws Error When a timeout is unusable, the application is not on Express, or `helmet` is
54
+ * needed and not installed. Nothing is applied in those cases.
55
+ */
56
+ declare function applyHttpFloors(app: INestApplication, options?: HttpFloorsOptions): Promise<void>;
57
+
58
+ export { applyHttpFloors };
59
+ export type { HttpFloorsHelmetOptions, HttpFloorsOptions };
@@ -0,0 +1,62 @@
1
+ // src/optional-peer.ts
2
+ function isMissingModuleError(cause) {
3
+ const code = cause.code;
4
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
5
+ }
6
+
7
+ // src/http/http.loader.ts
8
+ var MISSING_PEER_MESSAGE = "applyHttpFloors needs the optional peer helmet, which is not installed. Run: pnpm add helmet";
9
+ async function loadHelmet() {
10
+ try {
11
+ const loaded = await import('helmet');
12
+ return loaded.default;
13
+ } catch (cause) {
14
+ if (isMissingModuleError(cause)) {
15
+ throw new Error(MISSING_PEER_MESSAGE, { cause });
16
+ }
17
+ throw cause;
18
+ }
19
+ }
20
+
21
+ // src/http/http-floors.ts
22
+ var DEFAULT_HEADERS_TIMEOUT_MS = 15e3;
23
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
24
+ var DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS = 1e3;
25
+ function assertPositiveInteger(name, value) {
26
+ if (!Number.isSafeInteger(value) || value < 1) {
27
+ throw new Error(`${name} must be a positive integer, received ${String(value)}`);
28
+ }
29
+ }
30
+ async function applyHttpFloors(app, options = {}) {
31
+ const headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS;
32
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
33
+ assertPositiveInteger("headersTimeoutMs", headersTimeoutMs);
34
+ assertPositiveInteger("requestTimeoutMs", requestTimeoutMs);
35
+ if (headersTimeoutMs > requestTimeoutMs) {
36
+ throw new Error(
37
+ `headersTimeoutMs (${headersTimeoutMs}) must not exceed requestTimeoutMs (${requestTimeoutMs})`
38
+ );
39
+ }
40
+ const checkingIntervalMs = options.connectionsCheckingIntervalMs ?? Math.min(DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS, headersTimeoutMs);
41
+ assertPositiveInteger("connectionsCheckingIntervalMs", checkingIntervalMs);
42
+ const express = app;
43
+ if (express.getHttpAdapter().getType?.() !== "express") {
44
+ throw new Error("applyHttpFloors supports the Express adapter only");
45
+ }
46
+ const server = express.getHttpServer();
47
+ if (server.listening) {
48
+ throw new Error("applyHttpFloors must run before listen(): the server is already listening");
49
+ }
50
+ const given = options.helmet ?? {};
51
+ if (given !== false) {
52
+ const helmet = await loadHelmet();
53
+ express.use(helmet("hsts" in given ? given : { strictTransportSecurity: false, ...given }));
54
+ }
55
+ express.useBodyParser("json", { inflate: false });
56
+ express.useBodyParser("urlencoded", { inflate: false, extended: true });
57
+ server.headersTimeout = headersTimeoutMs;
58
+ server.requestTimeout = requestTimeoutMs;
59
+ server.connectionsCheckingInterval = checkingIntervalMs;
60
+ }
61
+
62
+ export { applyHttpFloors };
package/dist/index.cjs CHANGED
@@ -18,6 +18,114 @@ var __decorateClass = (decorators, target, key, kind) => {
18
18
  };
19
19
  var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
20
20
 
21
+ // src/core.tokens.ts
22
+ var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:core-options");
23
+ var BYMAX_CORRELATION_PROVIDER = /* @__PURE__ */ Symbol.for(
24
+ "@bymax-one/nest-core:correlation-provider"
25
+ );
26
+ var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:timing-sink");
27
+ var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
28
+ "@bymax-one/nest-core:health-indicators"
29
+ );
30
+ var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
31
+ "@bymax-one/nest-core:health-transition-sink"
32
+ );
33
+ var BYMAX_UNEXPECTED_ERROR_SINK = /* @__PURE__ */ Symbol.for(
34
+ "@bymax-one/nest-core:unexpected-error-sink"
35
+ );
36
+ var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
37
+ "@bymax-one/nest-core:metrics-registry"
38
+ );
39
+ var BYMAX_TRACE_CONTEXT = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:trace-context");
40
+
41
+ // src/envelope/error-codes.ts
42
+ var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
43
+ var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
44
+ var BYMAX_UNAUTHORIZED = "BYMAX_UNAUTHORIZED";
45
+ var BYMAX_FORBIDDEN = "BYMAX_FORBIDDEN";
46
+ var BYMAX_NOT_FOUND = "BYMAX_NOT_FOUND";
47
+ var BYMAX_CONFLICT = "BYMAX_CONFLICT";
48
+ var BYMAX_PAYLOAD_TOO_LARGE = "BYMAX_PAYLOAD_TOO_LARGE";
49
+ var BYMAX_UNSUPPORTED_MEDIA_TYPE = "BYMAX_UNSUPPORTED_MEDIA_TYPE";
50
+ var BYMAX_UNPROCESSABLE_ENTITY = "BYMAX_UNPROCESSABLE_ENTITY";
51
+ var BYMAX_TOO_MANY_REQUESTS = "BYMAX_TOO_MANY_REQUESTS";
52
+ var BYMAX_CLIENT_ERROR = "BYMAX_CLIENT_ERROR";
53
+ var BYMAX_INTERNAL_ERROR = "BYMAX_INTERNAL_ERROR";
54
+ var BYMAX_NOT_IMPLEMENTED = "BYMAX_NOT_IMPLEMENTED";
55
+ var BYMAX_BAD_GATEWAY = "BYMAX_BAD_GATEWAY";
56
+ var BYMAX_SERVICE_UNAVAILABLE = "BYMAX_SERVICE_UNAVAILABLE";
57
+ var BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
58
+ var STATUS_CODES = /* @__PURE__ */ new Map([
59
+ [400, BYMAX_BAD_REQUEST],
60
+ [401, BYMAX_UNAUTHORIZED],
61
+ [403, BYMAX_FORBIDDEN],
62
+ [404, BYMAX_NOT_FOUND],
63
+ [409, BYMAX_CONFLICT],
64
+ [413, BYMAX_PAYLOAD_TOO_LARGE],
65
+ [415, BYMAX_UNSUPPORTED_MEDIA_TYPE],
66
+ [422, BYMAX_UNPROCESSABLE_ENTITY],
67
+ [429, BYMAX_TOO_MANY_REQUESTS],
68
+ [500, BYMAX_INTERNAL_ERROR],
69
+ [501, BYMAX_NOT_IMPLEMENTED],
70
+ [502, BYMAX_BAD_GATEWAY],
71
+ [503, BYMAX_SERVICE_UNAVAILABLE],
72
+ [504, BYMAX_GATEWAY_TIMEOUT]
73
+ ]);
74
+ var CLIENT_ERROR_MIN = 400;
75
+ var CLIENT_ERROR_MAX = 500;
76
+ function codeForStatus(status) {
77
+ const catalogued = STATUS_CODES.get(status);
78
+ if (catalogued !== void 0) {
79
+ return catalogued;
80
+ }
81
+ if (status >= CLIENT_ERROR_MIN && status < CLIENT_ERROR_MAX) {
82
+ return BYMAX_CLIENT_ERROR;
83
+ }
84
+ return BYMAX_INTERNAL_ERROR;
85
+ }
86
+
87
+ // src/body-depth/body-depth.middleware.ts
88
+ var BODY_TOO_DEEP_MESSAGE = "Request body is nested too deeply";
89
+ function exceedsDepth(value, maxDepth) {
90
+ let level = [value];
91
+ for (let depth = 0; level.length > 0; depth += 1) {
92
+ const next = [];
93
+ for (const node of level) {
94
+ if (typeof node === "object" && node !== null) {
95
+ if (depth >= maxDepth) {
96
+ return true;
97
+ }
98
+ for (const child of Object.values(node)) {
99
+ next.push(child);
100
+ }
101
+ }
102
+ }
103
+ level = next;
104
+ }
105
+ return false;
106
+ }
107
+ exports.BymaxBodyDepthMiddleware = class BymaxBodyDepthMiddleware {
108
+ constructor(options) {
109
+ this.options = options;
110
+ }
111
+ /**
112
+ * @param req - The request, whose `body` the parser has already filled.
113
+ * @param _res - Unused.
114
+ * @param next - Continues the chain.
115
+ * @throws BadRequestException When the body is nested too deeply.
116
+ */
117
+ use(req, _res, next) {
118
+ if (exceedsDepth(req.body, this.options.bodyDepth.maxDepth)) {
119
+ throw new common.BadRequestException({ code: BYMAX_BAD_REQUEST, message: BODY_TOO_DEEP_MESSAGE });
120
+ }
121
+ next();
122
+ }
123
+ };
124
+ exports.BymaxBodyDepthMiddleware = __decorateClass([
125
+ common.Injectable(),
126
+ __decorateParam(0, common.Inject(BYMAX_CORE_OPTIONS))
127
+ ], exports.BymaxBodyDepthMiddleware);
128
+
21
129
  // src/route-defaults.ts
22
130
  var DEFAULT_HEALTH_PATH = "health";
23
131
  var DEFAULT_METRICS_PATH = "metrics";
@@ -64,6 +172,16 @@ function resolveTiming(raw) {
64
172
  const threshold = raw?.slowRequestThresholdMs;
65
173
  return threshold === void 0 ? { enabled } : { enabled, slowRequestThresholdMs: threshold };
66
174
  }
175
+ var DEFAULT_MAX_BODY_DEPTH = 32;
176
+ function resolveBodyDepth(raw) {
177
+ const maxDepth = raw?.maxDepth ?? DEFAULT_MAX_BODY_DEPTH;
178
+ if (!Number.isSafeInteger(maxDepth) || maxDepth < 1) {
179
+ throw new Error(
180
+ `bodyDepth.maxDepth must be a positive safe integer, received ${String(maxDepth)}`
181
+ );
182
+ }
183
+ return { enabled: raw?.enabled ?? false, maxDepth };
184
+ }
67
185
  function resolveHealth(raw) {
68
186
  return {
69
187
  enabled: raw?.enabled ?? true,
@@ -136,6 +254,7 @@ function normalizeCoreOptions(raw) {
136
254
  return deepFreeze({
137
255
  envelope: resolveEnvelope(raw?.envelope),
138
256
  timing: resolveTiming(raw?.timing),
257
+ bodyDepth: resolveBodyDepth(raw?.bodyDepth),
139
258
  health: resolveHealth(raw?.health),
140
259
  metrics: resolveMetrics(raw?.metrics),
141
260
  openapi: resolveOpenApi(raw?.openapi, raw?.environment),
@@ -148,26 +267,6 @@ function normalizeCoreOptions(raw) {
148
267
  }
149
268
  normalizeCoreOptions();
150
269
 
151
- // src/core.tokens.ts
152
- var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:core-options");
153
- var BYMAX_CORRELATION_PROVIDER = /* @__PURE__ */ Symbol.for(
154
- "@bymax-one/nest-core:correlation-provider"
155
- );
156
- var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:timing-sink");
157
- var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
158
- "@bymax-one/nest-core:health-indicators"
159
- );
160
- var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
161
- "@bymax-one/nest-core:health-transition-sink"
162
- );
163
- var BYMAX_UNEXPECTED_ERROR_SINK = /* @__PURE__ */ Symbol.for(
164
- "@bymax-one/nest-core:unexpected-error-sink"
165
- );
166
- var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
167
- "@bymax-one/nest-core:metrics-registry"
168
- );
169
- var BYMAX_TRACE_CONTEXT = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:trace-context");
170
-
171
270
  // src/optional-peer.ts
172
271
  function isMissingModuleError(cause) {
173
272
  const code = cause.code;
@@ -275,52 +374,6 @@ function containRejection(returned, onFailure) {
275
374
  }
276
375
  }
277
376
 
278
- // src/envelope/error-codes.ts
279
- var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
280
- var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
281
- var BYMAX_UNAUTHORIZED = "BYMAX_UNAUTHORIZED";
282
- var BYMAX_FORBIDDEN = "BYMAX_FORBIDDEN";
283
- var BYMAX_NOT_FOUND = "BYMAX_NOT_FOUND";
284
- var BYMAX_CONFLICT = "BYMAX_CONFLICT";
285
- var BYMAX_PAYLOAD_TOO_LARGE = "BYMAX_PAYLOAD_TOO_LARGE";
286
- var BYMAX_UNSUPPORTED_MEDIA_TYPE = "BYMAX_UNSUPPORTED_MEDIA_TYPE";
287
- var BYMAX_UNPROCESSABLE_ENTITY = "BYMAX_UNPROCESSABLE_ENTITY";
288
- var BYMAX_TOO_MANY_REQUESTS = "BYMAX_TOO_MANY_REQUESTS";
289
- var BYMAX_CLIENT_ERROR = "BYMAX_CLIENT_ERROR";
290
- var BYMAX_INTERNAL_ERROR = "BYMAX_INTERNAL_ERROR";
291
- var BYMAX_NOT_IMPLEMENTED = "BYMAX_NOT_IMPLEMENTED";
292
- var BYMAX_BAD_GATEWAY = "BYMAX_BAD_GATEWAY";
293
- var BYMAX_SERVICE_UNAVAILABLE = "BYMAX_SERVICE_UNAVAILABLE";
294
- var BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
295
- var STATUS_CODES = /* @__PURE__ */ new Map([
296
- [400, BYMAX_BAD_REQUEST],
297
- [401, BYMAX_UNAUTHORIZED],
298
- [403, BYMAX_FORBIDDEN],
299
- [404, BYMAX_NOT_FOUND],
300
- [409, BYMAX_CONFLICT],
301
- [413, BYMAX_PAYLOAD_TOO_LARGE],
302
- [415, BYMAX_UNSUPPORTED_MEDIA_TYPE],
303
- [422, BYMAX_UNPROCESSABLE_ENTITY],
304
- [429, BYMAX_TOO_MANY_REQUESTS],
305
- [500, BYMAX_INTERNAL_ERROR],
306
- [501, BYMAX_NOT_IMPLEMENTED],
307
- [502, BYMAX_BAD_GATEWAY],
308
- [503, BYMAX_SERVICE_UNAVAILABLE],
309
- [504, BYMAX_GATEWAY_TIMEOUT]
310
- ]);
311
- var CLIENT_ERROR_MIN = 400;
312
- var CLIENT_ERROR_MAX = 500;
313
- function codeForStatus(status) {
314
- const catalogued = STATUS_CODES.get(status);
315
- if (catalogued !== void 0) {
316
- return catalogued;
317
- }
318
- if (status >= CLIENT_ERROR_MIN && status < CLIENT_ERROR_MAX) {
319
- return BYMAX_CLIENT_ERROR;
320
- }
321
- return BYMAX_INTERNAL_ERROR;
322
- }
323
-
324
377
  // src/envelope/error-envelope.ts
325
378
  function buildErrorEnvelope(input) {
326
379
  const base = {
@@ -1697,12 +1750,14 @@ exports.BymaxCoreModule = class BymaxCoreModule extends BymaxCoreModuleBase {
1697
1750
  * @param consumer - Nest's middleware consumer.
1698
1751
  */
1699
1752
  configure(consumer) {
1700
- if (!this.resolved.timing.enabled) {
1701
- return;
1702
- }
1703
1753
  const adapter = this.adapterHost?.httpAdapter;
1704
- const onFastify = bridgeFastifyRouteMetadata(adapter);
1705
- consumer.apply(exports.BymaxTimingMiddleware).forRoutes(onFastify ? "{*splat}" : "/");
1754
+ if (this.resolved.timing.enabled) {
1755
+ const onFastify = bridgeFastifyRouteMetadata(adapter);
1756
+ consumer.apply(exports.BymaxTimingMiddleware).forRoutes(onFastify ? "{*splat}" : "/");
1757
+ }
1758
+ if (this.resolved.bodyDepth.enabled && adapter?.getType?.() !== "fastify") {
1759
+ consumer.apply(exports.BymaxBodyDepthMiddleware).forRoutes("/");
1760
+ }
1706
1761
  }
1707
1762
  /**
1708
1763
  * Register the module synchronously. Options are known now, so disabled
package/dist/index.d.cts CHANGED
@@ -22,6 +22,25 @@ interface TimingOptions {
22
22
  /** Samples above this threshold are flagged as slow. No default (unset). */
23
23
  slowRequestThresholdMs?: number;
24
24
  }
25
+ /** Request-body nesting limit, applied before the application's own validation runs. */
26
+ interface BodyDepthOptions {
27
+ /**
28
+ * Refuse a parsed request body nested deeper than {@link BodyDepthOptions.maxDepth} with a
29
+ * `400` through the error envelope. Default: `false`.
30
+ *
31
+ * Off by default because turning it on rejects bodies that are accepted today. Enable it:
32
+ * a body of a few thousand nested arrays is only a few kilobytes, and a recursive validator
33
+ * or transformer overflows the stack on it, so the request is answered `500`, with no
34
+ * credential needed. The check runs before routing, on the body the parser already produced, so it covers the Express adapter and a body parsed by
35
+ * Nest's own parsers; it does not cover Fastify.
36
+ */
37
+ enabled?: boolean;
38
+ /**
39
+ * The deepest nesting a body may have: `[[1]]` is depth 2, a flat object or array is depth 1,
40
+ * and a scalar is depth 0. Must be a positive safe integer. Default: `32`.
41
+ */
42
+ maxDepth?: number;
43
+ }
25
44
  /** Liveness and readiness endpoint configuration. */
26
45
  interface HealthOptions {
27
46
  /** Register the health controller. Default: `true`. */
@@ -267,6 +286,8 @@ interface BymaxCoreModuleOptions {
267
286
  envelope?: EnvelopeOptions;
268
287
  /** Request-timing middleware. Default: enabled. */
269
288
  timing?: TimingOptions;
289
+ /** Request-body nesting limit. Default: disabled. */
290
+ bodyDepth?: BodyDepthOptions;
270
291
  /** Liveness and readiness endpoints. Default: enabled. */
271
292
  health?: HealthOptions;
272
293
  /** Prometheus metrics endpoint. Default: disabled. */
@@ -308,6 +329,11 @@ interface ResolvedTimingOptions {
308
329
  enabled: boolean;
309
330
  slowRequestThresholdMs?: number;
310
331
  }
332
+ /** Fully-resolved body-depth options. */
333
+ interface ResolvedBodyDepthOptions {
334
+ enabled: boolean;
335
+ maxDepth: number;
336
+ }
311
337
  /** Fully-resolved health options. */
312
338
  interface ResolvedHealthOptions {
313
339
  enabled: boolean;
@@ -365,6 +391,7 @@ interface ResolvedOpenApiOptions {
365
391
  interface ResolvedCoreOptions {
366
392
  envelope: ResolvedEnvelopeOptions;
367
393
  timing: ResolvedTimingOptions;
394
+ bodyDepth: ResolvedBodyDepthOptions;
368
395
  health: ResolvedHealthOptions;
369
396
  metrics: ResolvedMetricsOptions;
370
397
  openapi: ResolvedOpenApiOptions;
@@ -1115,6 +1142,21 @@ declare class TimingInterceptor implements NestInterceptor {
1115
1142
  private recordSample;
1116
1143
  }
1117
1144
 
1145
+ /** Refuses a body nested deeper than `bodyDepth.maxDepth` with a coded `400`. */
1146
+ declare class BymaxBodyDepthMiddleware implements NestMiddleware {
1147
+ private readonly options;
1148
+ constructor(options: ResolvedCoreOptions);
1149
+ /**
1150
+ * @param req - The request, whose `body` the parser has already filled.
1151
+ * @param _res - Unused.
1152
+ * @param next - Continues the chain.
1153
+ * @throws BadRequestException When the body is nested too deeply.
1154
+ */
1155
+ use(req: {
1156
+ body?: unknown;
1157
+ }, _res: unknown, next: () => void): void;
1158
+ }
1159
+
1118
1160
  /**
1119
1161
  * @fileoverview Neutral request-info accessor for request timing. Reads the
1120
1162
  * HTTP method and the route template off a request without assuming Express or
@@ -1296,5 +1338,5 @@ declare const BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
1296
1338
  */
1297
1339
  declare function codeForStatus(status: number): string;
1298
1340
 
1299
- export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
1300
- export type { BuildErrorEnvelopeInput, BymaxCoreModuleOptions, EnvelopeOptions, ErrorDetails, ErrorEnvelope, FilterErrorContext, HealthOptions, ICorrelationIdProvider, ITimingSink, ITraceContextProvider, IUnexpectedErrorSink, MetricsOptions, OpenApiHttpMethod, OpenApiOperationIdFactory, OpenApiOperationKey, OpenApiOptions, OpenApiSecurityRequirement, OpenApiSecurityScheme, OpenApiServerDescriptor, OperationSecurityMap, RequestTimingSample, ResolvedCoreOptions, TelemetryOptions, TimingOptions, TraceContext, UnexpectedErrorReport };
1341
+ export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxBodyDepthMiddleware, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
1342
+ export type { BodyDepthOptions, BuildErrorEnvelopeInput, BymaxCoreModuleOptions, EnvelopeOptions, ErrorDetails, ErrorEnvelope, FilterErrorContext, HealthOptions, ICorrelationIdProvider, ITimingSink, ITraceContextProvider, IUnexpectedErrorSink, MetricsOptions, OpenApiHttpMethod, OpenApiOperationIdFactory, OpenApiOperationKey, OpenApiOptions, OpenApiSecurityRequirement, OpenApiSecurityScheme, OpenApiServerDescriptor, OperationSecurityMap, RequestTimingSample, ResolvedCoreOptions, TelemetryOptions, TimingOptions, TraceContext, UnexpectedErrorReport };
package/dist/index.d.ts CHANGED
@@ -22,6 +22,25 @@ interface TimingOptions {
22
22
  /** Samples above this threshold are flagged as slow. No default (unset). */
23
23
  slowRequestThresholdMs?: number;
24
24
  }
25
+ /** Request-body nesting limit, applied before the application's own validation runs. */
26
+ interface BodyDepthOptions {
27
+ /**
28
+ * Refuse a parsed request body nested deeper than {@link BodyDepthOptions.maxDepth} with a
29
+ * `400` through the error envelope. Default: `false`.
30
+ *
31
+ * Off by default because turning it on rejects bodies that are accepted today. Enable it:
32
+ * a body of a few thousand nested arrays is only a few kilobytes, and a recursive validator
33
+ * or transformer overflows the stack on it, so the request is answered `500`, with no
34
+ * credential needed. The check runs before routing, on the body the parser already produced, so it covers the Express adapter and a body parsed by
35
+ * Nest's own parsers; it does not cover Fastify.
36
+ */
37
+ enabled?: boolean;
38
+ /**
39
+ * The deepest nesting a body may have: `[[1]]` is depth 2, a flat object or array is depth 1,
40
+ * and a scalar is depth 0. Must be a positive safe integer. Default: `32`.
41
+ */
42
+ maxDepth?: number;
43
+ }
25
44
  /** Liveness and readiness endpoint configuration. */
26
45
  interface HealthOptions {
27
46
  /** Register the health controller. Default: `true`. */
@@ -267,6 +286,8 @@ interface BymaxCoreModuleOptions {
267
286
  envelope?: EnvelopeOptions;
268
287
  /** Request-timing middleware. Default: enabled. */
269
288
  timing?: TimingOptions;
289
+ /** Request-body nesting limit. Default: disabled. */
290
+ bodyDepth?: BodyDepthOptions;
270
291
  /** Liveness and readiness endpoints. Default: enabled. */
271
292
  health?: HealthOptions;
272
293
  /** Prometheus metrics endpoint. Default: disabled. */
@@ -308,6 +329,11 @@ interface ResolvedTimingOptions {
308
329
  enabled: boolean;
309
330
  slowRequestThresholdMs?: number;
310
331
  }
332
+ /** Fully-resolved body-depth options. */
333
+ interface ResolvedBodyDepthOptions {
334
+ enabled: boolean;
335
+ maxDepth: number;
336
+ }
311
337
  /** Fully-resolved health options. */
312
338
  interface ResolvedHealthOptions {
313
339
  enabled: boolean;
@@ -365,6 +391,7 @@ interface ResolvedOpenApiOptions {
365
391
  interface ResolvedCoreOptions {
366
392
  envelope: ResolvedEnvelopeOptions;
367
393
  timing: ResolvedTimingOptions;
394
+ bodyDepth: ResolvedBodyDepthOptions;
368
395
  health: ResolvedHealthOptions;
369
396
  metrics: ResolvedMetricsOptions;
370
397
  openapi: ResolvedOpenApiOptions;
@@ -1115,6 +1142,21 @@ declare class TimingInterceptor implements NestInterceptor {
1115
1142
  private recordSample;
1116
1143
  }
1117
1144
 
1145
+ /** Refuses a body nested deeper than `bodyDepth.maxDepth` with a coded `400`. */
1146
+ declare class BymaxBodyDepthMiddleware implements NestMiddleware {
1147
+ private readonly options;
1148
+ constructor(options: ResolvedCoreOptions);
1149
+ /**
1150
+ * @param req - The request, whose `body` the parser has already filled.
1151
+ * @param _res - Unused.
1152
+ * @param next - Continues the chain.
1153
+ * @throws BadRequestException When the body is nested too deeply.
1154
+ */
1155
+ use(req: {
1156
+ body?: unknown;
1157
+ }, _res: unknown, next: () => void): void;
1158
+ }
1159
+
1118
1160
  /**
1119
1161
  * @fileoverview Neutral request-info accessor for request timing. Reads the
1120
1162
  * HTTP method and the route template off a request without assuming Express or
@@ -1296,5 +1338,5 @@ declare const BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
1296
1338
  */
1297
1339
  declare function codeForStatus(status: number): string;
1298
1340
 
1299
- export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
1300
- export type { BuildErrorEnvelopeInput, BymaxCoreModuleOptions, EnvelopeOptions, ErrorDetails, ErrorEnvelope, FilterErrorContext, HealthOptions, ICorrelationIdProvider, ITimingSink, ITraceContextProvider, IUnexpectedErrorSink, MetricsOptions, OpenApiHttpMethod, OpenApiOperationIdFactory, OpenApiOperationKey, OpenApiOptions, OpenApiSecurityRequirement, OpenApiSecurityScheme, OpenApiServerDescriptor, OperationSecurityMap, RequestTimingSample, ResolvedCoreOptions, TelemetryOptions, TimingOptions, TraceContext, UnexpectedErrorReport };
1341
+ export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxBodyDepthMiddleware, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
1342
+ export type { BodyDepthOptions, BuildErrorEnvelopeInput, BymaxCoreModuleOptions, EnvelopeOptions, ErrorDetails, ErrorEnvelope, FilterErrorContext, HealthOptions, ICorrelationIdProvider, ITimingSink, ITraceContextProvider, IUnexpectedErrorSink, MetricsOptions, OpenApiHttpMethod, OpenApiOperationIdFactory, OpenApiOperationKey, OpenApiOptions, OpenApiSecurityRequirement, OpenApiSecurityScheme, OpenApiServerDescriptor, OperationSecurityMap, RequestTimingSample, ResolvedCoreOptions, TelemetryOptions, TimingOptions, TraceContext, UnexpectedErrorReport };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, Req, HttpStatus, UnauthorizedException, NotFoundException } from '@nestjs/common';
1
+ import { Injectable, Inject, Catch, Optional, ConfigurableModuleBuilder, Module, BadRequestException, HttpException, Logger, Get, Res, Controller, Req, HttpStatus, UnauthorizedException, NotFoundException } from '@nestjs/common';
2
2
  import { HttpAdapterHost, DiscoveryService, Reflector, BaseExceptionFilter, APP_FILTER, DiscoveryModule } from '@nestjs/core';
3
3
  import { createHash, timingSafeEqual } from 'crypto';
4
4
  import { AsyncResource } from 'async_hooks';
@@ -16,6 +16,114 @@ var __decorateClass = (decorators, target, key, kind) => {
16
16
  };
17
17
  var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
18
18
 
19
+ // src/core.tokens.ts
20
+ var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:core-options");
21
+ var BYMAX_CORRELATION_PROVIDER = /* @__PURE__ */ Symbol.for(
22
+ "@bymax-one/nest-core:correlation-provider"
23
+ );
24
+ var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:timing-sink");
25
+ var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
26
+ "@bymax-one/nest-core:health-indicators"
27
+ );
28
+ var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
29
+ "@bymax-one/nest-core:health-transition-sink"
30
+ );
31
+ var BYMAX_UNEXPECTED_ERROR_SINK = /* @__PURE__ */ Symbol.for(
32
+ "@bymax-one/nest-core:unexpected-error-sink"
33
+ );
34
+ var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
35
+ "@bymax-one/nest-core:metrics-registry"
36
+ );
37
+ var BYMAX_TRACE_CONTEXT = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:trace-context");
38
+
39
+ // src/envelope/error-codes.ts
40
+ var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
41
+ var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
42
+ var BYMAX_UNAUTHORIZED = "BYMAX_UNAUTHORIZED";
43
+ var BYMAX_FORBIDDEN = "BYMAX_FORBIDDEN";
44
+ var BYMAX_NOT_FOUND = "BYMAX_NOT_FOUND";
45
+ var BYMAX_CONFLICT = "BYMAX_CONFLICT";
46
+ var BYMAX_PAYLOAD_TOO_LARGE = "BYMAX_PAYLOAD_TOO_LARGE";
47
+ var BYMAX_UNSUPPORTED_MEDIA_TYPE = "BYMAX_UNSUPPORTED_MEDIA_TYPE";
48
+ var BYMAX_UNPROCESSABLE_ENTITY = "BYMAX_UNPROCESSABLE_ENTITY";
49
+ var BYMAX_TOO_MANY_REQUESTS = "BYMAX_TOO_MANY_REQUESTS";
50
+ var BYMAX_CLIENT_ERROR = "BYMAX_CLIENT_ERROR";
51
+ var BYMAX_INTERNAL_ERROR = "BYMAX_INTERNAL_ERROR";
52
+ var BYMAX_NOT_IMPLEMENTED = "BYMAX_NOT_IMPLEMENTED";
53
+ var BYMAX_BAD_GATEWAY = "BYMAX_BAD_GATEWAY";
54
+ var BYMAX_SERVICE_UNAVAILABLE = "BYMAX_SERVICE_UNAVAILABLE";
55
+ var BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
56
+ var STATUS_CODES = /* @__PURE__ */ new Map([
57
+ [400, BYMAX_BAD_REQUEST],
58
+ [401, BYMAX_UNAUTHORIZED],
59
+ [403, BYMAX_FORBIDDEN],
60
+ [404, BYMAX_NOT_FOUND],
61
+ [409, BYMAX_CONFLICT],
62
+ [413, BYMAX_PAYLOAD_TOO_LARGE],
63
+ [415, BYMAX_UNSUPPORTED_MEDIA_TYPE],
64
+ [422, BYMAX_UNPROCESSABLE_ENTITY],
65
+ [429, BYMAX_TOO_MANY_REQUESTS],
66
+ [500, BYMAX_INTERNAL_ERROR],
67
+ [501, BYMAX_NOT_IMPLEMENTED],
68
+ [502, BYMAX_BAD_GATEWAY],
69
+ [503, BYMAX_SERVICE_UNAVAILABLE],
70
+ [504, BYMAX_GATEWAY_TIMEOUT]
71
+ ]);
72
+ var CLIENT_ERROR_MIN = 400;
73
+ var CLIENT_ERROR_MAX = 500;
74
+ function codeForStatus(status) {
75
+ const catalogued = STATUS_CODES.get(status);
76
+ if (catalogued !== void 0) {
77
+ return catalogued;
78
+ }
79
+ if (status >= CLIENT_ERROR_MIN && status < CLIENT_ERROR_MAX) {
80
+ return BYMAX_CLIENT_ERROR;
81
+ }
82
+ return BYMAX_INTERNAL_ERROR;
83
+ }
84
+
85
+ // src/body-depth/body-depth.middleware.ts
86
+ var BODY_TOO_DEEP_MESSAGE = "Request body is nested too deeply";
87
+ function exceedsDepth(value, maxDepth) {
88
+ let level = [value];
89
+ for (let depth = 0; level.length > 0; depth += 1) {
90
+ const next = [];
91
+ for (const node of level) {
92
+ if (typeof node === "object" && node !== null) {
93
+ if (depth >= maxDepth) {
94
+ return true;
95
+ }
96
+ for (const child of Object.values(node)) {
97
+ next.push(child);
98
+ }
99
+ }
100
+ }
101
+ level = next;
102
+ }
103
+ return false;
104
+ }
105
+ var BymaxBodyDepthMiddleware = class {
106
+ constructor(options) {
107
+ this.options = options;
108
+ }
109
+ /**
110
+ * @param req - The request, whose `body` the parser has already filled.
111
+ * @param _res - Unused.
112
+ * @param next - Continues the chain.
113
+ * @throws BadRequestException When the body is nested too deeply.
114
+ */
115
+ use(req, _res, next) {
116
+ if (exceedsDepth(req.body, this.options.bodyDepth.maxDepth)) {
117
+ throw new BadRequestException({ code: BYMAX_BAD_REQUEST, message: BODY_TOO_DEEP_MESSAGE });
118
+ }
119
+ next();
120
+ }
121
+ };
122
+ BymaxBodyDepthMiddleware = __decorateClass([
123
+ Injectable(),
124
+ __decorateParam(0, Inject(BYMAX_CORE_OPTIONS))
125
+ ], BymaxBodyDepthMiddleware);
126
+
19
127
  // src/route-defaults.ts
20
128
  var DEFAULT_HEALTH_PATH = "health";
21
129
  var DEFAULT_METRICS_PATH = "metrics";
@@ -62,6 +170,16 @@ function resolveTiming(raw) {
62
170
  const threshold = raw?.slowRequestThresholdMs;
63
171
  return threshold === void 0 ? { enabled } : { enabled, slowRequestThresholdMs: threshold };
64
172
  }
173
+ var DEFAULT_MAX_BODY_DEPTH = 32;
174
+ function resolveBodyDepth(raw) {
175
+ const maxDepth = raw?.maxDepth ?? DEFAULT_MAX_BODY_DEPTH;
176
+ if (!Number.isSafeInteger(maxDepth) || maxDepth < 1) {
177
+ throw new Error(
178
+ `bodyDepth.maxDepth must be a positive safe integer, received ${String(maxDepth)}`
179
+ );
180
+ }
181
+ return { enabled: raw?.enabled ?? false, maxDepth };
182
+ }
65
183
  function resolveHealth(raw) {
66
184
  return {
67
185
  enabled: raw?.enabled ?? true,
@@ -134,6 +252,7 @@ function normalizeCoreOptions(raw) {
134
252
  return deepFreeze({
135
253
  envelope: resolveEnvelope(raw?.envelope),
136
254
  timing: resolveTiming(raw?.timing),
255
+ bodyDepth: resolveBodyDepth(raw?.bodyDepth),
137
256
  health: resolveHealth(raw?.health),
138
257
  metrics: resolveMetrics(raw?.metrics),
139
258
  openapi: resolveOpenApi(raw?.openapi, raw?.environment),
@@ -146,26 +265,6 @@ function normalizeCoreOptions(raw) {
146
265
  }
147
266
  normalizeCoreOptions();
148
267
 
149
- // src/core.tokens.ts
150
- var BYMAX_CORE_OPTIONS = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:core-options");
151
- var BYMAX_CORRELATION_PROVIDER = /* @__PURE__ */ Symbol.for(
152
- "@bymax-one/nest-core:correlation-provider"
153
- );
154
- var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:timing-sink");
155
- var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
156
- "@bymax-one/nest-core:health-indicators"
157
- );
158
- var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
159
- "@bymax-one/nest-core:health-transition-sink"
160
- );
161
- var BYMAX_UNEXPECTED_ERROR_SINK = /* @__PURE__ */ Symbol.for(
162
- "@bymax-one/nest-core:unexpected-error-sink"
163
- );
164
- var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
165
- "@bymax-one/nest-core:metrics-registry"
166
- );
167
- var BYMAX_TRACE_CONTEXT = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:trace-context");
168
-
169
268
  // src/optional-peer.ts
170
269
  function isMissingModuleError(cause) {
171
270
  const code = cause.code;
@@ -273,52 +372,6 @@ function containRejection(returned, onFailure) {
273
372
  }
274
373
  }
275
374
 
276
- // src/envelope/error-codes.ts
277
- var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
278
- var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
279
- var BYMAX_UNAUTHORIZED = "BYMAX_UNAUTHORIZED";
280
- var BYMAX_FORBIDDEN = "BYMAX_FORBIDDEN";
281
- var BYMAX_NOT_FOUND = "BYMAX_NOT_FOUND";
282
- var BYMAX_CONFLICT = "BYMAX_CONFLICT";
283
- var BYMAX_PAYLOAD_TOO_LARGE = "BYMAX_PAYLOAD_TOO_LARGE";
284
- var BYMAX_UNSUPPORTED_MEDIA_TYPE = "BYMAX_UNSUPPORTED_MEDIA_TYPE";
285
- var BYMAX_UNPROCESSABLE_ENTITY = "BYMAX_UNPROCESSABLE_ENTITY";
286
- var BYMAX_TOO_MANY_REQUESTS = "BYMAX_TOO_MANY_REQUESTS";
287
- var BYMAX_CLIENT_ERROR = "BYMAX_CLIENT_ERROR";
288
- var BYMAX_INTERNAL_ERROR = "BYMAX_INTERNAL_ERROR";
289
- var BYMAX_NOT_IMPLEMENTED = "BYMAX_NOT_IMPLEMENTED";
290
- var BYMAX_BAD_GATEWAY = "BYMAX_BAD_GATEWAY";
291
- var BYMAX_SERVICE_UNAVAILABLE = "BYMAX_SERVICE_UNAVAILABLE";
292
- var BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
293
- var STATUS_CODES = /* @__PURE__ */ new Map([
294
- [400, BYMAX_BAD_REQUEST],
295
- [401, BYMAX_UNAUTHORIZED],
296
- [403, BYMAX_FORBIDDEN],
297
- [404, BYMAX_NOT_FOUND],
298
- [409, BYMAX_CONFLICT],
299
- [413, BYMAX_PAYLOAD_TOO_LARGE],
300
- [415, BYMAX_UNSUPPORTED_MEDIA_TYPE],
301
- [422, BYMAX_UNPROCESSABLE_ENTITY],
302
- [429, BYMAX_TOO_MANY_REQUESTS],
303
- [500, BYMAX_INTERNAL_ERROR],
304
- [501, BYMAX_NOT_IMPLEMENTED],
305
- [502, BYMAX_BAD_GATEWAY],
306
- [503, BYMAX_SERVICE_UNAVAILABLE],
307
- [504, BYMAX_GATEWAY_TIMEOUT]
308
- ]);
309
- var CLIENT_ERROR_MIN = 400;
310
- var CLIENT_ERROR_MAX = 500;
311
- function codeForStatus(status) {
312
- const catalogued = STATUS_CODES.get(status);
313
- if (catalogued !== void 0) {
314
- return catalogued;
315
- }
316
- if (status >= CLIENT_ERROR_MIN && status < CLIENT_ERROR_MAX) {
317
- return BYMAX_CLIENT_ERROR;
318
- }
319
- return BYMAX_INTERNAL_ERROR;
320
- }
321
-
322
375
  // src/envelope/error-envelope.ts
323
376
  function buildErrorEnvelope(input) {
324
377
  const base = {
@@ -1695,12 +1748,14 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
1695
1748
  * @param consumer - Nest's middleware consumer.
1696
1749
  */
1697
1750
  configure(consumer) {
1698
- if (!this.resolved.timing.enabled) {
1699
- return;
1700
- }
1701
1751
  const adapter = this.adapterHost?.httpAdapter;
1702
- const onFastify = bridgeFastifyRouteMetadata(adapter);
1703
- consumer.apply(BymaxTimingMiddleware).forRoutes(onFastify ? "{*splat}" : "/");
1752
+ if (this.resolved.timing.enabled) {
1753
+ const onFastify = bridgeFastifyRouteMetadata(adapter);
1754
+ consumer.apply(BymaxTimingMiddleware).forRoutes(onFastify ? "{*splat}" : "/");
1755
+ }
1756
+ if (this.resolved.bodyDepth.enabled && adapter?.getType?.() !== "fastify") {
1757
+ consumer.apply(BymaxBodyDepthMiddleware).forRoutes("/");
1758
+ }
1704
1759
  }
1705
1760
  /**
1706
1761
  * Register the module synchronously. Options are known now, so disabled
@@ -1892,4 +1947,4 @@ TimingInterceptor = __decorateClass([
1892
1947
  __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
1893
1948
  ], TimingInterceptor);
1894
1949
 
1895
- export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
1950
+ export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxBodyDepthMiddleware, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.6.6",
3
+ "version": "1.6.7",
4
4
  "description": "Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing middleware, 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",
@@ -63,6 +63,16 @@
63
63
  "default": "./dist/metrics/index.cjs"
64
64
  }
65
65
  },
66
+ "./http": {
67
+ "import": {
68
+ "types": "./dist/http/index.d.ts",
69
+ "default": "./dist/http/index.mjs"
70
+ },
71
+ "require": {
72
+ "types": "./dist/http/index.d.cts",
73
+ "default": "./dist/http/index.cjs"
74
+ }
75
+ },
66
76
  "./openapi": {
67
77
  "import": {
68
78
  "types": "./dist/openapi/index.d.ts",
@@ -77,6 +87,7 @@
77
87
  },
78
88
  "scripts": {
79
89
  "build": "pnpm clean && tsup",
90
+ "check:file-length": "node scripts/check-file-length.mjs",
80
91
  "check:exports": "attw --pack . --profile strict",
81
92
  "check:mutants": "node scripts/check-mutation-directives.mjs",
82
93
  "check:published": "node scripts/check-published-surface.mjs",
@@ -94,8 +105,8 @@
94
105
  "size": "node scripts/check-size.mjs",
95
106
  "test": "jest",
96
107
  "test:cov": "jest --coverage",
97
- "test:cov:all": "jest --config jest.coverage.config.ts --coverage",
98
- "test:e2e": "jest --config jest.e2e.config.ts",
108
+ "test:cov:all": "node --experimental-vm-modules --disable-warning=ExperimentalWarning node_modules/jest/bin/jest.js --config jest.coverage.config.ts --coverage",
109
+ "test:e2e": "node --experimental-vm-modules --disable-warning=ExperimentalWarning node_modules/jest/bin/jest.js --config jest.e2e.config.ts",
99
110
  "test:watch": "jest --watch",
100
111
  "typecheck": "tsc --noEmit"
101
112
  },
@@ -113,6 +124,7 @@
113
124
  "@nestjs/core": "^11.1.18",
114
125
  "@nestjs/swagger": "^11.0.0",
115
126
  "@opentelemetry/api": "^1.9.0",
127
+ "helmet": "^8.0.0",
116
128
  "prom-client": "^15.0.0",
117
129
  "reflect-metadata": "^0.2.0",
118
130
  "rxjs": "^7.0.0"
@@ -124,6 +136,9 @@
124
136
  "@opentelemetry/api": {
125
137
  "optional": true
126
138
  },
139
+ "helmet": {
140
+ "optional": true
141
+ },
127
142
  "prom-client": {
128
143
  "optional": true
129
144
  }
@@ -150,6 +165,8 @@
150
165
  "@types/supertest": "^7.2.0",
151
166
  "@typescript-eslint/eslint-plugin": "^8.59.3",
152
167
  "@typescript-eslint/parser": "^8.59.3",
168
+ "class-transformer": "^0.5.1",
169
+ "class-validator": "^0.15.1",
153
170
  "eslint": "^9.39.4",
154
171
  "eslint-config-prettier": "^10.1.8",
155
172
  "eslint-import-resolver-typescript": "^4.4.4",
@@ -158,6 +175,7 @@
158
175
  "eslint-plugin-security": "^4.0.0",
159
176
  "fastify": "^5.12.5",
160
177
  "globals": "^17.6.0",
178
+ "helmet": "^8.3.0",
161
179
  "husky": "^9.1.7",
162
180
  "jest": "^30.4.2",
163
181
  "lint-staged": "^17.2.0",
@@ -182,6 +200,7 @@
182
200
  "metrics",
183
201
  "openapi",
184
202
  "swagger",
203
+ "helmet",
185
204
  "opentelemetry",
186
205
  "tracing",
187
206
  "typescript"
@@ -209,6 +228,9 @@
209
228
  ],
210
229
  "metrics": [
211
230
  "./dist/metrics/index.d.cts"
231
+ ],
232
+ "http": [
233
+ "./dist/http/index.d.cts"
212
234
  ]
213
235
  }
214
236
  }