@bymax-one/nest-core 1.6.7 → 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
@@ -229,10 +233,10 @@ and the classification table: [Production is a closed door](#production-is-a-clo
229
233
 
230
234
  ### `timing`
231
235
 
232
- | Option | Type | Default | Description |
233
- | ------------------------ | --------- | ------- | ------------------------------------------------------------------------------ |
234
- | `enabled` | `boolean` | `true` | Applies the request-timing middleware to every route. |
235
- | `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. |
236
240
 
237
241
  ### `bodyDepth`
238
242
 
@@ -241,30 +245,29 @@ and the classification table: [Production is a closed door](#production-is-a-clo
241
245
  | `enabled` | `boolean` | `false` | Refuses a request body nested deeper than `maxDepth` with a `400` through the envelope. See below. |
242
246
  | `maxDepth` | `number` | `32` | The deepest nesting a body may have. A flat object or array is depth 1. A positive safe integer. |
243
247
 
244
- **Off by default, and worth turning on.** It is off because turning it on rejects bodies that
245
- are accepted today, and a patch release must not start doing that. Turn it on: a body of about
246
- 2,000 nested arrays is only 4 KB, and the recursion in a validator or transformer
247
- (`class-validator`, `class-transformer`) overflows the stack on it. The filter then answers
248
- `500` for a request that was only malformed — measured with Nest's `ValidationPipe` on an
249
- application route, needing no credential, so it is also a way to make a healthy service look
250
- broken to a monitor. This package ships no validation of its own, so the limit sits in front of
251
- yours: the check runs on the body Nest's parsers already produced and answers
252
- `400 BYMAX_BAD_REQUEST` ("Request body is nested too deeply") before routing, so it protects
253
- every route, this package's own included, before any pipe runs.
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.
254
256
 
255
257
  It covers the Express adapter. It does nothing when the application disables Nest's body
256
- parsers, or parses bodies after module middleware has run, and it is not mounted on Fastify,
258
+ parsers or parses bodies after module middleware has run, and it is not mounted on Fastify,
257
259
  whose middleware runs before the body is parsed.
258
260
 
259
261
  ### `health`
260
262
 
261
- | Option | Type | Default | Description |
262
- | ----------------------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
263
- | `enabled` | `boolean` | `true` | Registers the health controller. |
264
- | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
265
- | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
266
- | `exposeIndicatorErrors` | `boolean` | `false` | Includes the failing indicator's message in the response under `details.error`. Never enable in production — see below. |
267
- | `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. |
268
271
 
269
272
  On `forRoot`, `enabled` and `path` are applied at module-definition time: a
270
273
  disabled feature registers no controller, and a custom `path` mounts the routes.
@@ -328,237 +331,21 @@ BymaxCoreModule.forRoot({
328
331
  | `operationIdFactory` | `(controller, method, version?) => string` | peer default | Names the operations. Leave unset and nothing an existing client generated changes. |
329
332
  | `includeCoreSchemas` | `boolean` | `true` | Contributes this package's own schemas and references them from the responses. |
330
333
 
331
- Unlike `health` and `metrics`, this block behaves identically on `forRoot` and
332
- `forRootAsync`: the document is mounted from the bootstrap helper, after the
333
- options have resolved, so a custom `path` is honored on both registration paths.
334
-
335
- #### Documenting authentication
336
-
337
- Set the default on the document and mark the exceptions. `security` names
338
- schemes declared in `securitySchemes`; `operationSecurity` overrides it for one
339
- operation, and an **empty array is how the specification says "public"** — which
340
- matters more than it looks, because an operation with _absent_ security inherits
341
- the document default, so a generated client would attach credentials to your
342
- registration endpoint.
343
-
344
- ```ts
345
- openapi: {
346
- enabled: true,
347
- securitySchemes: {
348
- cookieAuth: { type: 'apiKey', in: 'cookie', name: 'access_token' },
349
- refreshCookie: { type: 'apiKey', in: 'cookie', name: 'refresh_token' }
350
- },
351
- security: [{ cookieAuth: [] }],
352
- operationSecurity: {
353
- 'POST /auth/login': [],
354
- 'POST /auth/register': [],
355
- 'POST /auth/refresh': [{ refreshCookie: [] }]
356
- }
357
- }
358
- ```
359
-
360
- An operation that already declares its own requirement — because you decorated
361
- the handler — is never overwritten, on either path.
362
-
363
- #### The operation key is a contract
364
-
365
- Keys are `"<METHOD> <path>"`, and the format is documented rather than
366
- incidental: a sibling library can ship a plain-data map of its own operations
367
- keyed this way, so you spread it in instead of restating which of its routes are
368
- public. Import `OperationSecurityMap` to have that map checked at the library's
369
- own compile time — it is a type-only export, so nothing couples at runtime.
370
-
371
- - The method is **uppercase**, separated by exactly one space.
372
- - The path is written **exactly as it appears in the generated document**:
373
- leading slash, OpenAPI template braces (`/users/{id}`), no trailing slash, and
374
- **including any global prefix**. `@nestjs/swagger` puts
375
- `app.setGlobalPrefix('api')` into the documented paths, so the key becomes
376
- `'POST /api/auth/login'` in an application that sets one.
377
-
378
- Because of that last point, a library shipping such a map should expose a
379
- **function taking the prefix**, not a frozen constant — the call site is the only
380
- place that knows it:
381
-
382
- ```ts
383
- // in the library
384
- export function authOperationSecurity(prefix = ''): OperationSecurityMap {
385
- return {} /* … */
386
- }
387
-
388
- // in the application
389
- BymaxCoreModule.forRoot({
390
- openapi: { operationSecurity: { ...authOperationSecurity('api'), ...myOwnOverrides } }
391
- })
392
- ```
393
-
394
- A requirement naming a scheme that is not declared **fails the document build**
395
- too, listing the names that missed and the ones the document defines. A
396
- requirement is a reference, and a reference to nothing yields a document whose
397
- security cannot be resolved: a client generator looks the name up, finds
398
- nothing, and either fails or emits an unauthenticated client.
399
-
400
- A key matching no operation **fails the document build**, listing both the keys
401
- that missed and the operations that exist. Silence would be worse: a route
402
- renamed out from under a stale key would quietly inherit the document default
403
- and be documented as authenticated when it is not, or the reverse. Failing is
404
- safe here in a way it rarely is — the document is only ever built outside
405
- production, so this can only stop a developer.
406
-
407
- One consequence for conditionally-registered routes: the map is static wiring
408
- while a route may not be. If an operation belongs to a feature you register per
409
- environment — your own conditional module, or a library feature toggled off
410
- somewhere — a key naming it fails the boot in whichever docs-enabled environment
411
- lacks that route. That is the intended loud behavior, so build the map the same
412
- way you build the modules: assemble it per feature and spread the fragments in,
413
- rather than writing one flat literal that outlives the routes it names.
414
-
415
- That last sentence cuts both ways, and the consequence is worth stating rather
416
- than discovering. **These checks only run when the document is actually built.**
417
- With `openapi.enabled` false, or in a production runtime where the feature is
418
- forced off, nothing validates: a stale key, a renamed route, or a requirement
419
- naming a scheme you deleted all sit there quietly until someone turns the
420
- document on. That is deliberate — refusing to boot a production service over a
421
- documentation setting it never serves would be the wrong trade — but it means
422
- the errors surface on a developer's machine or in CI, **not** at the moment the
423
- configuration went wrong. If you gate the document behind an environment flag,
424
- make sure at least one environment that runs your tests has it on, or these
425
- checks never fire.
426
-
427
- #### When an operation ends up requiring nothing
428
-
429
- The checks above catch a requirement that points at nothing. The opposite
430
- mistake — an operation that ends up pointing at _no requirement at all_ — breaks
431
- nothing, and that is what makes it dangerous. It is a valid document. No name
432
- dangles. The runtime still answers `401`. A document test that asserts only the
433
- operations you enumerated stays green. The only thing that changed is that a
434
- client generated from the document now sends no credentials.
435
-
436
- The edit that produces it is ordinary: a library starts describing its own
437
- routes, its adoption note tells you to delete the entries you had written for
438
- them, and the document-level `security` default is sitting in the same options
439
- block and goes with them. Every route _you_ own then reads as public.
440
-
441
- So the document build says so, once, naming the operations:
442
-
443
- ```
444
- [BymaxCoreModule] a client generated from the OpenAPI document will send no
445
- credentials to 2 operation(s): GET /examples, POST /orders. They state no
446
- security requirement, the document declares no default, and other operations in
447
- it do state one — so this is more often a missing openapi.security default than
448
- a public API. Set openapi.security, or state the intent per operation with an
449
- explicit [] in openapi.operationSecurity.
450
- ```
451
-
452
- It is a **warning, not a failure**: an API that is public on purpose is a
453
- legitimate configuration, and failing a boot over one would be worse than the
454
- silence it replaces. The trigger is deliberately narrow, so the line stays worth
455
- reading:
456
-
457
- | Condition | Why |
458
- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
459
- | 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. |
460
- | 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. |
461
- | 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. |
462
-
463
- The escape hatch is to say what you mean, in the document's own vocabulary
464
- rather than by silencing output: `operationSecurity: { 'GET /examples': [] }`
465
- marks the operation public, and it stops being reported. A library can do the
466
- same for its own routes by contributing `security: []` in its fragment.
467
-
468
- **There is one shape this warning cannot report.** If you remove _every_
469
- requirement at once — no library describing anything, no decorator, no override,
470
- no document default — the second condition above is never met: nothing states a
471
- requirement, so there are no bare operations sitting _beside_ described ones.
472
- That follows from the trigger and therefore holds in every version; it is not a
473
- gap a later release closes, and a reader who expects one is exactly the reader
474
- who stops checking.
475
-
476
- The limit belongs to **anything reading only the rendered document**. Such a
477
- document is indistinguishable from that of an API which is public on purpose —
478
- both are a set of operations asking for nothing — and this package cannot tell
479
- them apart without also warning at every genuinely public API, which is how a
480
- warning earns the right to be ignored.
481
-
482
- **Your own suite has no such handicap, because you know which one you are.**
483
- Assert it, and the check runs on every commit rather than when somebody
484
- remembers to look:
485
-
486
- ```ts
487
- it('still requires a credential everywhere it should', () => {
488
- const document = buildYourDocument()
489
-
490
- // Assert the default you expect, not merely that one exists. `[]` is a
491
- // defined value that requires nothing, so a "toBeDefined" check passes for
492
- // a default that degraded to empty — which is the regression this test is
493
- // here to catch, wearing the shape of a pass.
494
- expect(document.security).toEqual([{ cookieAuth: [] }])
495
-
496
- // An explicit `[]` is how an operation says "public". The set of operations
497
- // saying it should be the set you meant — no more, no fewer.
498
- expect(publicOperationsOf(document)).toEqual(['POST /auth/login'])
499
- })
500
- ```
501
-
502
- Render-and-diff keeps a narrower job, and it is a good one: when you are
503
- _changing_ something — adopting a library that describes its own routes, moving
504
- a default — render with and without the change and compare the operations you
505
- mount. It shows you what moved without your having to predict it. Then turn what
506
- it showed you into an assertion, so the next change is caught rather than
507
- inspected.
508
-
509
- #### A contributed scheme's presence is part of the contributor's configuration
510
-
511
- A library contributes security schemes, and **which** ones it contributes can
512
- depend on how you configured it. Any of them, gated on any of its inputs, and
513
- often on more than one — the scheme you have in mind may be the absent one, and
514
- the setting you are thinking of may not be the only gate. The names are stable;
515
- their presence is not.
516
-
517
- That has one consequence worth stating as a rule, because getting it wrong
518
- produces a failure at either end of the loudness scale:
519
-
520
- > Derive a document-level default from **the same configuration the contributor
521
- > reads**, never from the scheme names, and never from what the document
522
- > happened to contain before you adopted the library.
523
-
524
- Writing the name as a literal is correct only for the configuration you wrote it
525
- against. Under a configuration that declares that scheme plus another, the
526
- default still resolves but describes one of two credentials the route accepts —
527
- **quietly incomplete**. Under one that declares it not at all, the name resolves
528
- to nothing and the document build **fails** with the undeclared-scheme error
529
- above. Guarding on whether the scheme exists is the tempting fix and it is the
530
- wrong one: it clears the case that already announced itself and ships the one
531
- that does not.
532
-
533
- Note what a document-level default does not let you _say_. Its entries are
534
- **alternatives** — any one of them satisfies an operation — and they apply to
535
- every operation that states nothing. So a backend whose routes sit behind **two
536
- different credential families** can certainly list both, and nothing rejects it:
537
- the result is a document asserting that _either_ credential works for _every_
538
- inheriting route. That is not an incomplete document, it is a false one, and it
539
- is false in the permissive direction — it tells a client that a credential the
540
- route will reject is one the route accepts.
541
-
542
- Give the majority family the default and the minority explicit
543
- `operationSecurity` entries. Those outrank the default, and an operation
544
- carrying one is never named by the warning above, because it states a
545
- 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).
546
337
 
547
338
  ## 🔑 DI Tokens
548
339
 
549
- Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER`,
550
- `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK` and
551
- `BYMAX_UNEXPECTED_ERROR_SINK` are consumed with `@Optional()` and are not bound
552
- by the module: provide any of them from your own module to supply your own
553
- implementation, otherwise the internal fallback in the last column applies.
554
- `BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY` behave differently on
555
- `forRootAsync`, where options resolve after the module is defined: there the
556
- module always binds and exports both (the timing sink as the metrics bridge or a
557
- no-op, the registry as a guarded placeholder when metrics are off), so a
558
- consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
559
- `forRootAsync`. Follow the pattern in
560
- [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
561
- 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).
562
349
 
563
350
  | Token | Provides | When you do not provide one |
564
351
  | ------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
@@ -605,19 +392,15 @@ something you bind. With `@bymax-one/nest-logger` that is its request-id
605
392
  middleware; see
606
393
  [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger).
607
394
 
608
- **A server error carries no `details` by default.** For a status of 500 or above, the filter omits `details`
609
- from the body, whichever library threw the `HttpException` and whether it wrote them flat or
610
- under `error`; the `code` and `message` stay, because they are text the thrower chose for the
611
- caller. `envelope.exposeInternals` keeps the details, for local debugging only. The filter owns no
612
- logger and does not record a 5xx's `details` itself, so whatever throws such an error should log
613
- that context on the server and must not interpolate internal data into its `message`. Client
614
- 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
615
402
  [Error Handling Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html)
616
- (a generic client response, details logged server side), RFC 9457
617
- [sections 3.1.4 and 5](https://www.rfc-editor.org/rfc/rfc9457.html) (a problem detail helps the client
618
- correct the problem rather than debug, and no implementation details leave through the HTTP
619
- interface), and the NestJS [exception filters](https://docs.nestjs.com/exception-filters) behaviour
620
- 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).
621
404
 
622
405
  Codes are stable strings under a reserved `BYMAX_` prefix, derived from the
623
406
  HTTP status: `BYMAX_NOT_FOUND` for 404, `BYMAX_VALIDATION_FAILED` for the
@@ -656,107 +439,31 @@ Every code the package assigns is exported from `@bymax-one/nest-core` as a cons
656
439
 
657
440
  ### What the filter classifies from, and what it cannot
658
441
 
659
- An error raised before any handler ran — a malformed JSON body, a payload over
660
- the size limit — still becomes a clean `4xx` envelope rather than a 500. The
661
- filter recognizes those by **shape**, not by class: it honours an error that
662
- marks itself `expose: true` with a `4xx` status, which is the `http-errors`
663
- convention Node's body pipeline follows. Nothing in the application failed, so
664
- 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.
665
447
 
666
448
  The message of such a refusal is fixed per status — `Malformed request` (400),
667
- `Request entity too large` (413), `Unsupported media type` (415) — because the
668
- parser's own text quotes the request: a JSON excerpt, a header value, the path.
669
- A `BadRequestException` a middleware throws before routing gets the same 400
670
- message; throw it with a `code` to keep its own. Fastify is not covered.
671
-
672
- **An error that carries no such marking is a 500, including when a client
673
- caused it.** The clearest case is depth: a body of a few kilobytes nested
674
- thousands of levels deep overflows the stack during validation and surfaces as
675
- `RangeError: Maximum call stack size exceeded` — well under any size limit, and
676
- answered `500 BYMAX_INTERNAL_ERROR`.
677
-
678
- That is deliberate, and the alternative is worse. Mapping `RangeError` to a
679
- `4xx` would make the filter infer causation from an error class, and it would be
680
- wrong exactly where it matters: a genuine stack overflow in your own code is a
681
- `500` that should page someone, and relabelling it as a client error would hide
682
- the failure the `500` exists to surface. By the time the filter sees the error,
683
- the body that caused it is gone.
684
-
685
- **So body-shape limits are the application's floor, not the filter's.** Cap
686
- nesting depth and the request is rejected as the `400` it is, instead of
687
- becoming a `5xx` that pollutes your error rate and writes a stack per request.
688
-
689
- **Position it after the body parser and before validation** — that window is the
690
- only place the body exists in a form you can measure and nothing has walked it
691
- yet. Earlier there is nothing to inspect; later the overflow has already
692
- happened, which is the failure you are trying to prevent.
693
-
694
- **Where that window is depends on the adapter, and the obvious answer is wrong
695
- on Express.** Measured against a real Nest application:
696
-
697
- | Registration point | `req.body` when it runs |
698
- | ---------------------------------------- | ----------------------- |
699
- | `app.use(...)` in `bootstrap.ts` | **`undefined`** |
700
- | Module middleware, `configure(consumer)` | the parsed body |
701
-
702
- Nest registers its own parser during `app.init()`, so a middleware added with
703
- `app.use()` before that is mounted _ahead_ of it — a depth guard there inspects
704
- nothing and silently protects nothing. Register it as module middleware
705
- instead. On Fastify the ordering differs again, since Nest middleware runs
706
- through `@fastify/middie` ahead of body parsing; a `preValidation` hook is the
707
- place to look, and it is worth measuring rather than assuming.
708
-
709
- **Registering it in the right place is not enough — the route pattern silently
710
- skips paths too.** This package hit the same trap with its own timing
711
- middleware, and the measured behaviour is in `core.module.ts`:
712
-
713
- | `forRoutes(...)` | Express | Fastify |
714
- | ---------------- | -------------- | ---------------- |
715
- | `'*splat'` | skips the root | — |
716
- | `'{*splat}'` | skips `/api` | every path |
717
- | `'/'` | every path | matches `/` only |
718
-
719
- So the named-wildcard form every migration guide reaches for leaves `POST /`
720
- unguarded on Express. A consumer measured exactly that: with `'*path'`, a
721
- 2000-level body to the root returned `404` because the middleware never ran;
722
- with `'{*path}'`, `400`. If your application mounts nothing at the root, both
723
- forms answer `4xx` and the status alone cannot tell you which one you have.
724
-
725
- **Verify by behaviour, not by wiring — this is the part worth insisting on.**
726
- Checking where the middleware is registered is what the consumer above did; it
727
- looked correct, they confirmed it to us, and the guard was inert. Send a body
728
- nested past your ceiling and require your own rejection:
729
-
730
- ```ts
731
- it('refuses a body nested past the ceiling', async () => {
732
- const deep = JSON.parse(`${'['.repeat(2000)}${']'.repeat(2000)}`)
733
-
734
- const res = await request(app.getHttpServer()).post('/anything').send({ name: deep })
735
-
736
- // Match your guard's own message, not the status: a validation pipe rejects
737
- // this shape with a 400 as well, so a status assertion passes with the floor
738
- // removed and proves nothing.
739
- expect(res.body.message).toBe('Request body is nested too deeply.')
740
- })
741
- ```
742
-
743
- Use a depth that actually overflows, and assert the guard's own message. A test
744
- at a depth your DTO validation already rejects passes identically with the guard
745
- deleted — which is a check that cannot produce a negative result, and the reason
746
- this defect survived a green suite.
747
-
748
- A depth ceiling well above anything a legitimate payload nests and well below
749
- what exhausts the stack leaves a wide margin: one consumer runs `32`, against
750
- the ~2000 levels that overflow. Walk the parsed body iteratively — a recursive
751
- 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.
752
461
 
753
462
  ### Recording the cause of a 500
754
463
 
755
- An error the filter does not recognise collapses to a fixed
756
- `500 BYMAX_INTERNAL_ERROR`, and that collapse is the point: the client learns
757
- nothing about your internals. It also means that once the response is written,
758
- the thrown value is gone — an access log sees only the status. Bind an
759
- `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:
760
467
 
761
468
  ```typescript
762
469
  import { Global, Logger, Module } from '@nestjs/common'
@@ -782,29 +489,19 @@ export class UnexpectedErrorLogger implements IUnexpectedErrorSink {
782
489
  export class ErrorReportingModule {}
783
490
  ```
784
491
 
785
- The sink is called once per unexpected error, on both registration paths, and
786
- for nothing else: an `HttpException` is a deliberate answer and an exposed `4xx`
787
- from the body pipeline is a client mistake, so neither reaches it. The report
788
- carries the thrown value as it was thrown, the method, the route **template**
789
- (`/invoices/:id`, or `<unmatched>` for an error raised before routing), and the
790
- correlation and trace ids when they resolve. It carries no raw URL, query
791
- string, headers or body.
792
-
793
- The response never depends on the sink. A sink that throws, or an
794
- `async record()` that rejects, is absorbed and the client receives the same
795
- envelope it would have received with no sink bound, so a broken logger cannot
796
- turn one failure into two. It runs synchronously before the reply, so hand the
797
- report off and return.
798
-
799
- The value is passed uncut whatever `envelope.exposeInternals` says, since that
800
- option governs the response body and the sink is not the response. A driver's
801
- error message can carry hosts and query fragments: send the report where access
802
- is already controlled.
803
-
804
- `IUnexpectedErrorSink` is matched by shape, so a library can ship a compatible
805
- class without importing this package. Subclassing `BymaxExceptionFilter` and
806
- overriding `onUnexpectedError` still works and runs alongside the sink; the
807
- 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.
808
505
 
809
506
  ## ⏱️ Request Timing
810
507
 
@@ -823,20 +520,17 @@ export interface RequestTimingSample {
823
520
  }
824
521
  ```
825
522
 
826
- `TimingInterceptor` is still exported and is **deprecated**: nothing registers it. It
827
- was the recorder until `BymaxTimingMiddleware` replaced it, because guards run
828
- before interceptors and a request a guard rejected never reached it. Registering it
829
- as well records a second sample for every request that reaches a handler, so leave
830
- 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.
831
526
 
832
527
  ### Rejected requests are counted too
833
528
 
834
- The recorder is middleware (`BymaxTimingMiddleware`), applied to every route by
835
- `BymaxCoreModule` when `timing.enabled` is `true`. That placement is the whole
836
- point. Nest runs **middleware → guards → interceptors → pipes → handler**, so a
837
- request rejected by a guard never reaches an interceptor, and a request matching
838
- no route never reaches a controller. A recorder sitting in either place is blind
839
- 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:
840
534
 
841
535
  | What happens | Status | Visible to an interceptor | Visible here |
842
536
  | ------------------------------ | ------ | ------------------------- | ------------ |
@@ -847,40 +541,28 @@ to exactly the traffic that matters during an incident:
847
541
  | No route matches | `404` | ❌ | ✅ |
848
542
  | Client hangs up mid-request | — | ❌ | ✅ |
849
543
 
850
- A credential-stuffing run is a flood of `401`s, route enumeration is a flood of
851
- `404`s, and a throttler doing its job is a flood of `429`s. All three used to
852
- leave the error graph flat.
853
-
854
- Requests that matched no route are recorded under the fixed label
855
- `UNMATCHED_ROUTE` (`<unmatched>`), never the path that was requested. The raw
856
- path is attacker-controlled, and a metrics label that follows it lets anyone
857
- mint one time series per probe until the process runs out of memory.
858
-
859
- The sample is emitted when the connection closes, so a client that hangs up
860
- before the response finishes is still counted — that is what a scanner does, and
861
- `durationMs` covers guards and middleware as well as the handler.
862
-
863
- An aborted request keeps whatever status the response held, which is `200` in
864
- Node unless something settled another one. No sentinel status is introduced:
865
- that would change the value of `status_code="200"` series that already exist in
866
- your dashboards, without any change in traffic.
867
-
868
- **Express and Fastify behave identically here**, and that is asserted end to
869
- end on both. It needs saying because the two platforms disagree underneath: Nest
870
- runs middleware on Fastify through `@fastify/middie`, which hands it the raw
871
- `IncomingMessage` carrying no route metadata, and `forRoutes('/')` is a mount on
872
- Express but an exact match on Fastify. The module resolves both for you.
873
-
874
- > **One gap remains, and it is Nest's scoping rule, not a setting.** Module
875
- > middleware is scoped to the global prefix, so with `setGlobalPrefix('api')` a
876
- > request to `/nope` — outside the prefix entirely — reaches no middleware and
877
- > is not recorded. Requests to `/api/nope` are recorded normally, under
878
- > `<unmatched>`, so a scan that probes below your prefix is still visible; only
879
- > one that probes above it is not. Covering that too is not supported yet: the
880
- > module has no way to register the recorder outside its own scope, and
881
- > `BymaxTimingMiddleware` is only provided when `timing` is enabled — at which
882
- > point the module already applies it, so resolving and re-registering it would
883
- > 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.
884
566
 
885
567
  Bind your own sink by providing `BYMAX_TIMING_SINK` from your own module, the
886
568
  same override pattern shown below for the correlation provider. This applies on
@@ -905,12 +587,10 @@ class LoggerTimingSink implements ITimingSink {
905
587
  export class ObservabilityModule {}
906
588
  ```
907
589
 
908
- A sink that fails cannot reach the request: both a synchronous throw and a
909
- rejection from an `async record()` are absorbed. Write it `async` if the backend
910
- behind it is async — the `void` return type accepts it, and the rejection is
911
- caught. The failure is swallowed rather than logged, unlike a health transition
912
- sink, because this runs on every request and a systematically failing sink would
913
- 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.
914
594
 
915
595
  ## 📄 Pagination
916
596
 
@@ -943,26 +623,16 @@ export class InvoiceController {
943
623
 
944
624
  #### Bound the offset, not just the page size
945
625
 
946
- `maxLimit` caps how many rows a request reads. `maxOffset` caps how far in it
947
- starts — and on an offset-paginated database that is the half that costs:
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.
948
631
 
949
- ```
950
- GET /invoices?page=1000000000&limit=20 → OFFSET 19999999980
951
- ```
952
-
953
- Twenty bytes of query, and Postgres walks the table to reach a page that does
954
- not exist. The page index has a floor of `1` and an arithmetic guard that keeps
955
- `(page - 1) * limit` an exact integer, but nothing bounds the product itself
956
- unless you say so.
957
-
958
- `maxOffset` is **absent by default and deliberately so**: legitimate deep paging
959
- exists, and a silent ceiling would change the rows a working query returns. Set
960
- it wherever the page index reaches SQL and your dataset has a knowable ceiling.
961
- `0` is a valid bound and means "the first page only".
962
-
963
- Clamping matches how `maxLimit` already behaves — the resolved values come back
964
- in `meta`, so a caller that cares can compare what it asked for against what it
965
- 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`.
966
636
 
967
637
  ### Cursor pagination
968
638
 
@@ -1000,11 +670,12 @@ Liveness always replies `200` with an empty checks array; readiness runs
1000
670
  every registered indicator concurrently and replies `200` only when every
1001
671
  indicator reports `up`, `503` otherwise, naming every check either way.
1002
672
 
1003
- A failing indicator is named but not quoted: the response says which check is
1004
- 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
1005
676
  [the security model](#-security-model) for why, and
1006
- `health.exposeIndicatorErrors` if you want the message in the response while
1007
- debugging locally.
677
+ `health.exposeIndicatorErrors` if you want the details and the message in the
678
+ response while debugging locally.
1008
679
 
1009
680
  ```json
1010
681
  { "status": "ok", "checks": [{ "name": "redis", "status": "up" }] }
@@ -1052,22 +723,25 @@ export class HealthIndicatorsModule {}
1052
723
  ```
1053
724
 
1054
725
  A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
1055
- converted to a `down` entry with a safe, bounded diagnostic detail; it never
1056
- 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.
1057
728
 
1058
- ### 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.
1059
733
 
1060
- A readiness failure that nothing records is a `503` an orchestrator acts on with
1061
- an empty log behind it, and diagnosing it means reproducing it. That is easy to
1062
- arrive at without deciding to: probe paths are the highest-volume request a
1063
- backend serves, so they are usually excluded from the HTTP log surface, and a
1064
- well-written indicator returns `{ status: 'down' }` rather than throwing —
1065
- because readiness is typically unauthenticated and a driver's error carries
1066
- hosts, ports and sometimes credentials.
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()`.
1067
737
 
1068
- So the aggregator records it. It holds the last state of every check and reports
1069
- each **change** — never once per probe — to its own logger, and to a sink you
1070
- bind:
738
+ ### A failing check leaves a record
739
+
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:
1071
745
 
1072
746
  ```typescript
1073
747
  import { Injectable } from '@nestjs/common'
@@ -1090,19 +764,9 @@ export class HealthTransitionLogger implements IHealthTransitionSink {
1090
764
  }
1091
765
  ```
1092
766
 
1093
- Bind it under `BYMAX_HEALTH_TRANSITION_SINK` from your own `@Global()` module,
1094
- the same override pattern as the indicator token above.
1095
-
1096
- Binding nothing is supported, and is not the silent case: the transitions reach
1097
- Nest's logger instead, so a readiness failure always leaves a record. **Binding a
1098
- sink stands that line down** — both destinations are usually the same logger, so
1099
- keeping it would put two records of one transition side by side, which is the
1100
- noise this feature exists to remove. Your sink receives the cause as structured
1101
- data, strictly more than the line renders, so what reaches the log after that is
1102
- your decision rather than this package's.
1103
-
1104
- `transition.cause` distinguishes the three ways a check can be down, which the
1105
- `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:
1106
770
 
1107
771
  | `cause.kind` | Meaning | Carries |
1108
772
  | --------------- | -------------------------------------------------- | ----------- |
@@ -1110,26 +774,12 @@ your decision rather than this package's.
1110
774
  | `rejected` | The indicator rejected or threw. | `message` |
1111
775
  | `timed-out` | The aggregator gave up after `indicatorTimeoutMs`. | `timeoutMs` |
1112
776
 
1113
- `timed-out` is the one that cannot be observed anywhere else. An indicator the
1114
- aggregator abandoned is never told, so it reports nothing — and a hung
1115
- dependency is the common shape, not a refused one: a database under load, a
1116
- network partition and a paused container all hang, while a refusal comes back
1117
- immediately and would have been reported. That is why the rule lives in the
1118
- aggregator rather than in each backend: the obstacle is information, not effort.
1119
-
1120
- Two details worth knowing before you rely on it. A first observation that is
1121
- **failing** is reported, while a first observation that is healthy is not — a
1122
- process that boots against a dependency already down would otherwise look
1123
- healthy in the log forever, whereas announcing every healthy check would write a
1124
- line per dependency on every boot. And the cause is the one seen **at the
1125
- transition**: a dependency that stays down while its failure mode changes
1126
- underneath keeps the first cause, which is the cost of one line per outage
1127
- instead of one per probe.
1128
-
1129
- The sink is called synchronously on the readiness path, so keep it cheap. A
1130
- throw is caught and logged rather than failing the probe — readiness answering
1131
- `500` because its own logging broke would take a healthy deployment out of
1132
- 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.
1133
783
 
1134
784
  ### Discovered indicators
1135
785
 
@@ -1191,6 +841,14 @@ is `true`. If you enable metrics without installing it, the module fails fast
1191
841
  at boot with a descriptive error naming the missing package and the install
1192
842
  command, rather than a cryptic resolution failure at the first scrape.
1193
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
+
1194
852
  When timing and metrics are both enabled, an internal bridge feeds two
1195
853
  default HTTP metrics with a bounded label set:
1196
854
 
@@ -1238,45 +896,28 @@ BymaxCoreModule.forRoot({ metrics: { enabled: true } })
1238
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. |
1239
897
  | Off with metrics | With the metrics feature disabled, no contributor runs and `prom-client` is never loaded. |
1240
898
 
1241
- **Naming and labels.** Contributors share one registry and one namespace, so the
1242
- conventions are part of the contract:
1243
-
1244
- - Prefix every metric with `bymax_<library>_` (`bymax_queue_depth`,
1245
- `bymax_cache_hits_total`). An application's own metrics need no prefix — they
1246
- have no one to collide with but themselves.
1247
- - Follow Prometheus naming: `_total` for counters, `_seconds` for durations, base
1248
- units, no units in the middle of a name.
1249
- - Keep labels bounded. Route templates, never raw paths; status codes, never
1250
- messages. **Never** a tenant, user, or request id — one unbounded label is
1251
- enough to make a scrape endpoint the most expensive route in a service.
1252
- `tenantId` deserves naming twice: every library in this family is
1253
- tenant-aware, so it is the first label anyone reaches for and it is unbounded
1254
- by construction.
1255
- - **Publish the list.** A library that contributes metrics documents them in its
1256
- own README — name, type, labels. This package deliberately keeps no central
1257
- catalogue: a list of everyone else's metrics rots the moment a library ships a
1258
- new one. What it does require is that the list exists somewhere an operator
1259
- can find it.
1260
-
1261
- **Why these rules live here.** A Prometheus registry is a flat namespace, and
1262
- `prom-client` rejects a duplicate metric name. If two libraries independently
1263
- pick `bymax_operations_total`, the collision surfaces at the **consumer's** boot
1264
- — in an application neither library's CI ever assembles, as a hard failure, in
1265
- front of whoever wired the app. Neither library can test for it. A namespace
1266
- rule is the only thing that prevents it, and it can only be arbitrated by the
1267
- dependency they share, which is this package.
1268
-
1269
- The rules are documentation, not enforcement. This package could inspect the
1270
- registry around each contributor and reject an unprefixed name, but that would
1271
- need a per-contributor prefix on the contract, and it would wrongly reject the
1272
- case that matters most — an **application's own** contributor, which has no
1273
- 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.
1274
915
 
1275
916
  ## 📘 OpenAPI
1276
917
 
1277
- Disabled by default, and **never served in production**. Enabling it and calling
1278
- one helper during bootstrap publishes an interactive UI at `GET /docs` and the
1279
- 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`:
1280
921
 
1281
922
  ```typescript
1282
923
  // app.module.ts — configuration lives with every other feature
@@ -1299,14 +940,11 @@ void bootstrap()
1299
940
  ```
1300
941
 
1301
942
  > [!IMPORTANT]
1302
- > Call `applyBymaxOpenApi` **before** `app.listen()`. Mounting the document
1303
- > re-registers routes on the HTTP adapter, and doing that against an
1304
- > already-initialized Express 5 application replaces the router: the document
1305
- > appears and every other route in the application — yours and this package's
1306
- > 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.
1307
946
 
1308
- The call is safe to make unconditionally. It returns what it did, so a template
1309
- can emit it once and never branch:
947
+ The call is safe to make unconditionally. It returns what it did:
1310
948
 
1311
949
  | Result | Meaning |
1312
950
  | ------------------------------------------ | ------------------------------------------------------- |
@@ -1314,55 +952,25 @@ can emit it once and never branch:
1314
952
  | `{ mounted: false, reason: 'disabled' }` | `openapi.enabled` is off. |
1315
953
  | `{ mounted: false, reason: 'production' }` | The runtime is production. Nothing was built or served. |
1316
954
 
1317
- `discarded` lists every component entry a library contributed that the served
1318
- document already defined with a shape of its own — a `DiscardedComponent` per
1319
- entry, naming the contributor, the member and the name. It is empty when every
1320
- contribution was placed, and the boot log reports the same entries.
1321
-
1322
- A collision is not an error and does not stop the boot: the merge prefers
1323
- whatever holds the name already. That is usually your own declaration, and you
1324
- authored it on purpose — but it may also be a core schema this package
1325
- contributes under `openapi.includeCoreSchemas`, or an earlier contributor's
1326
- entry, since contributors merge in class-name order. The report names what was
1327
- dropped and by whom, not who owns the definition that won, so read the document
1328
- before deciding what to remove.
1329
-
1330
- It is reported because it is otherwise unobservable from either side: the
1331
- contributing library cannot tell its entry lost, the application cannot tell one
1332
- was offered, and the served document looks the same either way.
1333
-
1334
- A schema present only as `{"type":"object","properties":{}}` is **not** a
1335
- declaration for this purpose. `@nestjs/swagger` emits exactly that for every DTO
1336
- in a package built without its plugin, so such a name is filled from the
1337
- contribution rather than preferred over it, and does not appear in `discarded`.
1338
- An empty `required: []` and a `description` do not change that; anything else
1339
- does. `{"type":"object"}` and `{}` are deliberate declarations — "any object" and
1340
- "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.
1341
962
 
1342
963
  ### Production is a closed door
1343
964
 
1344
- `NODE_ENV` decides, and the decision is fail-closed: only `development` and
1345
- `test` are non-production. Any other value is production, and in production the
1346
- document is never built and never mounted, whatever the configuration says. The
1347
- guard runs twice, independently: the option resolver forces the feature off, and
1348
- the bootstrap helper classifies the runtime again without trusting that
1349
- resolution.
1350
-
1351
- **`NODE_ENV` cannot be overridden.** With it set to anything, no option serves
1352
- the document in a runtime it named production.
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.
1353
971
 
1354
- Enabling it in production is not an error, it is a no-op with a warning naming
1355
- the option that was ignored, so a single configuration can be shared across
1356
- environments.
1357
-
1358
- #### When your application validates its own environment variable
1359
-
1360
- Plenty of applications parse an `APP_ENV` through a config schema and never set
1361
- `NODE_ENV` at all. Those deployments used to be classified as production —
1362
- absence was the only evidence available — so the document was refused in an
1363
- environment that never asked for the refusal, with no way to answer back.
1364
-
1365
- 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**:
1366
974
 
1367
975
  ```typescript
1368
976
  BymaxCoreModule.forRootAsync({
@@ -1383,25 +991,13 @@ BymaxCoreModule.forRootAsync({
1383
991
  | unset, or blank | `staging` | **production** |
1384
992
  | unset, or blank | unset | **production** |
1385
993
 
1386
- Two properties are worth reading off that table. A declaration never overrules a
1387
- process that named its own environment — the first row is the one that matters,
1388
- and it is asserted in both guards rather than in one. And the declaration enters
1389
- the same fail-closed classification, so an unrecognized name is production like
1390
- any other; this is a second **source** for the value, never a second set of
1391
- rules.
1392
-
1393
- The narrowing is deliberate and worth naming rather than burying: in the one
1394
- case where the process declares nothing, the configuration a consumer bound does
1395
- decide the answer, because there is nothing else to decide it with. Replacing a
1396
- guess with a declaration is not the same as allowing an override — but it is a
1397
- real change to what the second guard depends on, and you should know it before
1398
- 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.
1399
996
 
1400
997
  ### Testing the enabled path under Jest
1401
998
 
1402
- `applyBymaxOpenApi` loads `@nestjs/swagger` through a dynamic `import()` — that
1403
- is what keeps the peer optional for everyone who never enables the document —
1404
- 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:
1405
1001
 
1406
1002
  ```jsonc
1407
1003
  // package.json
@@ -1410,15 +1006,98 @@ and Jest's module registry cannot service a dynamic import without a flag:
1410
1006
  }
1411
1007
  ```
1412
1008
 
1413
- Without it, only the **enabled** case fails, with `dynamic import callback
1414
- invoked without --experimental-vm-modules`. The disabled and production cases
1415
- never reach the loader and pass either way, which is what makes the omission
1416
- 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.
1417
1096
 
1418
1097
  ### What the library contributes
1419
1098
 
1420
- With `includeCoreSchemas` on, the document carries the contracts this package
1421
- 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:
1422
1101
 
1423
1102
  | Component | Describes |
1424
1103
  | --------------------------------------------------------------------- | --------------------------------------- |
@@ -1427,49 +1106,25 @@ already serves, so an operation can `$ref` them instead of redeclaring them:
1427
1106
  | `BymaxPageResult`, `BymaxPageMeta`, `BymaxCursorResult` | The offset and cursor page shapes |
1428
1107
  | `BymaxPageQueryPage`, `BymaxPageQueryLimit`, `BymaxCursorQueryCursor` | The pagination query parameters |
1429
1108
 
1430
- They are contributed as plain specification objects, not as decorated classes.
1431
- That is what keeps `@nestjs/swagger` genuinely optional: a decorator runs when
1432
- its class is defined, so describing these contracts with `@ApiProperty` would
1433
- load the peer in every application that imports this package, including the ones
1434
- that never enable the feature.
1435
-
1436
- A contributed entry never overwrites one the document already has: if you
1437
- document your own `BymaxErrorEnvelope`, yours wins.
1438
-
1439
- Contributing the schemas is only half of it — the operations **reference** them,
1440
- which is what a generated client actually reads:
1441
-
1442
- - every operation gains a `default` response pointing at `BymaxErrorEnvelope`,
1443
- because every error path in this package answers with that envelope. It is
1444
- attached as `default` rather than guessed per status code: this package knows
1445
- what an error looks like and does not know which statuses your handler emits.
1446
- It follows the feature: with `envelope.enabled` off, errors are shaped by Nest
1447
- or by your own handler, so nothing is documented;
1448
- - the health endpoints gain an explicit `200` pointing at `BymaxHealthResponse`,
1449
- which this package _does_ know precisely, having registered them itself.
1450
-
1451
- `@nestjs/swagger` emits a placeholder response for every handler — a `200` with
1452
- a description and no content — so "already documented" is judged on whether a
1453
- response declares a **shape**: one carrying `content` — or written as a bare
1454
- `$ref`, which points at a shape declared elsewhere — is yours and is left alone,
1455
- one without either gets filled in while keeping any description you wrote.
1456
-
1457
- Both halves are the same switch. Referencing a schema that was not contributed
1458
- would leave a dangling `$ref`, and a document that resolves nowhere is worse
1459
- 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.
1460
1112
 
1461
- ### 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.
1462
1120
 
1463
- A library that ships controllers cannot document them itself. Decorating them
1464
- with `@nestjs/swagger` would load that peer in every application importing the
1465
- library, including the ones that never build a document — and a consumer-side
1466
- map keyed by path does not work either, because a library mounted through
1467
- `RouterModule.register` does not know its own final paths: the same route is
1468
- `/auth/login` in one deployment and `/api/v2/identity/login` in another, from one
1469
- build.
1121
+ ### A library can describe its own routes
1470
1122
 
1471
- So a library marks a provider and returns fragments keyed by **handler
1472
- 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:
1473
1128
 
1474
1129
  ```ts
1475
1130
  import { Injectable } from '@nestjs/common'
@@ -1513,10 +1168,8 @@ export class AuthOpenApi implements IOpenApiContributor {
1513
1168
  }
1514
1169
  ```
1515
1170
 
1516
- Nothing is wired by the application: enabling the document runs the scan, and a
1517
- library that is never imported contributes nothing.
1518
-
1519
- #### 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.
1520
1173
 
1521
1174
  | Rule | Behavior |
1522
1175
  | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -1528,60 +1181,26 @@ library that is never imported contributes nothing.
1528
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. |
1529
1182
  | Off with the document | With `openapi.enabled` false, no contributor runs. |
1530
1183
 
1531
- **Precedence, weakest first:** what this package infers about its own routes,
1532
- then what a library contributed, then what the consumer configured, and above
1533
- all of them whatever the operation already declared — a decorated handler is the
1534
- consumer speaking directly and is never overwritten. So a deployment can always
1535
- overrule a dependency's description of its own routes through
1536
- `operationSecurity`.
1537
-
1538
- **Operation ids are untouched.** This package installs an operation-id factory
1539
- to learn which handler produced which operation, and delegates the id string —
1540
- to `openapi.operationIdFactory` when you set one, to the format `@nestjs/swagger`
1541
- itself produces otherwise. A client generated from your document before adopting
1542
- this keeps working after.
1543
-
1544
- **The fragment shape is translatable to an OpenAPI Overlay, deliberately.** The
1545
- [Overlay Specification](https://spec.openapis.org/overlay/v1.0.0.html) is the
1546
- OpenAPI Initiative's format for describing changes to a document, and a
1547
- contributor's fragments are that in all but notation: a handler key resolves to
1548
- an operation id, which resolves to a JSONPath target, which is what an overlay
1549
- action addresses. Overlays are not used as the mechanism — their targets are
1550
- paths, which is the one thing a library mounted through `RouterModule.register`
1551
- cannot write, and a JSONPath engine would be a runtime dependency this package
1552
- does not have. But the translation is mechanical, so a tool that emits a
1553
- library's contribution as a standalone overlay can exist the day a pipeline
1554
- wants one. No such tool ships here, and none is promised.
1555
-
1556
- **Deriving the fragments is the library's business, not this package's.** A
1557
- library that wants its schemas to track its own validation decorators should
1558
- generate them in its own build or test suite, where that dependency already
1559
- exists, and commit the result — with a test asserting generated matches
1560
- committed, so drift fails in the repository that caused it. This package takes
1561
- no dependency on any validation library and merges what it is given. An
1562
- application's own DTOs need none of this: `@nestjs/swagger`'s CLI plugin already
1563
- 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.
1564
1193
 
1565
1194
  ### The document describes _this_ deployment
1566
1195
 
1567
- A feature you turned off has its routes removed from the document. With
1568
- `metrics: { enabled: false }` the runtime answers `GET /metrics` with a 404
1569
- envelope — on `forRootAsync` the controller is mounted unconditionally and
1570
- guards each request, because route metadata is fixed before the async options
1571
- resolve — so a document still listing the route would describe something this
1572
- deployment does not serve. The filter reads the same resolved snapshot the
1573
- runtime guard reads, which is what keeps the two from drifting.
1574
-
1575
- Your own routes are safe from it. `@nestjs/swagger` documents paths including
1576
- `app.setGlobalPrefix()`, so the match cannot be on equality — but a bare tail
1577
- match would treat `/tenants/{id}/health/live` as this package's probe and delete
1578
- it from your document. What separates the two is that a global prefix prefixes
1579
- _everything_: a tail match counts only when whatever precedes it also precedes
1580
- every other path in the document. `/api/v2` qualifies; `/tenants/{id}` does not,
1581
- because it does not prefix `/invoices`.
1582
-
1583
- This package also documents the security of the three routes it owns, without
1584
- 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:
1585
1204
 
1586
1205
  | Route | Documented as |
1587
1206
  | ----------------------------------- | ------------------------------------------------------- |
@@ -1589,19 +1208,11 @@ being asked:
1589
1208
  | `GET /metrics`, token set | Bearer-protected |
1590
1209
  | `GET /metrics`, no token | Public (`security: []`), when a document default exists |
1591
1210
 
1592
- The probes are polled by an orchestrator holding no credential, and the scrape
1593
- endpoint is protected exactly when you configured a token — this package owns
1594
- both the route and the option, so you should not have to restate either. Your
1595
- own `operationSecurity` entry still wins.
1596
-
1597
- The last row matters more than it looks. Without a token the scrape endpoint
1598
- answers anyone, which is the deliberate "protected at the edge" arrangement — and
1599
- an open route must **say** it is open rather than inherit your document default.
1600
- Of the two ways to describe a route wrongly, this is the direction that hides:
1601
- documenting a guarded route as open fails loudly at the first generated client
1602
- that omits the credential and gets a `401`, while documenting an open route as
1603
- guarded fails nowhere at all, and hands the wrong answer to whoever opened the
1604
- 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.
1605
1216
 
1606
1217
  ## 🧵 Trace correlation
1607
1218
 
@@ -1625,29 +1236,20 @@ collector setup already does, and doing it twice produces two spans per request.
1625
1236
  `@opentelemetry/api` is an optional peer, loaded once at bootstrap and only when
1626
1237
  the feature is on.
1627
1238
 
1628
- A request with nothing recording, or an all-zero span context, resolves to no
1629
- trace at all: the fields are absent rather than set to a sentinel, so a sink
1630
- never has to recognize a string of zeros.
1631
-
1632
- ### 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.
1633
1241
 
1634
- `exposeTraceId` is off by default. A trace id is not a secret, but in a response
1635
- body it tells a caller that a tracing backend exists and hands them the
1636
- identifier that ties their request to everything else in that trace. Support
1637
- teams often want exactly that, which is why the option exists; it is opt-in so it
1638
- is a decision rather than a side effect. With it off, the identifiers still reach
1639
- 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.
1640
1245
 
1641
1246
  ## 🔗 Integration with `@bymax-one/nest-logger`
1642
1247
 
1643
- Pairing this package with `@bymax-one/nest-logger` gives correlated logs and
1644
- error responses without either package importing the other. nest-logger's
1645
- request-id middleware stores the id under the `requestId` key of
1646
- `LogContextService`. That service is not itself an `ICorrelationIdProvider` —
1647
- it has no `getCorrelationId()` — so adapt it with a factory. `useExisting` on
1648
- it binds an object without that method, and the filter then reads no id and
1649
- reports nothing. Bind both tokens from a `@Global()` module of your own so they
1650
- 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:
1651
1253
 
1652
1254
  ```typescript
1653
1255
  import { Global, Module } from '@nestjs/common'
@@ -1677,9 +1279,9 @@ import {
1677
1279
  export class ObservabilityModule {}
1678
1280
  ```
1679
1281
 
1680
- The id exists only once nest-logger's request-id middleware runs, and
1681
- `BymaxLoggerModule` mounts none on its own. Mount it in `main.ts`, or call
1682
- `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()`:
1683
1285
 
1684
1286
  > **Express only.** nest-logger's request-id middleware supports
1685
1287
  > `@nestjs/platform-express`; the Fastify adapter is unsupported, so do not
@@ -1698,17 +1300,14 @@ async function bootstrap(): Promise<void> {
1698
1300
  void bootstrap()
1699
1301
  ```
1700
1302
 
1701
- Every error envelope now carries the same correlation id your logs do, and the
1702
- cause of every collapsed `500` is logged with it (`PinoUnexpectedErrorSink`
1703
- needs `@bymax-one/nest-logger` 1.4.1 or later). The
1704
- same `@Global()`-module pattern is how every pluggable token in this package
1705
- is overridden: `BYMAX_TIMING_SINK` and `BYMAX_HEALTH_INDICATORS` follow it
1706
- 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.
1707
1306
 
1708
1307
  ## 🧱 HTTP Floors
1709
1308
 
1710
1309
  `applyHttpFloors` (from `@bymax-one/nest-core/http`) is the HTTP-level protection that every
1711
- backend wants and none should tune per deployment. One call, before `app.listen()`:
1310
+ backend wants and none should tune per deployment. One call, before `app.init()` and `app.listen()`:
1712
1311
 
1713
1312
  ```typescript
1714
1313
  import { NestFactory } from '@nestjs/core'
@@ -1723,14 +1322,19 @@ async function bootstrap(): Promise<void> {
1723
1322
  void bootstrap()
1724
1323
  ```
1725
1324
 
1726
- | What | Default |
1727
- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1728
- | Security headers | `helmet()` with `strictTransportSecurity: false` (TLS is normally ended in front of the app, which owns HSTS). Pass `helmet: { ... }` to tune it, for example the CSP of a browser-serving backend, or `helmet: false` to leave it out |
1729
- | Request bodies | The JSON and urlencoded parsers refuse a compressed body (`Content-Encoding`) with `415` instead of decompressing it, on every route and before routing. Size limits and the extended form syntax keep the parsers' defaults |
1730
- | `headersTimeoutMs` | `15000`: the longest a client may take to send its complete header block. Node's own default is 60 s, which leaves a header block sent a byte at a time open as a cheap denial of service |
1731
- | `requestTimeoutMs` | `30000`: the longest a single request, headers and body together, may take. Node's default is 5 minutes. Must not be below `headersTimeoutMs` |
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` |
1732
1331
 
1733
- `keepAliveTimeout` is left at Node's 5 s: raising it only lengthens idle-socket retention.
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.
1734
1338
 
1735
1339
  Node enforces both timeouts from a sweep over the open connections, not from a timer per
1736
1340
  connection, and the sweep runs every 30 s by default: measured, a 1 s header timeout left a
@@ -1742,11 +1346,11 @@ starts listening, and the helper throws if the server is already listening. The
1742
1346
  open connection each time, so a server holding very many sockets can set
1743
1347
  `connectionsCheckingIntervalMs` higher, at the cost of cutting up to that long after the timeout.
1744
1348
 
1745
- **Order is part of the contract.** Call it right after `applyAccessLog` from
1349
+ **Order is part of the contract.** Call it before `app.init()` and right after `applyAccessLog` from
1746
1350
  `@bymax-one/nest-logger`, and before everything that reads the request: helmet is mounted first,
1747
1351
  so even a request the body parsers refuse carries the headers, and the parsers are registered
1748
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
1749
- setting and `applyBymaxOpenApi` stay with you and go after it:
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:
1750
1354
  `applyAccessLog` → `applyHttpFloors` → cookie parser → trusted proxies → `applyBymaxOpenApi`.
1751
1355
 
1752
1356
  Express only; on another adapter it throws before applying anything. `helmet` is an optional
@@ -1785,141 +1389,105 @@ BYMAX_CORRELATION_PROVIDER no module,
1785
1389
  comes from) script or a test
1786
1390
  ```
1787
1391
 
1788
- Four opt-in integrations attach to that spine rather than adding columns to it:
1789
-
1790
- ```
1791
- health/ + @BymaxHealthIndicator() → a marked provider joins readiness
1792
- metrics/ + @BymaxMetricsContributor() → a marked provider publishes on the registry
1793
- envelope/ + telemetry → the active trace id reaches the envelope and the seam
1794
- timing/ + telemetry → the sample carries traceId and spanId
1795
- ./openapi → one bootstrap call serves the document, in development
1796
- ```
1797
-
1798
- Each feature registers only when it is on. Turning metrics off does not leave a
1799
- disabled provider in the container — it leaves no provider, and `prom-client` is
1800
- never imported, which is why it can stay an optional peer. The same holds for
1801
- `@nestjs/swagger` and `@opentelemetry/api`: the release gate loads the packed
1802
- tarball and fails if any of the three is reachable with its feature off.
1803
-
1804
- Nothing here holds state across requests. The timing middleware emits and forgets;
1805
- the health service runs the indicators the app registered and folds their results;
1806
- 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.
1807
1397
 
1808
1398
  ### Design Principles
1809
1399
 
1810
- | Principle | Description |
1811
- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1812
- | 🎭 **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 |
1813
- | 🔌 **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 |
1814
- | 🧮 **Pure where it can be** | Pagination is functions on their own subpath — no provider, no module, no container. A script can use it |
1815
- | 🧊 **Zero runtime dependencies** | `dependencies` is `{}`. Every version you install is one you chose |
1816
- | 🩺 **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 |
1817
- | 🧬 **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 |
1818
1408
 
1819
1409
  ---
1820
1410
 
1821
1411
  ## 🔐 Security Model
1822
1412
 
1823
- This library writes the response a client sees when something fails, and exposes the
1824
- endpoints an operator scrapes. Its security contract is about what those two surfaces
1825
- 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.
1826
1415
 
1827
1416
  ### An error envelope is an exfiltration surface
1828
1417
 
1829
- The filter's job is to make every failure look the same to a client, so an unknown error
1830
- becomes a generic 500 whose body carries the fixed envelope fields and the generic message,
1831
- and not the cause unless `exposeInternals` is on. The original message and stack go to the
1832
- `BYMAX_UNEXPECTED_ERROR_SINK` seam when you bind one.
1833
- `envelope.exposeInternals` puts them in the body and exists for local debugging — its own
1834
- 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`.
1835
1422
 
1836
1423
  ### The readiness response names the failure, it does not describe it
1837
1424
 
1838
- A failing indicator produces `{ name, status: 'down' }` and nothing else. The reason goes
1839
- to the logger.
1840
-
1841
- That split is deliberate. Readiness is usually unauthenticated and reachable by whatever
1842
- probes it, and an indicator rarely authors its own failure text — it writes
1843
- `await this.redis.ping()` and lets the driver's error propagate. Driver errors carry hosts,
1844
- ports, and in the case of a connection string, credentials. Putting that text in the
1845
- response publishes it to everyone who can reach the endpoint; putting it in the log keeps
1846
- it where access is already controlled, without losing the diagnostic.
1847
-
1848
- `health.exposeIndicatorErrors` puts the message back in the response for local debugging.
1849
- It defaults to `false`, and its documentation says the same thing `envelope.exposeInternals`
1850
- does: never enable it in production. The two options are the same decision, made the same
1851
- way, about the same risk.
1852
-
1853
- What reaches the log is bounded the same way it always was: the top-level `Error#message`
1854
- only — never the raw error, its stack, or a nested cause — truncated at 300 characters.
1855
- A slow indicator is converted to `down` by the aggregator rather than hanging the probe,
1856
- and its `timedOutAfterMs` stays in the response either way, because that number is one this
1857
- 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.
1858
1434
 
1859
1435
  ### A metric an attack cannot be seen in is not a control
1860
1436
 
1861
- Request timing is counted as a security signal, not a performance one. A
1862
- credential-stuffing run is a flood of `401`s, a privilege probe a flood of
1863
- `403`s, route enumeration a flood of `404`s, and a rate limiter doing its job a
1864
- flood of `429`s. None of those reaches a handler, so a recorder placed after the
1865
- guards sees none of them — and an operator watching a flat error graph concludes
1866
- nothing is happening. The recorder is middleware for that reason, and every
1867
- 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.
1868
1441
 
1869
1442
  ### A route label is attacker-controlled input
1870
1443
 
1871
- The label on a timing sample comes from the matched route **template**, and a
1872
- request that matched nothing is recorded under the single constant
1873
- `UNMATCHED_ROUTE` (`<unmatched>`) rather than the path that was asked for.
1874
- Following the path would let anyone mint one Prometheus time series per probe:
1875
- a scan would grow the registry without bound, make the scrape endpoint the most
1876
- expensive route in the service, and end as an out-of-memory kill. The bound is
1877
- 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.
1878
1448
 
1879
1449
  ### Cursors are opaque, not secret
1880
1450
 
1881
- `encodeCursor` produces a token a client can round-trip; it is not encrypted and not
1882
- authenticated. Do not put anything in a cursor that the client is not allowed to read, and
1883
- 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.
1884
1454
 
1885
1455
  ### The metrics endpoint is a route like any other
1886
1456
 
1887
- It is off by default. When it is on, nothing in this library authenticates it — apply the
1888
- 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.
1889
1459
 
1890
1460
  ### The OpenAPI document does not exist in production
1891
1461
 
1892
- A published document is a map of every route, parameter and error shape an application has —
1893
- useful to a developer, and just as useful to anyone probing the service. So unlike the metrics
1894
- endpoint, it is not left to a guard: it is refused outright whenever the runtime is not
1895
- positively `development` or `test`, in two independent layers. **`NODE_ENV` cannot be
1896
- overridden** — with it set to anything, no option serves the document in a runtime it named
1897
- production. A runtime that declares nothing is classified from the application's own
1898
- [`environment`](#environment) when it supplied one, and counts as production otherwise, because
1899
- 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.
1900
1468
 
1901
1469
  ---
1902
1470
 
1903
1471
  ## 🛡️ Security Table
1904
1472
 
1905
- | Layer | Implementation |
1906
- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1907
- | Error responses | One shape for everything; unknown errors become a generic 500 |
1908
- | Internals | Message and stack handed to the unexpected-error sink when bound, in the body only under `exposeInternals` (default `false`) |
1909
- | Health output | The response names which indicator is down and nothing more; the reason goes to the logger. `exposeIndicatorErrors` (default `false`) puts it back in the response for debugging |
1910
- | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
1911
- | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
1912
- | Body nesting | Opt-in `bodyDepth` refuses a body nested deeper than `maxDepth` with a `400`, so a few kilobytes of nested arrays cannot overflow a recursive validator into a `500` |
1913
- | HTTP floors | `applyHttpFloors`: compressed request bodies refused with `415`, header and request timeouts of 15 s and 30 s, `helmet` headers without HSTS |
1914
- | Request accounting | Every closed request is counted, including the ones a guard rejected (`401`/`403`/`429`) and the ones that matched no route (`404`), so an attack in progress moves the graph |
1915
- | Route labels | Taken from the matched template; an unmatched request records the constant `<unmatched>`, never the requested path, so a scan cannot grow the metric registry without bound |
1916
- | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
1917
- | Metrics | Opt-in; `prom-client` never imported while it is off |
1918
- | OpenAPI | Opt-in and development-only; refused in production by two independent guards, `@nestjs/swagger` never imported while it is off |
1919
- | Discovered checks | Matched by an explicit marker, never by shape; off by default, because it lets an imported library fail your readiness probe |
1920
- | Contributed metrics | Called only when marked; a name collision fails the boot naming the contributor rather than half-populating a scrape |
1921
- | Trace ids | Read-only, never used as a metric label; published in a response body only under `telemetry.exposeTraceId` (default off) |
1922
- | 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 |
1923
1491
 
1924
1492
  > [!IMPORTANT]
1925
1493
  > **`exposeInternals` is a debugging switch, not a verbosity setting.** With it on,
@@ -1935,8 +1503,10 @@ the deployment nobody configured is the one most likely to be exposed.
1935
1503
  - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `NestModule.configure`)
1936
1504
  - **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
1937
1505
  - **Optional peers:** `prom-client ^15` when metrics are enabled, `@nestjs/swagger ^11` when
1938
- OpenAPI is enabled, `@opentelemetry/api ^1.9` when trace correlation is enabled — none is
1939
- 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
1940
1510
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
1941
1511
  - **Tests:** Jest (unit + e2e over real Nest applications, Express **and** Fastify) + Stryker (mutation)
1942
1512
  - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
@@ -1949,10 +1519,9 @@ This library sits in the path of every request and every failure of every servic
1949
1519
  installs it, so the suite is held to a bar beyond "the tests pass".
1950
1520
 
1951
1521
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
1952
- - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
1953
- `break: 95`; every killable survivor was killed by a strengthened test, with no production
1954
- change, and the nine equivalents that no test can kill each carry their reason on the line
1955
- 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))
1956
1525
  - ✅ **End-to-end against real applications, on both platforms** — the filter, the middleware,
1957
1526
  the health and metrics routes, the served OpenAPI document, discovered indicators,
1958
1527
  contributed metrics and trace correlation are all exercised through a booted Nest app, not
@@ -1970,7 +1539,7 @@ installs it, so the suite is held to a bar beyond "the tests pass".
1970
1539
  pnpm test # unit suite
1971
1540
  pnpm test:cov # unit suite with the 100% coverage gate
1972
1541
  pnpm test:e2e # end-to-end against real Nest applications (Express and Fastify)
1973
- pnpm mutation # Stryker mutation testing (break: 95)
1542
+ pnpm mutation # Stryker mutation testing (break: 100)
1974
1543
  pnpm typecheck # tsc strict check
1975
1544
  pnpm lint # ESLint
1976
1545
  ```
@@ -2041,10 +1610,10 @@ in the sections above.
2041
1610
 
2042
1611
  ### `./http`
2043
1612
 
2044
- | Export | Kind | Description |
2045
- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------------- |
2046
- | `applyHttpFloors` | function | Applies the headers, the body-parser floor and the timeouts; call it before `listen()`. |
2047
- | `HttpFloorsOptions`, `HttpFloorsHelmetOptions` | types | The `helmet`, `headersTimeoutMs` and `requestTimeoutMs` options. |
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. |
2048
1617
 
2049
1618
  ### `./openapi`
2050
1619
 
@@ -2058,24 +1627,28 @@ in the sections above.
2058
1627
  | `BYMAX_OPENAPI_CONTRACT_VERSION` | constant | The version of the fragment contract, currently `1`; a fragment declares it as `contractVersion`. |
2059
1628
  | `IOpenApiContributor`, `OpenApiFragment`, `OpenApiFragmentObject`, `OpenApiHandlerKey` | types | The contributor contract and the shape of what it returns. |
2060
1629
 
2061
- ## 🧩 Compatibility
2062
-
2063
- - Node.js `>= 24`
2064
- - NestJS `^11`
2065
- - Express and Fastify, both covered end to end. The accessors are
2066
- framework-agnostic for path, method and status, and the module absorbs the
2067
- two places the platforms genuinely differ: the route mount, and the fact that
2068
- Nest runs middleware on Fastify through `@fastify/middie`, which strips the
2069
- route metadata off the object the middleware receives.
2070
- - GraphQL and RPC execution contexts are out of scope for the error envelope and
2071
- the request timing in this release; both pass errors and requests through
2072
- 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.
2073
1647
 
2074
1648
  ## 🤝 Contributing
2075
1649
 
2076
1650
  Pull requests are welcome. Please open an issue first for significant changes.
2077
1651
 
2078
- - Read [`docs/technical_specification.md`](./docs/technical_specification.md) for architecture decisions.
2079
1652
  - Run the full gate listed in [`CONTRIBUTING.md`](./CONTRIBUTING.md) before opening a PR.
2080
1653
  - Conventional Commits are enforced by `commitlint.config.cjs`.
2081
1654