@bymax-one/nest-core 1.6.6 → 1.6.8

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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>Zero-dependency application foundation kit for NestJS</strong><br />
9
- <sub>Error Envelope · Request Timing · Offset &amp; Cursor Pagination · Health Probes · Prometheus Metrics · Zero Runtime Dependencies</sub>
9
+ <sub>Error Envelope · Request Timing · Offset &amp; Cursor Pagination · Health Probes · Prometheus Metrics · HTTP Floors · Zero Runtime Dependencies</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -33,22 +33,21 @@
33
33
 
34
34
  ## ✨ Overview
35
35
 
36
- `@bymax-one/nest-core` is the layer every service in a fleet ends up writing for itself: one
37
- error shape, one timing sample, one pagination contract, one health probe, one metrics
38
- endpoint. Writing it per service is how five services end up answering the same failure five
39
- different ways, and how a client integration breaks because one of them changed its error
40
- body.
36
+ `@bymax-one/nest-core` is the layer every NestJS service ends up writing for itself: one error
37
+ shape, one timing sample, one pagination contract, one health probe, one metrics endpoint.
38
+ Writing it per service is how services end up answering the same failure differently, and how a
39
+ client integration breaks when one of them changes its error body.
41
40
 
42
41
  It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflect-metadata`, and
43
- the three optional peers behind the features that need them (`prom-client`, `@nestjs/swagger`,
44
- `@opentelemetry/api`) — is a peer whose version you already control. A feature you leave off
45
- never loads its peer, which the release gate asserts against the packed tarball.
42
+ the optional peers behind the features that need them (`prom-client`, `@nestjs/swagger`,
43
+ `@opentelemetry/api`, `helmet`) — is a peer whose version you already control. A feature you
44
+ leave off never loads its peer, which the release gate asserts against the packed tarball.
46
45
 
47
46
  ### Why nest-core?
48
47
 
49
- - **One error shape, fleet-wide.** A versioned code catalog and a fixed envelope, so a client
50
- writes one error handler instead of one per service — and an unknown failure becomes a
51
- generic 500 rather than whatever the framework happened to serialize.
48
+ - **One error shape.** A versioned code catalog and a fixed envelope, so a client writes one
49
+ error handler — and an unknown failure becomes a generic 500 rather than whatever the
50
+ framework happened to serialize.
52
51
  - **Features register only when enabled.** Turning metrics off does not leave a disabled
53
52
  provider in the container; it leaves no provider, and `prom-client` is never imported. That
54
53
  is what lets it stay an optional peer.
@@ -70,15 +69,22 @@ never loads its peer, which the release gate asserts against the packed tarball.
70
69
  and the rest, exported as constants so a client maps a `code` rather than a message string
71
70
  - ✅ **Internals stay internal** — an unknown error becomes a generic 500; its message and
72
71
  stack go to the `BYMAX_UNEXPECTED_ERROR_SINK` seam when you bind one, and reach the body
73
- only under `exposeInternals`. A 5xx
74
- never carries `details` either, whichever library threw it, unless `exposeInternals` is on
72
+ only under `exposeInternals`. A 5xx never carries `details` either, whichever library threw
73
+ it, unless `exposeInternals` is on
75
74
  - ✅ **Correlation id** — resolved through `BYMAX_CORRELATION_PROVIDER`, so the id comes from
76
75
  wherever your request context already keeps it
77
76
 
77
+ ### 🛡️ Request floors
78
+
79
+ - ✅ **Body-depth guard** — opt-in `bodyDepth` answers a request body nested too deeply with a
80
+ `400` through the envelope, before a recursive validator overflows the stack into a `500`
81
+ - ✅ **HTTP floors** — `applyHttpFloors` from `./http` sets security headers without HSTS,
82
+ refuses compressed request bodies with `415`, and bounds header and request time
83
+
78
84
  ### ⏱️ Observability
79
85
 
80
- - ✅ **Request timing** — one sample per closed request, rejections included, handed to the sink you register;
81
- the library stores nothing itself
86
+ - ✅ **Request timing** — one sample per closed request, rejections included, handed to the
87
+ sink you register; the library stores nothing itself
82
88
  - ✅ **Slow-request flag** — samples above `slowRequestThresholdMs` are marked, so a sink can
83
89
  branch without re-deriving the threshold
84
90
  - ✅ **Prometheus endpoint** — opt-in scrape route over `BYMAX_METRICS_REGISTRY`;
@@ -105,9 +111,7 @@ never loads its peer, which the release gate asserts against the packed tarball.
105
111
 
106
112
  ### 🧩 Developer Experience
107
113
 
108
- - ✅ **Zero runtime dependencies** — `@nestjs/*`, `rxjs` and `reflect-metadata` arrive as
109
- peers, so you pin the versions
110
- - ✅ **Five subpaths** — the module, plus `./pagination`, `./health`, `./metrics` and
114
+ - ✅ **Subpaths** — the module, plus `./pagination`, `./health`, `./metrics`, `./http` and
111
115
  `./openapi` that a package can import without pulling the module in
112
116
  - ✅ **Dual-format output** — ESM + CJS with declarations for each format, verified against
113
117
  the packed tarball on every run
@@ -126,6 +130,7 @@ never loads its peer, which the release gate asserts against the packed tarball.
126
130
  | `./pagination` | `normalizePageQuery`, `buildPageResult`, `normalizeCursorQuery`, `buildCursorResult`, `encodeCursor`, `decodeCursor` and their types — pure functions, no NestJS provider involved |
127
131
  | `./health` | `IHealthIndicator`, `HealthResponse`, the indicator contracts and the `@BymaxHealthIndicator()` marker, so a package that only implements an indicator does not import the module |
128
132
  | `./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 |
133
+ | `./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
134
  | `./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
135
 
131
136
  Each subpath ships ESM and CommonJS with its own `.d.ts` and `.d.cts`, so
@@ -138,13 +143,14 @@ pnpm add @bymax-one/nest-core @nestjs/common @nestjs/core reflect-metadata rxjs
138
143
  ```
139
144
 
140
145
  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.
146
+ enable the OpenAPI feature, `@opentelemetry/api` if you enable trace
147
+ correlation, and `helmet` if you call `applyHttpFloors`. Each is an optional peer
148
+ dependency: none is required, or ever loaded, unless you turn its feature on.
144
149
 
145
150
  ```bash
146
151
  pnpm add prom-client
147
152
  pnpm add @opentelemetry/api
153
+ pnpm add helmet
148
154
  pnpm add -D @nestjs/swagger
149
155
  ```
150
156
 
@@ -227,20 +233,41 @@ and the classification table: [Production is a closed door](#production-is-a-clo
227
233
 
228
234
  ### `timing`
229
235
 
230
- | Option | Type | Default | Description |
231
- | ------------------------ | --------- | ------- | ------------------------------------------------------------------------------ |
232
- | `enabled` | `boolean` | `true` | Applies the request-timing middleware to every route. |
233
- | `slowRequestThresholdMs` | `number` | unset | Samples above this duration are flagged `slow: true`. Absent means never slow. |
236
+ | Option | Type | Default | Description |
237
+ | ------------------------ | --------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
238
+ | `enabled` | `boolean` | `true` | Applies the request-timing middleware to every route. |
239
+ | `slowRequestThresholdMs` | `number` | unset | Samples above this duration are flagged `slow: true`. Absent means never slow; a positive integer, or boot fails. |
240
+
241
+ ### `bodyDepth`
242
+
243
+ | Option | Type | Default | Description |
244
+ | ---------- | --------- | ------- | -------------------------------------------------------------------------------------------------- |
245
+ | `enabled` | `boolean` | `false` | Refuses a request body nested deeper than `maxDepth` with a `400` through the envelope. See below. |
246
+ | `maxDepth` | `number` | `32` | The deepest nesting a body may have. A flat object or array is depth 1. A positive safe integer. |
247
+
248
+ **Off by default, and worth turning on.** Enabling it rejects bodies that are accepted without
249
+ it, so it is a decision rather than a side effect. Turn it on: a body of about 2,000 nested
250
+ arrays is only 4 KB, and the recursion in a validator or transformer (`class-validator`,
251
+ `class-transformer`) overflows the stack on it, so the filter answers `500` for a request that
252
+ was only malformed — no credential needed. This package ships no validation of its own, so the
253
+ limit sits in front of yours: the check runs before routing, on the body Nest's parsers already
254
+ produced, and answers `400 BYMAX_BAD_REQUEST` ("Request body is nested too deeply"), so it
255
+ protects every route, this package's own included.
256
+
257
+ It covers the Express adapter. It does nothing when the application disables Nest's body
258
+ parsers or parses bodies after module middleware has run, and it is not mounted on Fastify,
259
+ whose middleware runs before the body is parsed.
234
260
 
235
261
  ### `health`
236
262
 
237
- | Option | Type | Default | Description |
238
- | ----------------------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
239
- | `enabled` | `boolean` | `true` | Registers the health controller. |
240
- | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
241
- | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
242
- | `exposeIndicatorErrors` | `boolean` | `false` | Includes the failing indicator's message in the response under `details.error`. Never enable in production — see below. |
243
- | `autoDiscover` | `boolean` | `false` | Also aggregates every provider marked `@BymaxHealthIndicator()`, anywhere in the application. |
263
+ | Option | Type | Default | Description |
264
+ | ----------------------- | --------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
265
+ | `enabled` | `boolean` | `true` | Registers the health controller. |
266
+ | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
267
+ | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down; a positive integer up to `2147483647`, or boot fails. |
268
+ | `readinessCacheMs` | `number` | `1000` | How long a finished readiness answer is reused; `0` turns the cache off. Probes already in flight always share one run. |
269
+ | `exposeIndicatorErrors` | `boolean` | `false` | Serves what an indicator returned in `details`, and the failing indicator's message under `details.error`. Never enable in production — see below. |
270
+ | `autoDiscover` | `boolean` | `false` | Also aggregates every provider marked `@BymaxHealthIndicator()`, anywhere in the application. |
244
271
 
245
272
  On `forRoot`, `enabled` and `path` are applied at module-definition time: a
246
273
  disabled feature registers no controller, and a custom `path` mounts the routes.
@@ -304,237 +331,21 @@ BymaxCoreModule.forRoot({
304
331
  | `operationIdFactory` | `(controller, method, version?) => string` | peer default | Names the operations. Leave unset and nothing an existing client generated changes. |
305
332
  | `includeCoreSchemas` | `boolean` | `true` | Contributes this package's own schemas and references them from the responses. |
306
333
 
307
- Unlike `health` and `metrics`, this block behaves identically on `forRoot` and
308
- `forRootAsync`: the document is mounted from the bootstrap helper, after the
309
- options have resolved, so a custom `path` is honored on both registration paths.
310
-
311
- #### Documenting authentication
312
-
313
- Set the default on the document and mark the exceptions. `security` names
314
- schemes declared in `securitySchemes`; `operationSecurity` overrides it for one
315
- operation, and an **empty array is how the specification says "public"** — which
316
- matters more than it looks, because an operation with _absent_ security inherits
317
- the document default, so a generated client would attach credentials to your
318
- registration endpoint.
319
-
320
- ```ts
321
- openapi: {
322
- enabled: true,
323
- securitySchemes: {
324
- cookieAuth: { type: 'apiKey', in: 'cookie', name: 'access_token' },
325
- refreshCookie: { type: 'apiKey', in: 'cookie', name: 'refresh_token' }
326
- },
327
- security: [{ cookieAuth: [] }],
328
- operationSecurity: {
329
- 'POST /auth/login': [],
330
- 'POST /auth/register': [],
331
- 'POST /auth/refresh': [{ refreshCookie: [] }]
332
- }
333
- }
334
- ```
335
-
336
- An operation that already declares its own requirement — because you decorated
337
- the handler — is never overwritten, on either path.
338
-
339
- #### The operation key is a contract
340
-
341
- Keys are `"<METHOD> <path>"`, and the format is documented rather than
342
- incidental: a sibling library can ship a plain-data map of its own operations
343
- keyed this way, so you spread it in instead of restating which of its routes are
344
- public. Import `OperationSecurityMap` to have that map checked at the library's
345
- own compile time — it is a type-only export, so nothing couples at runtime.
346
-
347
- - The method is **uppercase**, separated by exactly one space.
348
- - The path is written **exactly as it appears in the generated document**:
349
- leading slash, OpenAPI template braces (`/users/{id}`), no trailing slash, and
350
- **including any global prefix**. `@nestjs/swagger` puts
351
- `app.setGlobalPrefix('api')` into the documented paths, so the key becomes
352
- `'POST /api/auth/login'` in an application that sets one.
353
-
354
- Because of that last point, a library shipping such a map should expose a
355
- **function taking the prefix**, not a frozen constant — the call site is the only
356
- place that knows it:
357
-
358
- ```ts
359
- // in the library
360
- export function authOperationSecurity(prefix = ''): OperationSecurityMap {
361
- return {} /* … */
362
- }
363
-
364
- // in the application
365
- BymaxCoreModule.forRoot({
366
- openapi: { operationSecurity: { ...authOperationSecurity('api'), ...myOwnOverrides } }
367
- })
368
- ```
369
-
370
- A requirement naming a scheme that is not declared **fails the document build**
371
- too, listing the names that missed and the ones the document defines. A
372
- requirement is a reference, and a reference to nothing yields a document whose
373
- security cannot be resolved: a client generator looks the name up, finds
374
- nothing, and either fails or emits an unauthenticated client.
375
-
376
- A key matching no operation **fails the document build**, listing both the keys
377
- that missed and the operations that exist. Silence would be worse: a route
378
- renamed out from under a stale key would quietly inherit the document default
379
- and be documented as authenticated when it is not, or the reverse. Failing is
380
- safe here in a way it rarely is — the document is only ever built outside
381
- production, so this can only stop a developer.
382
-
383
- One consequence for conditionally-registered routes: the map is static wiring
384
- while a route may not be. If an operation belongs to a feature you register per
385
- environment — your own conditional module, or a library feature toggled off
386
- somewhere — a key naming it fails the boot in whichever docs-enabled environment
387
- lacks that route. That is the intended loud behavior, so build the map the same
388
- way you build the modules: assemble it per feature and spread the fragments in,
389
- rather than writing one flat literal that outlives the routes it names.
390
-
391
- That last sentence cuts both ways, and the consequence is worth stating rather
392
- than discovering. **These checks only run when the document is actually built.**
393
- With `openapi.enabled` false, or in a production runtime where the feature is
394
- forced off, nothing validates: a stale key, a renamed route, or a requirement
395
- naming a scheme you deleted all sit there quietly until someone turns the
396
- document on. That is deliberate — refusing to boot a production service over a
397
- documentation setting it never serves would be the wrong trade — but it means
398
- the errors surface on a developer's machine or in CI, **not** at the moment the
399
- configuration went wrong. If you gate the document behind an environment flag,
400
- make sure at least one environment that runs your tests has it on, or these
401
- checks never fire.
402
-
403
- #### When an operation ends up requiring nothing
404
-
405
- The checks above catch a requirement that points at nothing. The opposite
406
- mistake — an operation that ends up pointing at _no requirement at all_ — breaks
407
- nothing, and that is what makes it dangerous. It is a valid document. No name
408
- dangles. The runtime still answers `401`. A document test that asserts only the
409
- operations you enumerated stays green. The only thing that changed is that a
410
- client generated from the document now sends no credentials.
411
-
412
- The edit that produces it is ordinary: a library starts describing its own
413
- routes, its adoption note tells you to delete the entries you had written for
414
- them, and the document-level `security` default is sitting in the same options
415
- block and goes with them. Every route _you_ own then reads as public.
416
-
417
- So the document build says so, once, naming the operations:
418
-
419
- ```
420
- [BymaxCoreModule] a client generated from the OpenAPI document will send no
421
- credentials to 2 operation(s): GET /examples, POST /orders. They state no
422
- security requirement, the document declares no default, and other operations in
423
- it do state one — so this is more often a missing openapi.security default than
424
- a public API. Set openapi.security, or state the intent per operation with an
425
- explicit [] in openapi.operationSecurity.
426
- ```
427
-
428
- It is a **warning, not a failure**: an API that is public on purpose is a
429
- legitimate configuration, and failing a boot over one would be worse than the
430
- silence it replaces. The trigger is deliberately narrow, so the line stays worth
431
- reading:
432
-
433
- | Condition | Why |
434
- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
435
- | The document declares no top-level `security` | With a default present, a bare operation inherits it. An explicit `security: []` counts as an answer, not an omission. |
436
- | At least one other operation _does_ state a requirement | Somebody described a posture on purpose, so the bare ones beside it are an omission rather than a public API. |
437
- | The operation is not one of this package's own | A health probe carrying nothing is the correct description of a route an orchestrator polls without a credential. |
438
-
439
- The escape hatch is to say what you mean, in the document's own vocabulary
440
- rather than by silencing output: `operationSecurity: { 'GET /examples': [] }`
441
- marks the operation public, and it stops being reported. A library can do the
442
- same for its own routes by contributing `security: []` in its fragment.
443
-
444
- **There is one shape this warning cannot report.** If you remove _every_
445
- requirement at once — no library describing anything, no decorator, no override,
446
- no document default — the second condition above is never met: nothing states a
447
- requirement, so there are no bare operations sitting _beside_ described ones.
448
- That follows from the trigger and therefore holds in every version; it is not a
449
- gap a later release closes, and a reader who expects one is exactly the reader
450
- who stops checking.
451
-
452
- The limit belongs to **anything reading only the rendered document**. Such a
453
- document is indistinguishable from that of an API which is public on purpose —
454
- both are a set of operations asking for nothing — and this package cannot tell
455
- them apart without also warning at every genuinely public API, which is how a
456
- warning earns the right to be ignored.
457
-
458
- **Your own suite has no such handicap, because you know which one you are.**
459
- Assert it, and the check runs on every commit rather than when somebody
460
- remembers to look:
461
-
462
- ```ts
463
- it('still requires a credential everywhere it should', () => {
464
- const document = buildYourDocument()
465
-
466
- // Assert the default you expect, not merely that one exists. `[]` is a
467
- // defined value that requires nothing, so a "toBeDefined" check passes for
468
- // a default that degraded to empty — which is the regression this test is
469
- // here to catch, wearing the shape of a pass.
470
- expect(document.security).toEqual([{ cookieAuth: [] }])
471
-
472
- // An explicit `[]` is how an operation says "public". The set of operations
473
- // saying it should be the set you meant — no more, no fewer.
474
- expect(publicOperationsOf(document)).toEqual(['POST /auth/login'])
475
- })
476
- ```
477
-
478
- Render-and-diff keeps a narrower job, and it is a good one: when you are
479
- _changing_ something — adopting a library that describes its own routes, moving
480
- a default — render with and without the change and compare the operations you
481
- mount. It shows you what moved without your having to predict it. Then turn what
482
- it showed you into an assertion, so the next change is caught rather than
483
- inspected.
484
-
485
- #### A contributed scheme's presence is part of the contributor's configuration
486
-
487
- A library contributes security schemes, and **which** ones it contributes can
488
- depend on how you configured it. Any of them, gated on any of its inputs, and
489
- often on more than one — the scheme you have in mind may be the absent one, and
490
- the setting you are thinking of may not be the only gate. The names are stable;
491
- their presence is not.
492
-
493
- That has one consequence worth stating as a rule, because getting it wrong
494
- produces a failure at either end of the loudness scale:
495
-
496
- > Derive a document-level default from **the same configuration the contributor
497
- > reads**, never from the scheme names, and never from what the document
498
- > happened to contain before you adopted the library.
499
-
500
- Writing the name as a literal is correct only for the configuration you wrote it
501
- against. Under a configuration that declares that scheme plus another, the
502
- default still resolves but describes one of two credentials the route accepts —
503
- **quietly incomplete**. Under one that declares it not at all, the name resolves
504
- to nothing and the document build **fails** with the undeclared-scheme error
505
- above. Guarding on whether the scheme exists is the tempting fix and it is the
506
- wrong one: it clears the case that already announced itself and ships the one
507
- that does not.
508
-
509
- Note what a document-level default does not let you _say_. Its entries are
510
- **alternatives** — any one of them satisfies an operation — and they apply to
511
- every operation that states nothing. So a backend whose routes sit behind **two
512
- different credential families** can certainly list both, and nothing rejects it:
513
- the result is a document asserting that _either_ credential works for _every_
514
- inheriting route. That is not an incomplete document, it is a false one, and it
515
- is false in the permissive direction — it tells a client that a credential the
516
- route will reject is one the route accepts.
517
-
518
- Give the majority family the default and the minority explicit
519
- `operationSecurity` entries. Those outrank the default, and an operation
520
- carrying one is never named by the warning above, because it states a
521
- requirement.
334
+ Unlike `health` and `metrics`, this block behaves identically on `forRoot` and `forRootAsync`:
335
+ the document is mounted from the bootstrap helper, after the options have resolved. How to
336
+ document authentication and the rest of the feature: [OpenAPI](#-openapi).
522
337
 
523
338
  ## 🔑 DI Tokens
524
339
 
525
- Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER`,
526
- `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK` and
527
- `BYMAX_UNEXPECTED_ERROR_SINK` are consumed with `@Optional()` and are not bound
528
- by the module: provide any of them from your own module to supply your own
529
- implementation, otherwise the internal fallback in the last column applies.
530
- `BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY` behave differently on
531
- `forRootAsync`, where options resolve after the module is defined: there the
532
- module always binds and exports both (the timing sink as the metrics bridge or a
533
- no-op, the registry as a guarded placeholder when metrics are off), so a
534
- consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
535
- `forRootAsync`. Follow the pattern in
536
- [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
537
- below.
340
+ Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER`, `BYMAX_HEALTH_INDICATORS`,
341
+ `BYMAX_HEALTH_TRANSITION_SINK` and `BYMAX_UNEXPECTED_ERROR_SINK` are consumed with `@Optional()`
342
+ and are not bound by the module: provide any of them from your own module, otherwise the
343
+ internal fallback in the last column applies. `BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY`
344
+ behave differently on `forRootAsync`, where options resolve after the module is defined: the
345
+ module always binds and exports both (the timing sink as the metrics bridge or a no-op, the
346
+ registry as a guarded placeholder when metrics are off), so a consumer `BYMAX_TIMING_SINK`
347
+ override is honored on `forRoot` but shadowed on `forRootAsync`. Follow the pattern in
348
+ [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger).
538
349
 
539
350
  | Token | Provides | When you do not provide one |
540
351
  | ------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
@@ -581,19 +392,15 @@ something you bind. With `@bymax-one/nest-logger` that is its request-id
581
392
  middleware; see
582
393
  [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger).
583
394
 
584
- **A server error carries no `details` by default.** For a status of 500 or above, the filter omits `details`
585
- from the body, whichever library threw the `HttpException` and whether it wrote them flat or
586
- under `error`; the `code` and `message` stay, because they are text the thrower chose for the
587
- caller. `envelope.exposeInternals` keeps the details, for local debugging only. The filter owns no
588
- logger and does not record a 5xx's `details` itself, so whatever throws such an error should log
589
- that context on the server and must not interpolate internal data into its `message`. Client
590
- errors are unchanged: their details are the contract. The reasoning follows the OWASP
395
+ **A server error carries no `details` by default.** For a status of 500 or above the filter
396
+ omits `details`, whichever library threw the `HttpException` and whether it wrote them flat or
397
+ under `error`; `code` and `message` stay, because the thrower chose them for the caller.
398
+ `envelope.exposeInternals` keeps the details, for local debugging only. The filter owns no
399
+ logger, so whatever throws such an error should log that context server side and must not
400
+ interpolate internal data into its `message`. Client errors are unchanged: their details are the
401
+ contract. This follows the OWASP
591
402
  [Error Handling Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html)
592
- (a generic client response, details logged server side), RFC 9457
593
- [sections 3.1.4 and 5](https://www.rfc-editor.org/rfc/rfc9457.html) (a problem detail helps the client
594
- correct the problem rather than debug, and no implementation details leave through the HTTP
595
- interface), and the NestJS [exception filters](https://docs.nestjs.com/exception-filters) behaviour
596
- of serializing an `HttpException` response object as the whole body.
403
+ and RFC 9457 [sections 3.1.4 and 5](https://www.rfc-editor.org/rfc/rfc9457.html).
597
404
 
598
405
  Codes are stable strings under a reserved `BYMAX_` prefix, derived from the
599
406
  HTTP status: `BYMAX_NOT_FOUND` for 404, `BYMAX_VALIDATION_FAILED` for the
@@ -632,107 +439,31 @@ Every code the package assigns is exported from `@bymax-one/nest-core` as a cons
632
439
 
633
440
  ### What the filter classifies from, and what it cannot
634
441
 
635
- An error raised before any handler ran — a malformed JSON body, a payload over
636
- the size limit — still becomes a clean `4xx` envelope rather than a 500. The
637
- filter recognizes those by **shape**, not by class: it honours an error that
638
- marks itself `expose: true` with a `4xx` status, which is the `http-errors`
639
- convention Node's body pipeline follows. Nothing in the application failed, so
640
- it is not routed through the unexpected-error seam either.
442
+ An error raised before any handler ran — a malformed JSON body, a payload over the size limit —
443
+ still becomes a clean `4xx` envelope rather than a 500. The filter recognizes those by **shape**,
444
+ not by class: it honours an error that marks itself `expose: true` with a `4xx` status, the
445
+ `http-errors` convention Node's body pipeline follows. Nothing in the application failed, so it
446
+ is not routed through the unexpected-error seam either.
641
447
 
642
448
  The message of such a refusal is fixed per status — `Malformed request` (400),
643
- `Request entity too large` (413), `Unsupported media type` (415) — because the
644
- parser's own text quotes the request: a JSON excerpt, a header value, the path.
645
- A `BadRequestException` a middleware throws before routing gets the same 400
646
- message; throw it with a `code` to keep its own. Fastify is not covered.
647
-
648
- **An error that carries no such marking is a 500, including when a client
649
- caused it.** The clearest case is depth: a body of a few kilobytes nested
650
- thousands of levels deep overflows the stack during validation and surfaces as
651
- `RangeError: Maximum call stack size exceeded` — well under any size limit, and
652
- answered `500 BYMAX_INTERNAL_ERROR`.
653
-
654
- That is deliberate, and the alternative is worse. Mapping `RangeError` to a
655
- `4xx` would make the filter infer causation from an error class, and it would be
656
- wrong exactly where it matters: a genuine stack overflow in your own code is a
657
- `500` that should page someone, and relabelling it as a client error would hide
658
- the failure the `500` exists to surface. By the time the filter sees the error,
659
- the body that caused it is gone.
660
-
661
- **So body-shape limits are the application's floor, not the filter's.** Cap
662
- nesting depth and the request is rejected as the `400` it is, instead of
663
- becoming a `5xx` that pollutes your error rate and writes a stack per request.
664
-
665
- **Position it after the body parser and before validation** — that window is the
666
- only place the body exists in a form you can measure and nothing has walked it
667
- yet. Earlier there is nothing to inspect; later the overflow has already
668
- happened, which is the failure you are trying to prevent.
669
-
670
- **Where that window is depends on the adapter, and the obvious answer is wrong
671
- on Express.** Measured against a real Nest application:
672
-
673
- | Registration point | `req.body` when it runs |
674
- | ---------------------------------------- | ----------------------- |
675
- | `app.use(...)` in `bootstrap.ts` | **`undefined`** |
676
- | Module middleware, `configure(consumer)` | the parsed body |
677
-
678
- Nest registers its own parser during `app.init()`, so a middleware added with
679
- `app.use()` before that is mounted _ahead_ of it — a depth guard there inspects
680
- nothing and silently protects nothing. Register it as module middleware
681
- instead. On Fastify the ordering differs again, since Nest middleware runs
682
- through `@fastify/middie` ahead of body parsing; a `preValidation` hook is the
683
- place to look, and it is worth measuring rather than assuming.
684
-
685
- **Registering it in the right place is not enough — the route pattern silently
686
- skips paths too.** This package hit the same trap with its own timing
687
- middleware, and the measured behaviour is in `core.module.ts`:
688
-
689
- | `forRoutes(...)` | Express | Fastify |
690
- | ---------------- | -------------- | ---------------- |
691
- | `'*splat'` | skips the root | — |
692
- | `'{*splat}'` | skips `/api` | every path |
693
- | `'/'` | every path | matches `/` only |
694
-
695
- So the named-wildcard form every migration guide reaches for leaves `POST /`
696
- unguarded on Express. A consumer measured exactly that: with `'*path'`, a
697
- 2000-level body to the root returned `404` because the middleware never ran;
698
- with `'{*path}'`, `400`. If your application mounts nothing at the root, both
699
- forms answer `4xx` and the status alone cannot tell you which one you have.
700
-
701
- **Verify by behaviour, not by wiring — this is the part worth insisting on.**
702
- Checking where the middleware is registered is what the consumer above did; it
703
- looked correct, they confirmed it to us, and the guard was inert. Send a body
704
- nested past your ceiling and require your own rejection:
705
-
706
- ```ts
707
- it('refuses a body nested past the ceiling', async () => {
708
- const deep = JSON.parse(`${'['.repeat(2000)}${']'.repeat(2000)}`)
709
-
710
- const res = await request(app.getHttpServer()).post('/anything').send({ name: deep })
711
-
712
- // Match your guard's own message, not the status: a validation pipe rejects
713
- // this shape with a 400 as well, so a status assertion passes with the floor
714
- // removed and proves nothing.
715
- expect(res.body.message).toBe('Request body is nested too deeply.')
716
- })
717
- ```
718
-
719
- Use a depth that actually overflows, and assert the guard's own message. A test
720
- at a depth your DTO validation already rejects passes identically with the guard
721
- deleted — which is a check that cannot produce a negative result, and the reason
722
- this defect survived a green suite.
723
-
724
- A depth ceiling well above anything a legitimate payload nests and well below
725
- what exhausts the stack leaves a wide margin: one consumer runs `32`, against
726
- the ~2000 levels that overflow. Walk the parsed body iteratively — a recursive
727
- depth check on a hostile payload overflows the stack it was written to protect.
449
+ `Request entity too large` (413), `Unsupported media type` (415) — because the parser's own text
450
+ quotes the request: a JSON excerpt, a header value, the path. A `BadRequestException` a
451
+ middleware throws before routing gets the same 400 message; throw it with a `code` to keep its
452
+ own. Fastify is not covered.
453
+
454
+ **An error that carries no such marking is a 500, including when a client caused it.** A body of
455
+ a few kilobytes nested thousands of levels deep overflows the stack during validation and
456
+ surfaces as `RangeError: Maximum call stack size exceeded`, answered
457
+ `500 BYMAX_INTERNAL_ERROR`. Mapping `RangeError` to a `4xx` would infer causation from an error
458
+ class and hide a genuine stack overflow in your own code, which is a `500` that should page
459
+ someone. The floor for that case is the opt-in [`bodyDepth`](#bodydepth) guard, which refuses
460
+ the body as the `400` it is before validation runs.
728
461
 
729
462
  ### Recording the cause of a 500
730
463
 
731
- An error the filter does not recognise collapses to a fixed
732
- `500 BYMAX_INTERNAL_ERROR`, and that collapse is the point: the client learns
733
- nothing about your internals. It also means that once the response is written,
734
- the thrown value is gone — an access log sees only the status. Bind an
735
- `IUnexpectedErrorSink` and the filter hands it the original value first:
464
+ An error the filter does not recognise collapses to a fixed `500 BYMAX_INTERNAL_ERROR`, so the
465
+ client learns nothing about your internals, and the thrown value is gone once the response is
466
+ written. Bind an `IUnexpectedErrorSink` and the filter hands it the original value first:
736
467
 
737
468
  ```typescript
738
469
  import { Global, Logger, Module } from '@nestjs/common'
@@ -758,29 +489,19 @@ export class UnexpectedErrorLogger implements IUnexpectedErrorSink {
758
489
  export class ErrorReportingModule {}
759
490
  ```
760
491
 
761
- The sink is called once per unexpected error, on both registration paths, and
762
- for nothing else: an `HttpException` is a deliberate answer and an exposed `4xx`
763
- from the body pipeline is a client mistake, so neither reaches it. The report
764
- carries the thrown value as it was thrown, the method, the route **template**
765
- (`/invoices/:id`, or `<unmatched>` for an error raised before routing), and the
766
- correlation and trace ids when they resolve. It carries no raw URL, query
767
- string, headers or body.
768
-
769
- The response never depends on the sink. A sink that throws, or an
770
- `async record()` that rejects, is absorbed and the client receives the same
771
- envelope it would have received with no sink bound, so a broken logger cannot
772
- turn one failure into two. It runs synchronously before the reply, so hand the
773
- report off and return.
774
-
775
- The value is passed uncut whatever `envelope.exposeInternals` says, since that
776
- option governs the response body and the sink is not the response. A driver's
777
- error message can carry hosts and query fragments: send the report where access
778
- is already controlled.
779
-
780
- `IUnexpectedErrorSink` is matched by shape, so a library can ship a compatible
781
- class without importing this package. Subclassing `BymaxExceptionFilter` and
782
- overriding `onUnexpectedError` still works and runs alongside the sink; the
783
- sink is what reaches the filter `BymaxCoreModule` registers itself.
492
+ The sink is called once per unexpected error, on both registration paths, and for nothing else:
493
+ an `HttpException` is a deliberate answer and an exposed `4xx` from the body pipeline is a client
494
+ mistake. The report carries the thrown value as it was thrown, the method, the route **template**
495
+ (`/invoices/:id`, or `<unmatched>` for an error raised before routing), and the correlation and
496
+ trace ids when they resolve; no raw URL, query string, headers or body. The value is passed uncut
497
+ whatever `envelope.exposeInternals` says, since that option governs the response body, so send the
498
+ report where access is already controlled.
499
+
500
+ The response never depends on the sink: a sink that throws, or an `async record()` that rejects,
501
+ is absorbed, and it runs synchronously before the reply, so hand the report off and return.
502
+ `IUnexpectedErrorSink` is matched by shape, so a library can ship a compatible class without
503
+ importing this package. Subclassing `BymaxExceptionFilter` and overriding `onUnexpectedError`
504
+ still works and runs alongside the sink.
784
505
 
785
506
  ## ⏱️ Request Timing
786
507
 
@@ -799,20 +520,17 @@ export interface RequestTimingSample {
799
520
  }
800
521
  ```
801
522
 
802
- `TimingInterceptor` is still exported and is **deprecated**: nothing registers it. It
803
- was the recorder until `BymaxTimingMiddleware` replaced it, because guards run
804
- before interceptors and a request a guard rejected never reached it. Registering it
805
- as well records a second sample for every request that reaches a handler, so leave
806
- it out.
523
+ `TimingInterceptor` is still exported and is **deprecated**: nothing registers it, and
524
+ registering it alongside the middleware records a second sample for every request that reaches
525
+ a handler, so leave it out.
807
526
 
808
527
  ### Rejected requests are counted too
809
528
 
810
- The recorder is middleware (`BymaxTimingMiddleware`), applied to every route by
811
- `BymaxCoreModule` when `timing.enabled` is `true`. That placement is the whole
812
- point. Nest runs **middleware → guards → interceptors → pipes → handler**, so a
813
- request rejected by a guard never reaches an interceptor, and a request matching
814
- no route never reaches a controller. A recorder sitting in either place is blind
815
- to exactly the traffic that matters during an incident:
529
+ The recorder is middleware (`BymaxTimingMiddleware`), applied to every route by `BymaxCoreModule`
530
+ when `timing.enabled` is `true`. Nest runs **middleware → guards → interceptors → pipes →
531
+ handler**, so a request rejected by a guard never reaches an interceptor, and a request matching
532
+ no route never reaches a controller. A recorder in either place is blind to exactly the traffic
533
+ that matters during an incident:
816
534
 
817
535
  | What happens | Status | Visible to an interceptor | Visible here |
818
536
  | ------------------------------ | ------ | ------------------------- | ------------ |
@@ -823,40 +541,28 @@ to exactly the traffic that matters during an incident:
823
541
  | No route matches | `404` | ❌ | ✅ |
824
542
  | Client hangs up mid-request | — | ❌ | ✅ |
825
543
 
826
- A credential-stuffing run is a flood of `401`s, route enumeration is a flood of
827
- `404`s, and a throttler doing its job is a flood of `429`s. All three used to
828
- leave the error graph flat.
829
-
830
- Requests that matched no route are recorded under the fixed label
831
- `UNMATCHED_ROUTE` (`<unmatched>`), never the path that was requested. The raw
832
- path is attacker-controlled, and a metrics label that follows it lets anyone
833
- mint one time series per probe until the process runs out of memory.
834
-
835
- The sample is emitted when the connection closes, so a client that hangs up
836
- before the response finishes is still counted — that is what a scanner does, and
837
- `durationMs` covers guards and middleware as well as the handler.
838
-
839
- An aborted request keeps whatever status the response held, which is `200` in
840
- Node unless something settled another one. No sentinel status is introduced:
841
- that would change the value of `status_code="200"` series that already exist in
842
- your dashboards, without any change in traffic.
843
-
844
- **Express and Fastify behave identically here**, and that is asserted end to
845
- end on both. It needs saying because the two platforms disagree underneath: Nest
846
- runs middleware on Fastify through `@fastify/middie`, which hands it the raw
847
- `IncomingMessage` carrying no route metadata, and `forRoutes('/')` is a mount on
848
- Express but an exact match on Fastify. The module resolves both for you.
849
-
850
- > **One gap remains, and it is Nest's scoping rule, not a setting.** Module
851
- > middleware is scoped to the global prefix, so with `setGlobalPrefix('api')` a
852
- > request to `/nope` — outside the prefix entirely — reaches no middleware and
853
- > is not recorded. Requests to `/api/nope` are recorded normally, under
854
- > `<unmatched>`, so a scan that probes below your prefix is still visible; only
855
- > one that probes above it is not. Covering that too is not supported yet: the
856
- > module has no way to register the recorder outside its own scope, and
857
- > `BymaxTimingMiddleware` is only provided when `timing` is enabled — at which
858
- > point the module already applies it, so resolving and re-registering it would
859
- > double-count. If you need it, open an issue rather than wiring it by hand.
544
+ A credential-stuffing run is a flood of `401`s, route enumeration a flood of `404`s, and a
545
+ throttler doing its job a flood of `429`s.
546
+
547
+ Requests that matched no route are recorded under the fixed label `UNMATCHED_ROUTE`
548
+ (`<unmatched>`), never the requested path: the raw path is attacker-controlled, and a metrics
549
+ label that follows it lets anyone mint one time series per probe.
550
+
551
+ The sample is emitted when the connection closes, so a client that hangs up before the response
552
+ finishes is still counted, and `durationMs` covers guards and middleware as well as the handler.
553
+ An aborted request keeps whatever status the response held, `200` in Node unless something
554
+ settled another one; no sentinel status is introduced.
555
+
556
+ Express and Fastify behave identically here, and that is asserted end to end on both. The module
557
+ resolves the two platform differences for you: Fastify runs middleware through `@fastify/middie`,
558
+ which hands it the raw `IncomingMessage` without route metadata, and `forRoutes('/')` is a mount
559
+ on Express but an exact match on Fastify.
560
+
561
+ > **One gap is Nest's scoping rule, not a setting.** Module middleware is scoped to the global
562
+ > prefix, so with `setGlobalPrefix('api')` a request to `/nope` — outside the prefix — reaches no
563
+ > middleware and is not recorded. `/api/nope` is recorded under `<unmatched>`. The module cannot
564
+ > register the recorder outside its own scope; open an issue rather than wiring it by hand,
565
+ > which would double-count.
860
566
 
861
567
  Bind your own sink by providing `BYMAX_TIMING_SINK` from your own module, the
862
568
  same override pattern shown below for the correlation provider. This applies on
@@ -881,12 +587,10 @@ class LoggerTimingSink implements ITimingSink {
881
587
  export class ObservabilityModule {}
882
588
  ```
883
589
 
884
- A sink that fails cannot reach the request: both a synchronous throw and a
885
- rejection from an `async record()` are absorbed. Write it `async` if the backend
886
- behind it is async — the `void` return type accepts it, and the rejection is
887
- caught. The failure is swallowed rather than logged, unlike a health transition
888
- sink, because this runs on every request and a systematically failing sink would
889
- otherwise become a second flood beside the first.
590
+ A sink that fails cannot reach the request: a synchronous throw and a rejection from an `async
591
+ record()` are both absorbed. The failure is swallowed rather than logged, unlike a health
592
+ transition sink, because this runs on every request and a failing sink would otherwise become a
593
+ second flood beside the first.
890
594
 
891
595
  ## 📄 Pagination
892
596
 
@@ -919,26 +623,16 @@ export class InvoiceController {
919
623
 
920
624
  #### Bound the offset, not just the page size
921
625
 
922
- `maxLimit` caps how many rows a request reads. `maxOffset` caps how far in it
923
- starts — and on an offset-paginated database that is the half that costs:
924
-
925
- ```
926
- GET /invoices?page=1000000000&limit=20 → OFFSET 19999999980
927
- ```
928
-
929
- Twenty bytes of query, and Postgres walks the table to reach a page that does
930
- not exist. The page index has a floor of `1` and an arithmetic guard that keeps
931
- `(page - 1) * limit` an exact integer, but nothing bounds the product itself
932
- unless you say so.
933
-
934
- `maxOffset` is **absent by default and deliberately so**: legitimate deep paging
935
- exists, and a silent ceiling would change the rows a working query returns. Set
936
- it wherever the page index reaches SQL and your dataset has a knowable ceiling.
937
- `0` is a valid bound and means "the first page only".
626
+ `maxLimit` caps how many rows a request reads; `maxOffset` caps how far in it starts, which is
627
+ the half that costs on an offset-paginated database: `GET /invoices?page=1000000000&limit=20`
628
+ becomes `OFFSET 19999999980`, and Postgres walks the table to reach a page that does not exist.
629
+ The page index has a floor of `1` and an arithmetic guard that keeps `(page - 1) * limit` an
630
+ exact integer, but nothing bounds the product unless you say so.
938
631
 
939
- Clamping matches how `maxLimit` already behaves — the resolved values come back
940
- in `meta`, so a caller that cares can compare what it asked for against what it
941
- got.
632
+ `maxOffset` is **absent by default**, because legitimate deep paging exists and a silent ceiling
633
+ would change the rows a working query returns. Set it wherever the page index reaches SQL and
634
+ your dataset has a knowable ceiling; `0` means "the first page only". Values are clamped the way
635
+ `maxLimit` is, and the resolved values come back in `meta`.
942
636
 
943
637
  ### Cursor pagination
944
638
 
@@ -976,11 +670,12 @@ Liveness always replies `200` with an empty checks array; readiness runs
976
670
  every registered indicator concurrently and replies `200` only when every
977
671
  indicator reports `up`, `503` otherwise, naming every check either way.
978
672
 
979
- A failing indicator is named but not quoted: the response says which check is
980
- down, and the reason goes to the logger. See
673
+ A check is named but not quoted: the response carries each indicator's name and
674
+ status, never the `details` it returned, and a failing indicator's reason goes to
675
+ the logger. See
981
676
  [the security model](#-security-model) for why, and
982
- `health.exposeIndicatorErrors` if you want the message in the response while
983
- debugging locally.
677
+ `health.exposeIndicatorErrors` if you want the details and the message in the
678
+ response while debugging locally.
984
679
 
985
680
  ```json
986
681
  { "status": "ok", "checks": [{ "name": "redis", "status": "up" }] }
@@ -1028,22 +723,25 @@ export class HealthIndicatorsModule {}
1028
723
  ```
1029
724
 
1030
725
  A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
1031
- converted to a `down` entry with a safe, bounded diagnostic detail; it never
1032
- hides the results of the other registered indicators.
726
+ converted to a `down` entry; it never hides the results of the other registered
727
+ indicators.
1033
728
 
1034
- ### A failing check leaves a record
729
+ Readiness is usually reachable without credentials, so probe volume does not set dependency
730
+ volume: probes that arrive while a run is in progress share it, and its answer is reused for
731
+ `readinessCacheMs`. `check()` receives an `AbortSignal` that is aborted when the indicator times
732
+ out, so an indicator that can cancel its query should pass the signal on.
733
+
734
+ When shutdown begins, readiness answers `503` at once, without running an indicator and whatever
735
+ is cached, so a load balancer stops routing to an instance that is draining. Nest only runs
736
+ `beforeApplicationShutdown` on `app.close()` or, for a signal, after `app.enableShutdownHooks()`.
1035
737
 
1036
- A readiness failure that nothing records is a `503` an orchestrator acts on with
1037
- an empty log behind it, and diagnosing it means reproducing it. That is easy to
1038
- arrive at without deciding to: probe paths are the highest-volume request a
1039
- backend serves, so they are usually excluded from the HTTP log surface, and a
1040
- well-written indicator returns `{ status: 'down' }` rather than throwing —
1041
- because readiness is typically unauthenticated and a driver's error carries
1042
- hosts, ports and sometimes credentials.
738
+ ### A failing check leaves a record
1043
739
 
1044
- So the aggregator records it. It holds the last state of every check and reports
1045
- each **change** — never once per probe — to its own logger, and to a sink you
1046
- bind:
740
+ Probe paths are usually excluded from the HTTP log, and a well-written indicator returns
741
+ `{ status: 'down' }` rather than throwing, because a driver's error carries hosts, ports and
742
+ sometimes credentials. So a readiness `503` could leave nothing behind. The aggregator therefore
743
+ holds the last state of every check and reports each **change** — never once per probe — to its
744
+ own logger and to a sink you bind:
1047
745
 
1048
746
  ```typescript
1049
747
  import { Injectable } from '@nestjs/common'
@@ -1066,19 +764,9 @@ export class HealthTransitionLogger implements IHealthTransitionSink {
1066
764
  }
1067
765
  ```
1068
766
 
1069
- Bind it under `BYMAX_HEALTH_TRANSITION_SINK` from your own `@Global()` module,
1070
- the same override pattern as the indicator token above.
1071
-
1072
- Binding nothing is supported, and is not the silent case: the transitions reach
1073
- Nest's logger instead, so a readiness failure always leaves a record. **Binding a
1074
- sink stands that line down** — both destinations are usually the same logger, so
1075
- keeping it would put two records of one transition side by side, which is the
1076
- noise this feature exists to remove. Your sink receives the cause as structured
1077
- data, strictly more than the line renders, so what reaches the log after that is
1078
- your decision rather than this package's.
1079
-
1080
- `transition.cause` distinguishes the three ways a check can be down, which the
1081
- `up`/`down` response body cannot:
767
+ Bind it under `BYMAX_HEALTH_TRANSITION_SINK` from your own `@Global()` module. With nothing
768
+ bound, transitions reach Nest's logger; **binding a sink stands that line down**, so one
769
+ transition is not recorded twice. The sink receives the cause as structured data:
1082
770
 
1083
771
  | `cause.kind` | Meaning | Carries |
1084
772
  | --------------- | -------------------------------------------------- | ----------- |
@@ -1086,26 +774,12 @@ your decision rather than this package's.
1086
774
  | `rejected` | The indicator rejected or threw. | `message` |
1087
775
  | `timed-out` | The aggregator gave up after `indicatorTimeoutMs`. | `timeoutMs` |
1088
776
 
1089
- `timed-out` is the one that cannot be observed anywhere else. An indicator the
1090
- aggregator abandoned is never told, so it reports nothing — and a hung
1091
- dependency is the common shape, not a refused one: a database under load, a
1092
- network partition and a paused container all hang, while a refusal comes back
1093
- immediately and would have been reported. That is why the rule lives in the
1094
- aggregator rather than in each backend: the obstacle is information, not effort.
1095
-
1096
- Two details worth knowing before you rely on it. A first observation that is
1097
- **failing** is reported, while a first observation that is healthy is not — a
1098
- process that boots against a dependency already down would otherwise look
1099
- healthy in the log forever, whereas announcing every healthy check would write a
1100
- line per dependency on every boot. And the cause is the one seen **at the
1101
- transition**: a dependency that stays down while its failure mode changes
1102
- underneath keeps the first cause, which is the cost of one line per outage
1103
- instead of one per probe.
1104
-
1105
- The sink is called synchronously on the readiness path, so keep it cheap. A
1106
- throw is caught and logged rather than failing the probe — readiness answering
1107
- `500` because its own logging broke would take a healthy deployment out of
1108
- rotation — but do not use that as flow control.
777
+ `timed-out` cannot be observed anywhere else: an abandoned indicator is never told, and a hung
778
+ dependency is the common shape. A first observation that is **failing** is reported and a first
779
+ healthy one is not, so a process that boots against a dependency already down does not look
780
+ healthy in the log forever. The cause is the one seen **at the transition**, so a dependency that
781
+ stays down while its failure mode changes keeps the first cause. The sink runs synchronously on
782
+ the readiness path, so keep it cheap; a throw is caught and logged rather than failing the probe.
1109
783
 
1110
784
  ### Discovered indicators
1111
785
 
@@ -1167,6 +841,14 @@ is `true`. If you enable metrics without installing it, the module fails fast
1167
841
  at boot with a descriptive error naming the missing package and the install
1168
842
  command, rather than a cryptic resolution failure at the first scrape.
1169
843
 
844
+ npm marks `prom-client` 15.x deprecated in favour of `@prometheus-io/client`. This library stays on
845
+ `prom-client` `^15`: the collectors you register and the `MetricsRegistry` must come from one package.
846
+
847
+ `prom-client`'s default collectors keep a garbage-collection observer and an event-loop histogram for
848
+ the life of the process, and it offers no way to stop them. Neither keeps the process alive, so
849
+ `app.close()` still lets it exit. It matters only to a host that creates many applications in one
850
+ process, a test runner or a hot reload, where each application leaves a small observer behind.
851
+
1170
852
  When timing and metrics are both enabled, an internal bridge feeds two
1171
853
  default HTTP metrics with a bounded label set:
1172
854
 
@@ -1214,45 +896,28 @@ BymaxCoreModule.forRoot({ metrics: { enabled: true } })
1214
896
  | Named failures | A registration failure — usually a metric name another library already claimed — fails the boot naming the contributor. `prom-client` names the metric; this names who registered it. |
1215
897
  | Off with metrics | With the metrics feature disabled, no contributor runs and `prom-client` is never loaded. |
1216
898
 
1217
- **Naming and labels.** Contributors share one registry and one namespace, so the
1218
- conventions are part of the contract:
1219
-
1220
- - Prefix every metric with `bymax_<library>_` (`bymax_queue_depth`,
1221
- `bymax_cache_hits_total`). An application's own metrics need no prefix — they
1222
- have no one to collide with but themselves.
1223
- - Follow Prometheus naming: `_total` for counters, `_seconds` for durations, base
1224
- units, no units in the middle of a name.
1225
- - Keep labels bounded. Route templates, never raw paths; status codes, never
1226
- messages. **Never** a tenant, user, or request id — one unbounded label is
1227
- enough to make a scrape endpoint the most expensive route in a service.
1228
- `tenantId` deserves naming twice: every library in this family is
1229
- tenant-aware, so it is the first label anyone reaches for and it is unbounded
1230
- by construction.
1231
- - **Publish the list.** A library that contributes metrics documents them in its
1232
- own README — name, type, labels. This package deliberately keeps no central
1233
- catalogue: a list of everyone else's metrics rots the moment a library ships a
1234
- new one. What it does require is that the list exists somewhere an operator
1235
- can find it.
1236
-
1237
- **Why these rules live here.** A Prometheus registry is a flat namespace, and
1238
- `prom-client` rejects a duplicate metric name. If two libraries independently
1239
- pick `bymax_operations_total`, the collision surfaces at the **consumer's** boot
1240
- — in an application neither library's CI ever assembles, as a hard failure, in
1241
- front of whoever wired the app. Neither library can test for it. A namespace
1242
- rule is the only thing that prevents it, and it can only be arbitrated by the
1243
- dependency they share, which is this package.
1244
-
1245
- The rules are documentation, not enforcement. This package could inspect the
1246
- registry around each contributor and reject an unprefixed name, but that would
1247
- need a per-contributor prefix on the contract, and it would wrongly reject the
1248
- case that matters most — an **application's own** contributor, which has no
1249
- business being pushed into a `bymax_` namespace.
899
+ **Naming and labels.** Contributors share one registry and one flat namespace, and `prom-client`
900
+ rejects a duplicate name, so a collision between two libraries surfaces at the **consumer's**
901
+ boot, in an application neither library's CI assembles. The conventions are therefore part of
902
+ the contract:
903
+
904
+ - Prefix every library metric with `bymax_<library>_` (`bymax_queue_depth`). An application's own
905
+ metrics need no prefix.
906
+ - Follow Prometheus naming: `_total` for counters, `_seconds` for durations, base units.
907
+ - Keep labels bounded: route templates, never raw paths; status codes, never messages. **Never**
908
+ a tenant, user or request id: one unbounded label makes the scrape endpoint the most expensive
909
+ route in a service.
910
+ - **Publish the list.** A library that contributes metrics documents name, type and labels in its
911
+ own README; this package keeps no central catalogue.
912
+
913
+ These rules are documentation, not enforcement: rejecting an unprefixed name would need a
914
+ per-contributor prefix on the contract and would wrongly reject an application's own contributor.
1250
915
 
1251
916
  ## 📘 OpenAPI
1252
917
 
1253
- Disabled by default, and **never served in production**. Enabling it and calling
1254
- one helper during bootstrap publishes an interactive UI at `GET /docs` and the
1255
- raw document at `GET /docs-json`:
918
+ Disabled by default, and **never served in production**. Enabling it and calling one helper
919
+ during bootstrap publishes an interactive UI at `GET /docs` and the raw document at
920
+ `GET /docs-json`:
1256
921
 
1257
922
  ```typescript
1258
923
  // app.module.ts — configuration lives with every other feature
@@ -1275,14 +940,11 @@ void bootstrap()
1275
940
  ```
1276
941
 
1277
942
  > [!IMPORTANT]
1278
- > Call `applyBymaxOpenApi` **before** `app.listen()`. Mounting the document
1279
- > re-registers routes on the HTTP adapter, and doing that against an
1280
- > already-initialized Express 5 application replaces the router: the document
1281
- > appears and every other route in the application — yours and this package's
1282
- > health endpoints alike — starts returning 404.
943
+ > Call `applyBymaxOpenApi` **before** `app.listen()`. Mounting the document re-registers routes
944
+ > on the HTTP adapter, and doing that against an already-initialized Express 5 application
945
+ > replaces the router: the document appears and every other route starts returning 404.
1283
946
 
1284
- The call is safe to make unconditionally. It returns what it did, so a template
1285
- can emit it once and never branch:
947
+ The call is safe to make unconditionally. It returns what it did:
1286
948
 
1287
949
  | Result | Meaning |
1288
950
  | ------------------------------------------ | ------------------------------------------------------- |
@@ -1290,55 +952,25 @@ can emit it once and never branch:
1290
952
  | `{ mounted: false, reason: 'disabled' }` | `openapi.enabled` is off. |
1291
953
  | `{ mounted: false, reason: 'production' }` | The runtime is production. Nothing was built or served. |
1292
954
 
1293
- `discarded` lists every component entry a library contributed that the served
1294
- document already defined with a shape of its own — a `DiscardedComponent` per
1295
- entry, naming the contributor, the member and the name. It is empty when every
1296
- contribution was placed, and the boot log reports the same entries.
1297
-
1298
- A collision is not an error and does not stop the boot: the merge prefers
1299
- whatever holds the name already. That is usually your own declaration, and you
1300
- authored it on purpose — but it may also be a core schema this package
1301
- contributes under `openapi.includeCoreSchemas`, or an earlier contributor's
1302
- entry, since contributors merge in class-name order. The report names what was
1303
- dropped and by whom, not who owns the definition that won, so read the document
1304
- before deciding what to remove.
1305
-
1306
- It is reported because it is otherwise unobservable from either side: the
1307
- contributing library cannot tell its entry lost, the application cannot tell one
1308
- was offered, and the served document looks the same either way.
1309
-
1310
- A schema present only as `{"type":"object","properties":{}}` is **not** a
1311
- declaration for this purpose. `@nestjs/swagger` emits exactly that for every DTO
1312
- in a package built without its plugin, so such a name is filled from the
1313
- contribution rather than preferred over it, and does not appear in `discarded`.
1314
- An empty `required: []` and a `description` do not change that; anything else
1315
- does. `{"type":"object"}` and `{}` are deliberate declarations — "any object" and
1316
- "any value" — and are kept.
955
+ `discarded` lists each component a library contributed that the document already defined with a
956
+ shape of its own (a `DiscardedComponent` naming the contributor, the member and the name); the
957
+ boot log reports the same entries. A collision is not an error: the merge keeps whatever holds
958
+ the name, which is usually your own declaration but may be a core schema or an earlier
959
+ contributor's entry. A schema that is only `{"type":"object","properties":{}}` — what
960
+ `@nestjs/swagger` emits for every DTO in a package built without its plugin — is not a
961
+ declaration, so it is filled from the contribution; `{"type":"object"}` and `{}` are kept.
1317
962
 
1318
963
  ### Production is a closed door
1319
964
 
1320
- `NODE_ENV` decides, and the decision is fail-closed: only `development` and
1321
- `test` are non-production. Any other value is production, and in production the
1322
- document is never built and never mounted, whatever the configuration says. The
1323
- guard runs twice, independently: the option resolver forces the feature off, and
1324
- the bootstrap helper classifies the runtime again without trusting that
1325
- resolution.
1326
-
1327
- **`NODE_ENV` cannot be overridden.** With it set to anything, no option serves
1328
- the document in a runtime it named production.
1329
-
1330
- Enabling it in production is not an error, it is a no-op with a warning naming
1331
- the option that was ignored, so a single configuration can be shared across
1332
- environments.
1333
-
1334
- #### When your application validates its own environment variable
1335
-
1336
- Plenty of applications parse an `APP_ENV` through a config schema and never set
1337
- `NODE_ENV` at all. Those deployments used to be classified as production —
1338
- absence was the only evidence available — so the document was refused in an
1339
- environment that never asked for the refusal, with no way to answer back.
965
+ `NODE_ENV` decides, and the decision is fail-closed: only `development` and `test` are
966
+ non-production. Any other value is production, and in production the document is never built or
967
+ mounted, whatever the configuration says. The guard runs twice, independently: the option
968
+ resolver forces the feature off, and the bootstrap helper classifies the runtime again.
969
+ Enabling it in production is a no-op with a warning naming the ignored option, so one
970
+ configuration can be shared across environments. `NODE_ENV` cannot be overridden.
1340
971
 
1341
- Declare the environment and it is used **where the process declares nothing**:
972
+ An application that validates its own `APP_ENV` and never sets `NODE_ENV` declares it, and the
973
+ declaration is used **only where the process declares nothing**:
1342
974
 
1343
975
  ```typescript
1344
976
  BymaxCoreModule.forRootAsync({
@@ -1359,25 +991,13 @@ BymaxCoreModule.forRootAsync({
1359
991
  | unset, or blank | `staging` | **production** |
1360
992
  | unset, or blank | unset | **production** |
1361
993
 
1362
- Two properties are worth reading off that table. A declaration never overrules a
1363
- process that named its own environment — the first row is the one that matters,
1364
- and it is asserted in both guards rather than in one. And the declaration enters
1365
- the same fail-closed classification, so an unrecognized name is production like
1366
- any other; this is a second **source** for the value, never a second set of
1367
- rules.
1368
-
1369
- The narrowing is deliberate and worth naming rather than burying: in the one
1370
- case where the process declares nothing, the configuration a consumer bound does
1371
- decide the answer, because there is nothing else to decide it with. Replacing a
1372
- guess with a declaration is not the same as allowing an override — but it is a
1373
- real change to what the second guard depends on, and you should know it before
1374
- relying on either.
994
+ A declaration never overrules a process that named its own environment, and it goes through the
995
+ same fail-closed classification: an unrecognized name is production.
1375
996
 
1376
997
  ### Testing the enabled path under Jest
1377
998
 
1378
- `applyBymaxOpenApi` loads `@nestjs/swagger` through a dynamic `import()` — that
1379
- is what keeps the peer optional for everyone who never enables the document —
1380
- and Jest's module registry cannot service a dynamic import without a flag:
999
+ `applyBymaxOpenApi` loads `@nestjs/swagger` through a dynamic `import()`, which Jest's module
1000
+ registry cannot service without a flag:
1381
1001
 
1382
1002
  ```jsonc
1383
1003
  // package.json
@@ -1386,15 +1006,98 @@ and Jest's module registry cannot service a dynamic import without a flag:
1386
1006
  }
1387
1007
  ```
1388
1008
 
1389
- Without it, only the **enabled** case fails, with `dynamic import callback
1390
- invoked without --experimental-vm-modules`. The disabled and production cases
1391
- never reach the loader and pass either way, which is what makes the omission
1392
- confusing when you meet it.
1009
+ Without it only the **enabled** case fails, with `dynamic import callback invoked without
1010
+ --experimental-vm-modules`; the disabled and production cases never reach the loader.
1011
+
1012
+ ### Documenting authentication
1013
+
1014
+ Set the default on the document and mark the exceptions. `security` names schemes declared in
1015
+ `securitySchemes`; `operationSecurity` overrides it for one operation, and an **empty array is
1016
+ how the specification says "public"**: an operation with _absent_ security inherits the document
1017
+ default, so a generated client would attach credentials to your registration endpoint.
1018
+
1019
+ ```ts
1020
+ openapi: {
1021
+ enabled: true,
1022
+ securitySchemes: {
1023
+ cookieAuth: { type: 'apiKey', in: 'cookie', name: 'access_token' },
1024
+ refreshCookie: { type: 'apiKey', in: 'cookie', name: 'refresh_token' }
1025
+ },
1026
+ security: [{ cookieAuth: [] }],
1027
+ operationSecurity: {
1028
+ 'POST /auth/login': [],
1029
+ 'POST /auth/register': [],
1030
+ 'POST /auth/refresh': [{ refreshCookie: [] }]
1031
+ }
1032
+ }
1033
+ ```
1034
+
1035
+ An operation that already declares its own requirement, because you decorated the handler, is
1036
+ never overwritten.
1037
+
1038
+ **The operation key is a contract.** Keys are `"<METHOD> <path>"`: the method uppercase, one
1039
+ space, and the path exactly as it appears in the generated document — leading slash, template
1040
+ braces (`/users/{id}`), no trailing slash, **including any global prefix**
1041
+ (`'POST /api/auth/login'` when the application sets `setGlobalPrefix('api')`). A library that
1042
+ ships such a map should expose a **function taking the prefix**, because only the call site
1043
+ knows it; `OperationSecurityMap` is a type-only export for checking that map:
1044
+
1045
+ ```ts
1046
+ // in the library
1047
+ export function authOperationSecurity(prefix = ''): OperationSecurityMap {
1048
+ return {} /* … */
1049
+ }
1050
+
1051
+ // in the application
1052
+ BymaxCoreModule.forRoot({
1053
+ openapi: { operationSecurity: { ...authOperationSecurity('api'), ...myOwnOverrides } }
1054
+ })
1055
+ ```
1056
+
1057
+ **What fails the document build.** A requirement naming an undeclared scheme, and a key matching
1058
+ no operation, each fail the build and list what missed and what exists: a stale key would
1059
+ otherwise quietly document a route as authenticated, or the reverse. These checks run **only
1060
+ when the document is built**: with `openapi.enabled` false, or in production, a stale key or a
1061
+ deleted scheme sits there until someone turns the document on. Keep at least one environment
1062
+ that runs your tests with the document enabled. A key naming a route that exists only in some
1063
+ environments fails the boot in a docs-enabled environment that lacks it, so assemble the map per
1064
+ feature the way you assemble the modules.
1065
+
1066
+ **An operation that ends up requiring nothing** is a valid document, so nothing else catches it,
1067
+ and a client generated from it sends no credentials. When the document declares no top-level
1068
+ `security`, at least one other operation states a requirement, and the bare operation is not one
1069
+ of this package's own, the build logs a warning naming the operations:
1070
+
1071
+ ```
1072
+ [BymaxCoreModule] a client generated from the OpenAPI document will send no
1073
+ credentials to 2 operation(s): GET /examples, POST /orders. They state no
1074
+ security requirement, the document declares no default, and other operations in
1075
+ it do state one — so this is more often a missing openapi.security default than
1076
+ a public API. Set openapi.security, or state the intent per operation with an
1077
+ explicit [] in openapi.operationSecurity.
1078
+ ```
1079
+
1080
+ It is a warning because a public API is a legitimate configuration; `operationSecurity: {
1081
+ 'GET /examples': [] }` states the intent and silences it. It cannot report the case where
1082
+ _every_ requirement is removed at once, since a document with no described operations is
1083
+ indistinguishable from a public API. Your own suite knows which one you are, so assert the
1084
+ default you expect (`expect(document.security).toEqual([{ cookieAuth: [] }])`, not
1085
+ `toBeDefined`, since `[]` is defined) and the set of operations that say `[]`.
1086
+
1087
+ **A contributed scheme's presence belongs to the contributor's configuration.** A library
1088
+ decides which schemes it contributes from its own options. Derive a document-level default from
1089
+ the **same configuration the contributor reads**, never from a scheme name written as a literal:
1090
+ a literal is right only for the configuration you wrote it against, and under another it either
1091
+ describes one of two accepted credentials or fails the build as undeclared. The default's entries
1092
+ are **alternatives** — any one satisfies an operation — and apply to every operation that states
1093
+ nothing, so listing two credential families asserts that _either_ works for _every_ inheriting
1094
+ route, which is false. Give the majority family the default and the minority explicit
1095
+ `operationSecurity` entries, which outrank it.
1393
1096
 
1394
1097
  ### What the library contributes
1395
1098
 
1396
- With `includeCoreSchemas` on, the document carries the contracts this package
1397
- already serves, so an operation can `$ref` them instead of redeclaring them:
1099
+ With `includeCoreSchemas` on, the document carries the contracts this package serves, so an
1100
+ operation can `$ref` them:
1398
1101
 
1399
1102
  | Component | Describes |
1400
1103
  | --------------------------------------------------------------------- | --------------------------------------- |
@@ -1403,49 +1106,25 @@ already serves, so an operation can `$ref` them instead of redeclaring them:
1403
1106
  | `BymaxPageResult`, `BymaxPageMeta`, `BymaxCursorResult` | The offset and cursor page shapes |
1404
1107
  | `BymaxPageQueryPage`, `BymaxPageQueryLimit`, `BymaxCursorQueryCursor` | The pagination query parameters |
1405
1108
 
1406
- They are contributed as plain specification objects, not as decorated classes.
1407
- That is what keeps `@nestjs/swagger` genuinely optional: a decorator runs when
1408
- its class is defined, so describing these contracts with `@ApiProperty` would
1409
- load the peer in every application that imports this package, including the ones
1410
- that never enable the feature.
1411
-
1412
- A contributed entry never overwrites one the document already has: if you
1413
- document your own `BymaxErrorEnvelope`, yours wins.
1414
-
1415
- Contributing the schemas is only half of it — the operations **reference** them,
1416
- which is what a generated client actually reads:
1417
-
1418
- - every operation gains a `default` response pointing at `BymaxErrorEnvelope`,
1419
- because every error path in this package answers with that envelope. It is
1420
- attached as `default` rather than guessed per status code: this package knows
1421
- what an error looks like and does not know which statuses your handler emits.
1422
- It follows the feature: with `envelope.enabled` off, errors are shaped by Nest
1423
- or by your own handler, so nothing is documented;
1424
- - the health endpoints gain an explicit `200` pointing at `BymaxHealthResponse`,
1425
- which this package _does_ know precisely, having registered them itself.
1426
-
1427
- `@nestjs/swagger` emits a placeholder response for every handler — a `200` with
1428
- a description and no content — so "already documented" is judged on whether a
1429
- response declares a **shape**: one carrying `content` — or written as a bare
1430
- `$ref`, which points at a shape declared elsewhere — is yours and is left alone,
1431
- one without either gets filled in while keeping any description you wrote.
1432
-
1433
- Both halves are the same switch. Referencing a schema that was not contributed
1434
- would leave a dangling `$ref`, and a document that resolves nowhere is worse
1435
- than one that says less — so `includeCoreSchemas: false` opts out of both.
1109
+ They are plain specification objects, not decorated classes: a decorator runs when its class is
1110
+ defined, so `@ApiProperty` would load `@nestjs/swagger` in every application that imports this
1111
+ package. A contributed entry never overwrites one the document already has.
1436
1112
 
1437
- ### A library can describe its own routes
1113
+ The operations **reference** them: every operation gains a `default` response pointing at
1114
+ `BymaxErrorEnvelope` (only while `envelope.enabled`, since otherwise errors are shaped by Nest or
1115
+ your handler), and the health endpoints gain an explicit `200` pointing at `BymaxHealthResponse`.
1116
+ A response is "already documented" when it declares a **shape** — `content`, or a bare `$ref` —
1117
+ and is left alone; the placeholder `@nestjs/swagger` emits is filled in, keeping any description
1118
+ you wrote. Both halves are one switch: `includeCoreSchemas: false` opts out of both, because a
1119
+ reference to a schema that was not contributed would dangle.
1438
1120
 
1439
- A library that ships controllers cannot document them itself. Decorating them
1440
- with `@nestjs/swagger` would load that peer in every application importing the
1441
- library, including the ones that never build a document — and a consumer-side
1442
- map keyed by path does not work either, because a library mounted through
1443
- `RouterModule.register` does not know its own final paths: the same route is
1444
- `/auth/login` in one deployment and `/api/v2/identity/login` in another, from one
1445
- build.
1121
+ ### A library can describe its own routes
1446
1122
 
1447
- So a library marks a provider and returns fragments keyed by **handler
1448
- identity**, which survives every prefix, version and mount point:
1123
+ A library that ships controllers cannot document them itself: decorating them with
1124
+ `@nestjs/swagger` would load that peer in every importing application, and a consumer-side map
1125
+ keyed by path fails because a library mounted through `RouterModule.register` does not know its
1126
+ final paths. So a library marks a provider and returns fragments keyed by **handler identity**,
1127
+ which survives every prefix, version and mount point:
1449
1128
 
1450
1129
  ```ts
1451
1130
  import { Injectable } from '@nestjs/common'
@@ -1489,10 +1168,8 @@ export class AuthOpenApi implements IOpenApiContributor {
1489
1168
  }
1490
1169
  ```
1491
1170
 
1492
- Nothing is wired by the application: enabling the document runs the scan, and a
1493
- library that is never imported contributes nothing.
1494
-
1495
- #### What the merge guarantees
1171
+ Nothing is wired by the application: enabling the document runs the scan, and a library that is
1172
+ never imported contributes nothing.
1496
1173
 
1497
1174
  | Rule | Behavior |
1498
1175
  | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -1504,60 +1181,26 @@ library that is never imported contributes nothing.
1504
1181
  | Versioned contract | A fragment declares `contractVersion` as `BYMAX_OPENAPI_CONTRACT_VERSION`, currently `1`. A fragment declaring another version fails the document build, naming both versions. |
1505
1182
  | Off with the document | With `openapi.enabled` false, no contributor runs. |
1506
1183
 
1507
- **Precedence, weakest first:** what this package infers about its own routes,
1508
- then what a library contributed, then what the consumer configured, and above
1509
- all of them whatever the operation already declared — a decorated handler is the
1510
- consumer speaking directly and is never overwritten. So a deployment can always
1511
- overrule a dependency's description of its own routes through
1512
- `operationSecurity`.
1513
-
1514
- **Operation ids are untouched.** This package installs an operation-id factory
1515
- to learn which handler produced which operation, and delegates the id string —
1516
- to `openapi.operationIdFactory` when you set one, to the format `@nestjs/swagger`
1517
- itself produces otherwise. A client generated from your document before adopting
1518
- this keeps working after.
1519
-
1520
- **The fragment shape is translatable to an OpenAPI Overlay, deliberately.** The
1521
- [Overlay Specification](https://spec.openapis.org/overlay/v1.0.0.html) is the
1522
- OpenAPI Initiative's format for describing changes to a document, and a
1523
- contributor's fragments are that in all but notation: a handler key resolves to
1524
- an operation id, which resolves to a JSONPath target, which is what an overlay
1525
- action addresses. Overlays are not used as the mechanism — their targets are
1526
- paths, which is the one thing a library mounted through `RouterModule.register`
1527
- cannot write, and a JSONPath engine would be a runtime dependency this package
1528
- does not have. But the translation is mechanical, so a tool that emits a
1529
- library's contribution as a standalone overlay can exist the day a pipeline
1530
- wants one. No such tool ships here, and none is promised.
1531
-
1532
- **Deriving the fragments is the library's business, not this package's.** A
1533
- library that wants its schemas to track its own validation decorators should
1534
- generate them in its own build or test suite, where that dependency already
1535
- exists, and commit the result — with a test asserting generated matches
1536
- committed, so drift fails in the repository that caused it. This package takes
1537
- no dependency on any validation library and merges what it is given. An
1538
- application's own DTOs need none of this: `@nestjs/swagger`'s CLI plugin already
1539
- derives them, which is a route a precompiled library does not have.
1184
+ **Precedence, weakest first:** what this package infers about its own routes, then what a library
1185
+ contributed, then what the consumer configured, and above all of them whatever the operation
1186
+ already declared. So a deployment can always overrule a dependency's description of its own
1187
+ routes through `operationSecurity`. **Operation ids are untouched:** this package installs an
1188
+ operation-id factory to learn which handler produced which operation and delegates the id string
1189
+ to `openapi.operationIdFactory`, or to the format `@nestjs/swagger` produces, so a client
1190
+ generated before adopting this keeps working. A library that wants its schemas to track its own
1191
+ validation decorators derives them in its own build and commits the result, with a test that
1192
+ generated matches committed; this package takes no dependency on a validation library.
1540
1193
 
1541
1194
  ### The document describes _this_ deployment
1542
1195
 
1543
- A feature you turned off has its routes removed from the document. With
1544
- `metrics: { enabled: false }` the runtime answers `GET /metrics` with a 404
1545
- envelope — on `forRootAsync` the controller is mounted unconditionally and
1546
- guards each request, because route metadata is fixed before the async options
1547
- resolve — so a document still listing the route would describe something this
1548
- deployment does not serve. The filter reads the same resolved snapshot the
1549
- runtime guard reads, which is what keeps the two from drifting.
1550
-
1551
- Your own routes are safe from it. `@nestjs/swagger` documents paths including
1552
- `app.setGlobalPrefix()`, so the match cannot be on equality — but a bare tail
1553
- match would treat `/tenants/{id}/health/live` as this package's probe and delete
1554
- it from your document. What separates the two is that a global prefix prefixes
1555
- _everything_: a tail match counts only when whatever precedes it also precedes
1556
- every other path in the document. `/api/v2` qualifies; `/tenants/{id}` does not,
1557
- because it does not prefix `/invoices`.
1558
-
1559
- This package also documents the security of the three routes it owns, without
1560
- being asked:
1196
+ A feature you turned off has its routes removed from the document: with
1197
+ `metrics: { enabled: false }` the runtime answers `GET /metrics` with a 404 envelope, so a
1198
+ document still listing it would describe something this deployment does not serve. Your own
1199
+ routes are safe from the filter: it matches this package's paths including a global prefix, but
1200
+ a tail match counts only when whatever precedes it also precedes every other path in the
1201
+ document, so `/tenants/{id}/health/live` is never mistaken for the probe.
1202
+
1203
+ This package also documents the security of the routes it owns, without being asked:
1561
1204
 
1562
1205
  | Route | Documented as |
1563
1206
  | ----------------------------------- | ------------------------------------------------------- |
@@ -1565,19 +1208,11 @@ being asked:
1565
1208
  | `GET /metrics`, token set | Bearer-protected |
1566
1209
  | `GET /metrics`, no token | Public (`security: []`), when a document default exists |
1567
1210
 
1568
- The probes are polled by an orchestrator holding no credential, and the scrape
1569
- endpoint is protected exactly when you configured a token — this package owns
1570
- both the route and the option, so you should not have to restate either. Your
1571
- own `operationSecurity` entry still wins.
1572
-
1573
- The last row matters more than it looks. Without a token the scrape endpoint
1574
- answers anyone, which is the deliberate "protected at the edge" arrangement — and
1575
- an open route must **say** it is open rather than inherit your document default.
1576
- Of the two ways to describe a route wrongly, this is the direction that hides:
1577
- documenting a guarded route as open fails loudly at the first generated client
1578
- that omits the credential and gets a `401`, while documenting an open route as
1579
- guarded fails nowhere at all, and hands the wrong answer to whoever opened the
1580
- document to ask what is exposed.
1211
+ The probes are polled by an orchestrator holding no credential, and the scrape endpoint is
1212
+ protected exactly when you configured a token. An open route must **say** it is open rather than
1213
+ inherit your document default: documenting a guarded route as open fails at the first client
1214
+ that omits the credential, while documenting an open route as guarded fails nowhere. Your own
1215
+ `operationSecurity` entry still wins.
1581
1216
 
1582
1217
  ## 🧵 Trace correlation
1583
1218
 
@@ -1601,29 +1236,20 @@ collector setup already does, and doing it twice produces two spans per request.
1601
1236
  `@opentelemetry/api` is an optional peer, loaded once at bootstrap and only when
1602
1237
  the feature is on.
1603
1238
 
1604
- A request with nothing recording, or an all-zero span context, resolves to no
1605
- trace at all: the fields are absent rather than set to a sentinel, so a sink
1606
- never has to recognize a string of zeros.
1607
-
1608
- ### Publishing the id is a separate decision
1239
+ A request with nothing recording, or an all-zero span context, resolves to no trace at all: the
1240
+ fields are absent rather than set to a sentinel.
1609
1241
 
1610
- `exposeTraceId` is off by default. A trace id is not a secret, but in a response
1611
- body it tells a caller that a tracing backend exists and hands them the
1612
- identifier that ties their request to everything else in that trace. Support
1613
- teams often want exactly that, which is why the option exists; it is opt-in so it
1614
- is a decision rather than a side effect. With it off, the identifiers still reach
1615
- your samples and your logs.
1242
+ `exposeTraceId` is off by default: a trace id is not a secret, but in a response body it tells a
1243
+ caller that a tracing backend exists and hands them the identifier that ties their request to
1244
+ everything else in that trace. With it off, the identifiers still reach your samples and logs.
1616
1245
 
1617
1246
  ## 🔗 Integration with `@bymax-one/nest-logger`
1618
1247
 
1619
- Pairing this package with `@bymax-one/nest-logger` gives correlated logs and
1620
- error responses without either package importing the other. nest-logger's
1621
- request-id middleware stores the id under the `requestId` key of
1622
- `LogContextService`. That service is not itself an `ICorrelationIdProvider` —
1623
- it has no `getCorrelationId()` — so adapt it with a factory. `useExisting` on
1624
- it binds an object without that method, and the filter then reads no id and
1625
- reports nothing. Bind both tokens from a `@Global()` module of your own so they
1626
- are visible outside the module that declares them:
1248
+ Pairing this package with `@bymax-one/nest-logger` gives correlated logs and error responses
1249
+ without either package importing the other. nest-logger's request-id middleware stores the id
1250
+ under the `requestId` key of `LogContextService`, which has no `getCorrelationId()`, so adapt it
1251
+ with a factory (`useExisting` would bind an object without that method, and the filter would read
1252
+ no id). Bind both tokens from a `@Global()` module of your own:
1627
1253
 
1628
1254
  ```typescript
1629
1255
  import { Global, Module } from '@nestjs/common'
@@ -1653,9 +1279,9 @@ import {
1653
1279
  export class ObservabilityModule {}
1654
1280
  ```
1655
1281
 
1656
- The id exists only once nest-logger's request-id middleware runs, and
1657
- `BymaxLoggerModule` mounts none on its own. Mount it in `main.ts`, or call
1658
- `applyRequestIdMiddleware(consumer)` from a module's `configure()`:
1282
+ The id exists only once nest-logger's request-id middleware runs, and `BymaxLoggerModule` mounts
1283
+ none on its own. Mount it in `main.ts`, or call `applyRequestIdMiddleware(consumer)` from a
1284
+ module's `configure()`:
1659
1285
 
1660
1286
  > **Express only.** nest-logger's request-id middleware supports
1661
1287
  > `@nestjs/platform-express`; the Fastify adapter is unsupported, so do not
@@ -1674,12 +1300,63 @@ async function bootstrap(): Promise<void> {
1674
1300
  void bootstrap()
1675
1301
  ```
1676
1302
 
1677
- Every error envelope now carries the same correlation id your logs do, and the
1678
- cause of every collapsed `500` is logged with it (`PinoUnexpectedErrorSink`
1679
- needs `@bymax-one/nest-logger` 1.4.1 or later). The
1680
- same `@Global()`-module pattern is how every pluggable token in this package
1681
- is overridden: `BYMAX_TIMING_SINK` and `BYMAX_HEALTH_INDICATORS` follow it
1682
- identically.
1303
+ Every error envelope now carries the same correlation id your logs do, and the cause of every
1304
+ collapsed `500` is logged with it (`PinoUnexpectedErrorSink` needs `@bymax-one/nest-logger` 1.4.1
1305
+ or later). The same `@Global()`-module pattern overrides every pluggable token in this package.
1306
+
1307
+ ## 🧱 HTTP Floors
1308
+
1309
+ `applyHttpFloors` (from `@bymax-one/nest-core/http`) is the HTTP-level protection that every
1310
+ backend wants and none should tune per deployment. One call, before `app.init()` and `app.listen()`:
1311
+
1312
+ ```typescript
1313
+ import { NestFactory } from '@nestjs/core'
1314
+ import { applyHttpFloors } from '@bymax-one/nest-core/http'
1315
+ import { AppModule } from './app.module'
1316
+
1317
+ async function bootstrap(): Promise<void> {
1318
+ const app = await NestFactory.create(AppModule)
1319
+ await applyHttpFloors(app)
1320
+ await app.listen(3000)
1321
+ }
1322
+ void bootstrap()
1323
+ ```
1324
+
1325
+ | What | Default |
1326
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1327
+ | 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. `strictTransportSecurity: undefined` is not `false`: it turns helmet's HSTS default back on |
1328
+ | 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. Only these two parsers: a raw or text parser you add yourself still decompresses. Size limits and the extended form syntax keep the parsers' defaults |
1329
+ | `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 |
1330
+ | `requestTimeoutMs` | `30000`: the longest a single request, headers and body together, may take. Node's default is 5 minutes. Must not be below `headersTimeoutMs` |
1331
+
1332
+ `keepAliveTimeout` is left at Node's 5 s, which is too short behind a load balancer. A balancer whose
1333
+ idle timeout is longer than the server's reuses a connection Node has just closed and answers an
1334
+ intermittent `502`, so set it above the balancer's idle timeout, for example 61 s behind a 60 s one:
1335
+ `app.getHttpServer().keepAliveTimeout = 61_000`. The cost is that an idle socket is kept that much
1336
+ longer. Measured on Node 24, a `headersTimeoutMs` shorter than `keepAliveTimeout` (500 ms and 1 s
1337
+ against 3 s and 5 s) did not close an idle keep-alive socket, so the two need no particular order.
1338
+
1339
+ Node enforces both timeouts from a sweep over the open connections, not from a timer per
1340
+ connection, and the sweep runs every 30 s by default: measured, a 1 s header timeout left a
1341
+ dribbled header block open for 30 s. The helper therefore also sets the server's
1342
+ `connectionsCheckingInterval` to one second (or the header timeout, if that is shorter), so a
1343
+ connection is cut within a second of its timeout expiring. Nest creates the server without
1344
+ exposing its options, so this is a property set before `listen()`: Node reads it when the server
1345
+ starts listening, and the helper throws if the server is already listening. The sweep walks every
1346
+ open connection each time, so a server holding very many sockets can set
1347
+ `connectionsCheckingIntervalMs` higher, at the cost of cutting up to that long after the timeout.
1348
+
1349
+ **Order is part of the contract.** Call it before `app.init()` and right after `applyAccessLog` from
1350
+ `@bymax-one/nest-logger`, and before everything that reads the request: helmet is mounted first,
1351
+ so even a request the body parsers refuse carries the headers, and the parsers are registered
1352
+ 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
1353
+ setting and `applyBymaxOpenApi` stay with you and go after it. On an application that is already initialised the helper throws, because Nest's own parsers and the routes are registered ahead of anything mounted later:
1354
+ `applyAccessLog` → `applyHttpFloors` → cookie parser → trusted proxies → `applyBymaxOpenApi`.
1355
+
1356
+ Express only; on another adapter it throws before applying anything. `helmet` is an optional
1357
+ peer, loaded only when the helper runs, and a missing one fails with the install command. An
1358
+ unusable timeout (zero, fractional, or a header timeout above the request timeout) throws
1359
+ rather than leaving a socket limit switched off.
1683
1360
 
1684
1361
  ## 🏗️ Architecture
1685
1362
 
@@ -1712,139 +1389,105 @@ BYMAX_CORRELATION_PROVIDER no module,
1712
1389
  comes from) script or a test
1713
1390
  ```
1714
1391
 
1715
- Four opt-in integrations attach to that spine rather than adding columns to it:
1716
-
1717
- ```
1718
- health/ + @BymaxHealthIndicator() → a marked provider joins readiness
1719
- metrics/ + @BymaxMetricsContributor() → a marked provider publishes on the registry
1720
- envelope/ + telemetry → the active trace id reaches the envelope and the seam
1721
- timing/ + telemetry → the sample carries traceId and spanId
1722
- ./openapi → one bootstrap call serves the document, in development
1723
- ```
1724
-
1725
- Each feature registers only when it is on. Turning metrics off does not leave a
1726
- disabled provider in the container — it leaves no provider, and `prom-client` is
1727
- never imported, which is why it can stay an optional peer. The same holds for
1728
- `@nestjs/swagger` and `@opentelemetry/api`: the release gate loads the packed
1729
- tarball and fails if any of the three is reachable with its feature off.
1730
-
1731
- Nothing here holds state across requests. The timing middleware emits and forgets;
1732
- the health service runs the indicators the app registered and folds their results;
1733
- the pagination helpers are functions of their arguments.
1392
+ Opt-in integrations attach to that spine rather than adding columns to it: a provider marked
1393
+ `@BymaxHealthIndicator()` joins readiness, one marked `@BymaxMetricsContributor()` publishes on
1394
+ the registry, telemetry puts the trace id on the envelope and the timing sample, and `./openapi`
1395
+ serves the document in development. No request data is retained: the timing middleware emits
1396
+ and forgets, and the pagination helpers are functions of their arguments.
1734
1397
 
1735
1398
  ### Design Principles
1736
1399
 
1737
- | Principle | Description |
1738
- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1739
- | 🎭 **One shape for every failure** | The filter's job is to make a client's error handling independent of which service failed and how. An unknown error becomes a generic 500 with a code, not a leaked stack |
1740
- | 🔌 **Enabled means registered** | A feature that is off registers no provider at all, which is what lets `prom-client` remain an optional peer instead of an always-installed one |
1741
- | 🧮 **Pure where it can be** | Pagination is functions on their own subpath — no provider, no module, no container. A script can use it |
1742
- | 🧊 **Zero runtime dependencies** | `dependencies` is `{}`. Every version you install is one you chose |
1743
- | 🩺 **A probe cannot hang** | The aggregator converts a rejecting or slow indicator to `down` itself, so an indicator implementation never needs to guard its own timeout |
1744
- | 🧬 **Explicit DI tokens** | Tokens are `Symbol()`, so no string token can collide with them, and every injectable constructor parameter is decorated explicitly |
1400
+ | Principle | Description |
1401
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1402
+ | 🎭 **One shape for every failure** | A client's error handling is independent of which service failed and how. An unknown error becomes a generic 500 with a code, not a leaked stack |
1403
+ | 🔌 **Enabled means registered** | A feature that is off registers no provider at all, which is what keeps `prom-client`, `@nestjs/swagger`, `@opentelemetry/api` and `helmet` optional peers; the release gate asserts it on the packed tarball |
1404
+ | 🧮 **Pure where it can be** | Pagination is functions on their own subpath — no provider, no module, no container |
1405
+ | 🧊 **Zero runtime dependencies** | `dependencies` is `{}`. Every version you install is one you chose |
1406
+ | 🩺 **A probe cannot hang** | The aggregator converts a rejecting or slow indicator to `down` itself |
1407
+ | 🧬 **Explicit DI tokens** | Tokens are registered symbols (`Symbol.for`), so every bundle of this package resolves the same identity, and every injectable constructor parameter is decorated explicitly |
1745
1408
 
1746
1409
  ---
1747
1410
 
1748
1411
  ## 🔐 Security Model
1749
1412
 
1750
- This library writes the response a client sees when something fails, and exposes the
1751
- endpoints an operator scrapes. Its security contract is about what those two surfaces
1752
- disclose.
1413
+ This library writes the response a client sees when something fails, and exposes the endpoints an
1414
+ operator scrapes. Its security contract is about what those surfaces disclose.
1753
1415
 
1754
1416
  ### An error envelope is an exfiltration surface
1755
1417
 
1756
- The filter's job is to make every failure look the same to a client, so an unknown error
1757
- becomes a generic 500 whose body carries the fixed envelope fields and the generic message,
1758
- and not the cause unless `exposeInternals` is on. The original message and stack go to the
1759
- `BYMAX_UNEXPECTED_ERROR_SINK` seam when you bind one.
1760
- `envelope.exposeInternals` puts them in the body and exists for local debugging — its own
1761
- documentation says never to enable it in production, and it defaults to `false`.
1418
+ An unknown error becomes a generic 500 whose body carries the fixed envelope fields and the
1419
+ generic message, and not the cause unless `exposeInternals` is on. The original message and stack
1420
+ go to the `BYMAX_UNEXPECTED_ERROR_SINK` seam when you bind one. `envelope.exposeInternals` exists
1421
+ for local debugging only and defaults to `false`.
1762
1422
 
1763
1423
  ### The readiness response names the failure, it does not describe it
1764
1424
 
1765
- A failing indicator produces `{ name, status: 'down' }` and nothing else. The reason goes
1766
- to the logger.
1767
-
1768
- That split is deliberate. Readiness is usually unauthenticated and reachable by whatever
1769
- probes it, and an indicator rarely authors its own failure text — it writes
1770
- `await this.redis.ping()` and lets the driver's error propagate. Driver errors carry hosts,
1771
- ports, and in the case of a connection string, credentials. Putting that text in the
1772
- response publishes it to everyone who can reach the endpoint; putting it in the log keeps
1773
- it where access is already controlled, without losing the diagnostic.
1774
-
1775
- `health.exposeIndicatorErrors` puts the message back in the response for local debugging.
1776
- It defaults to `false`, and its documentation says the same thing `envelope.exposeInternals`
1777
- does: never enable it in production. The two options are the same decision, made the same
1778
- way, about the same risk.
1779
-
1780
- What reaches the log is bounded the same way it always was: the top-level `Error#message`
1781
- only — never the raw error, its stack, or a nested cause — truncated at 300 characters.
1782
- A slow indicator is converted to `down` by the aggregator rather than hanging the probe,
1783
- and its `timedOutAfterMs` stays in the response either way, because that number is one this
1784
- library chose rather than text an indicator produced.
1425
+ A failing indicator produces `{ name, status: 'down' }` and nothing else; the reason goes to the
1426
+ logger. Readiness is usually unauthenticated, and a driver's error carries hosts, ports and, in a
1427
+ connection string, credentials: in the response it is published to everyone who can reach the
1428
+ endpoint, in the log it stays where access is controlled. `health.exposeIndicatorErrors` puts the
1429
+ message back for local debugging, defaults to `false`, and carries the same warning as
1430
+ `exposeInternals`. What reaches the log is the top-level `Error#message` only — never the raw
1431
+ error, its stack or a nested cause — truncated at 300 characters. A slow indicator is converted to
1432
+ `down` by the aggregator, and its `timedOutAfterMs` stays in the response because that number is
1433
+ one this library chose.
1785
1434
 
1786
1435
  ### A metric an attack cannot be seen in is not a control
1787
1436
 
1788
- Request timing is counted as a security signal, not a performance one. A
1789
- credential-stuffing run is a flood of `401`s, a privilege probe a flood of
1790
- `403`s, route enumeration a flood of `404`s, and a rate limiter doing its job a
1791
- flood of `429`s. None of those reaches a handler, so a recorder placed after the
1792
- guards sees none of them — and an operator watching a flat error graph concludes
1793
- nothing is happening. The recorder is middleware for that reason, and every
1794
- closed request is counted whatever ended it.
1437
+ Request timing is a security signal: a credential-stuffing run is a flood of `401`s, a privilege
1438
+ probe a flood of `403`s, route enumeration a flood of `404`s, and a rate limiter doing its job a
1439
+ flood of `429`s. None reaches a handler, so a recorder placed after the guards sees none of them.
1440
+ The recorder is middleware for that reason, and every closed request is counted whatever ended it.
1795
1441
 
1796
1442
  ### A route label is attacker-controlled input
1797
1443
 
1798
- The label on a timing sample comes from the matched route **template**, and a
1799
- request that matched nothing is recorded under the single constant
1800
- `UNMATCHED_ROUTE` (`<unmatched>`) rather than the path that was asked for.
1801
- Following the path would let anyone mint one Prometheus time series per probe:
1802
- a scan would grow the registry without bound, make the scrape endpoint the most
1803
- expensive route in the service, and end as an out-of-memory kill. The bound is
1804
- why the unmatched case is a fixed string and not a fallback to the URL.
1444
+ The label on a timing sample comes from the matched route **template**, and a request that matched
1445
+ nothing is recorded under the constant `UNMATCHED_ROUTE` (`<unmatched>`), never the requested
1446
+ path. Following the path would let anyone mint one Prometheus time series per probe and grow the
1447
+ registry without bound.
1805
1448
 
1806
1449
  ### Cursors are opaque, not secret
1807
1450
 
1808
- `encodeCursor` produces a token a client can round-trip; it is not encrypted and not
1809
- authenticated. Do not put anything in a cursor that the client is not allowed to read, and
1810
- do not treat a cursor as proof of anything.
1451
+ `encodeCursor` produces a token a client can round-trip; it is neither encrypted nor
1452
+ authenticated. Put nothing in a cursor the client may not read, and do not treat one as proof of
1453
+ anything.
1811
1454
 
1812
1455
  ### The metrics endpoint is a route like any other
1813
1456
 
1814
- It is off by default. When it is on, nothing in this library authenticates it — apply the
1815
- guard you would apply to any internal endpoint, or keep it off the public listener.
1457
+ It is off by default. When on, nothing authenticates it unless you set `metrics.authToken`: apply
1458
+ the guard you would apply to any internal endpoint, or keep it off the public listener.
1816
1459
 
1817
1460
  ### The OpenAPI document does not exist in production
1818
1461
 
1819
- A published document is a map of every route, parameter and error shape an application has —
1820
- useful to a developer, and just as useful to anyone probing the service. So unlike the metrics
1821
- endpoint, it is not left to a guard: it is refused outright whenever the runtime is not
1822
- positively `development` or `test`, in two independent layers. **`NODE_ENV` cannot be
1823
- overridden** — with it set to anything, no option serves the document in a runtime it named
1824
- production. A runtime that declares nothing is classified from the application's own
1825
- [`environment`](#environment) when it supplied one, and counts as production otherwise, because
1826
- the deployment nobody configured is the one most likely to be exposed.
1462
+ A published document maps every route, parameter and error shape, which is as useful to someone
1463
+ probing the service as to a developer. So unlike the metrics endpoint it is not left to a guard:
1464
+ it is refused whenever the runtime is not positively `development` or `test`, in two independent
1465
+ layers. **`NODE_ENV` cannot be overridden.** A runtime that declares nothing is classified from the
1466
+ application's own [`environment`](#environment) when it supplied one, and counts as production
1467
+ otherwise.
1827
1468
 
1828
1469
  ---
1829
1470
 
1830
1471
  ## 🛡️ Security Table
1831
1472
 
1832
- | Layer | Implementation |
1833
- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1834
- | Error responses | One shape for everything; unknown errors become a generic 500 |
1835
- | Internals | Message and stack handed to the unexpected-error sink when bound, in the body only under `exposeInternals` (default `false`) |
1836
- | 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
- | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
1838
- | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
1839
- | 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
- | 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
- | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
1842
- | Metrics | Opt-in; `prom-client` never imported while it is off |
1843
- | OpenAPI | Opt-in and development-only; refused in production by two independent guards, `@nestjs/swagger` never imported while it is off |
1844
- | Discovered checks | Matched by an explicit marker, never by shape; off by default, because it lets an imported library fail your readiness probe |
1845
- | Contributed metrics | Called only when marked; a name collision fails the boot naming the contributor rather than half-populating a scrape |
1846
- | Trace ids | Read-only, never used as a metric label; published in a response body only under `telemetry.exposeTraceId` (default off) |
1847
- | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
1473
+ | Layer | Implementation |
1474
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1475
+ | Error responses | One shape for everything; unknown errors become a generic 500 |
1476
+ | Internals | Message and stack handed to the unexpected-error sink when bound, in the body only under `exposeInternals` (default `false`) |
1477
+ | Health output | The response carries each indicator's name and status, never the `details` it returned; a failing indicator's reason goes to the logger. `exposeIndicatorErrors` (default `false`) puts it back in the response for debugging |
1478
+ | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
1479
+ | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
1480
+ | 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` |
1481
+ | HTTP floors | `applyHttpFloors`: compressed request bodies refused with `415`, header and request timeouts of 15 s and 30 s, `helmet` headers without HSTS |
1482
+ | 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 |
1483
+ | 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 |
1484
+ | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
1485
+ | Metrics | Opt-in; `prom-client` never imported while it is off |
1486
+ | OpenAPI | Opt-in and development-only; refused in production by two independent guards, `@nestjs/swagger` never imported while it is off |
1487
+ | Discovered checks | Matched by an explicit marker, never by shape; off by default, because it lets an imported library fail your readiness probe |
1488
+ | Contributed metrics | Called only when marked; a name collision fails the boot naming the contributor rather than half-populating a scrape |
1489
+ | Trace ids | Read-only, never used as a metric label; published in a response body only under `telemetry.exposeTraceId` (default off) |
1490
+ | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
1848
1491
 
1849
1492
  > [!IMPORTANT]
1850
1493
  > **`exposeInternals` is a debugging switch, not a verbosity setting.** With it on,
@@ -1860,8 +1503,10 @@ the deployment nobody configured is the one most likely to be exposed.
1860
1503
  - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `NestModule.configure`)
1861
1504
  - **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
1862
1505
  - **Optional peers:** `prom-client ^15` when metrics are enabled, `@nestjs/swagger ^11` when
1863
- OpenAPI is enabled, `@opentelemetry/api ^1.9` when trace correlation is enabled — none is
1864
- imported while its feature is off
1506
+ OpenAPI is enabled, `@opentelemetry/api ^1.9` when trace correlation is enabled, `helmet ^8`
1507
+ when `applyHttpFloors` runs — none is imported while its feature is off
1508
+ - **Adapters:** Express and Fastify for timing, the filter and health; the body-depth guard and
1509
+ `applyHttpFloors` are Express only
1865
1510
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
1866
1511
  - **Tests:** Jest (unit + e2e over real Nest applications, Express **and** Fastify) + Stryker (mutation)
1867
1512
  - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
@@ -1874,10 +1519,9 @@ This library sits in the path of every request and every failure of every servic
1874
1519
  installs it, so the suite is held to a bar beyond "the tests pass".
1875
1520
 
1876
1521
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
1877
- - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
1878
- `break: 95`; every killable survivor was killed by a strengthened test, with no production
1879
- change, and the nine equivalents that no test can kill each carry their reason on the line
1880
- they apply to ([report](./docs/mutation_testing_results.md))
1522
+ - ✅ **100% mutation score** — [Stryker](https://stryker-mutator.io/) runs with `break: 100`, so
1523
+ a surviving mutant fails the run; a mutant no test can kill carries its reason on the line it
1524
+ applies to ([report](./docs/mutation_testing_results.md))
1881
1525
  - ✅ **End-to-end against real applications, on both platforms** — the filter, the middleware,
1882
1526
  the health and metrics routes, the served OpenAPI document, discovered indicators,
1883
1527
  contributed metrics and trace correlation are all exercised through a booted Nest app, not
@@ -1895,7 +1539,7 @@ installs it, so the suite is held to a bar beyond "the tests pass".
1895
1539
  pnpm test # unit suite
1896
1540
  pnpm test:cov # unit suite with the 100% coverage gate
1897
1541
  pnpm test:e2e # end-to-end against real Nest applications (Express and Fastify)
1898
- pnpm mutation # Stryker mutation testing (break: 95)
1542
+ pnpm mutation # Stryker mutation testing (break: 100)
1899
1543
  pnpm typecheck # tsc strict check
1900
1544
  pnpm lint # ESLint
1901
1545
  ```
@@ -1909,27 +1553,28 @@ in the sections above.
1909
1553
 
1910
1554
  ### `.` (root)
1911
1555
 
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. |
1556
+ | Export | Kind | Description |
1557
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1558
+ | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
1559
+ | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `BodyDepthOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
1560
+ | `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
1561
+ | `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
1562
+ | `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). |
1563
+ | `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
1564
+ | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1565
+ | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
1566
+ | `BymaxExceptionFilter` | class | The envelope exception filter. |
1567
+ | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
1568
+ | `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). |
1569
+ | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
1570
+ | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
1571
+ | `BymaxTimingMiddleware` | class | The request-timing middleware, applied to every route when timing is enabled. |
1572
+ | `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. |
1573
+ | `UNMATCHED_ROUTE` | constant | The bounded label recorded when a request matched no route (`<unmatched>`). |
1574
+ | `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. |
1575
+ | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
1576
+ | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
1577
+ | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
1933
1578
 
1934
1579
  ### `./pagination`
1935
1580
 
@@ -1963,6 +1608,13 @@ in the sections above.
1963
1608
  | `IMetricsContributor` | type | The contract: `registerMetrics(registry)`. |
1964
1609
  | `MetricsRegistry` | type | The `prom-client` registry the scrape endpoint serves. |
1965
1610
 
1611
+ ### `./http`
1612
+
1613
+ | Export | Kind | Description |
1614
+ | ---------------------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
1615
+ | `applyHttpFloors` | function | Applies the headers, the body-parser floor and the timeouts; call it before `app.init()`. |
1616
+ | `HttpFloorsOptions`, `HttpFloorsHelmetOptions` | types | The `helmet`, `headersTimeoutMs` and `requestTimeoutMs` options. |
1617
+
1966
1618
  ### `./openapi`
1967
1619
 
1968
1620
  | Export | Kind | Description |
@@ -1975,24 +1627,28 @@ in the sections above.
1975
1627
  | `BYMAX_OPENAPI_CONTRACT_VERSION` | constant | The version of the fragment contract, currently `1`; a fragment declares it as `contractVersion`. |
1976
1628
  | `IOpenApiContributor`, `OpenApiFragment`, `OpenApiFragmentObject`, `OpenApiHandlerKey` | types | The contributor contract and the shape of what it returns. |
1977
1629
 
1978
- ## 🧩 Compatibility
1979
-
1980
- - Node.js `>= 24`
1981
- - NestJS `^11`
1982
- - Express and Fastify, both covered end to end. The accessors are
1983
- framework-agnostic for path, method and status, and the module absorbs the
1984
- two places the platforms genuinely differ: the route mount, and the fact that
1985
- Nest runs middleware on Fastify through `@fastify/middie`, which strips the
1986
- route metadata off the object the middleware receives.
1987
- - GraphQL and RPC execution contexts are out of scope for the error envelope and
1988
- the request timing in this release; both pass errors and requests through
1989
- untouched.
1630
+ ## 🚫 What This Library Does NOT Do
1631
+
1632
+ - **It mints no request id.** `correlationId` is whatever the `BYMAX_CORRELATION_PROVIDER` you
1633
+ bind returns; with none bound the field is absent.
1634
+ - **It creates no span and configures no OpenTelemetry SDK.** Trace correlation only reads the
1635
+ span your instrumentation already opened.
1636
+ - **It validates no request body.** There is no validation pipe here; `bodyDepth` only bounds
1637
+ nesting, ahead of yours.
1638
+ - **It authenticates and authorizes nothing.** The one credential it knows is the optional
1639
+ `metrics.authToken`; guards, sessions and roles are yours.
1640
+ - **It keeps no request data.** Timing samples go to the sink you register.
1641
+ - **It does not cover GraphQL or RPC execution contexts.** The error envelope and request timing
1642
+ pass those through untouched.
1643
+ - **It does not cover Fastify** for the body-depth guard, `applyHttpFloors`, or the fixed messages
1644
+ of refusals raised before routing. Request timing and the envelope's path handling are
1645
+ asserted end to end on both Express and Fastify.
1646
+ - **It never serves the OpenAPI document in production,** whatever the configuration says.
1990
1647
 
1991
1648
  ## 🤝 Contributing
1992
1649
 
1993
1650
  Pull requests are welcome. Please open an issue first for significant changes.
1994
1651
 
1995
- - Read [`docs/technical_specification.md`](./docs/technical_specification.md) for architecture decisions.
1996
1652
  - Run the full gate listed in [`CONTRIBUTING.md`](./CONTRIBUTING.md) before opening a PR.
1997
1653
  - Conventional Commits are enforced by `commitlint.config.cjs`.
1998
1654