@bymax-one/nest-core 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,7 +11,146 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
- ## [1.3.2] - 2026-08-11
14
+ ## [1.4.0] - 2026-08-13
15
+
16
+ HTTP metrics were blind to every request that did not reach a handler. Nest runs
17
+ **middleware → guards → interceptors → pipes → handler**, and the recorder was an
18
+ interceptor, so authentication failures, authorization failures, throttled
19
+ requests and unknown paths were never counted. Measured on a running
20
+ application, three requests — a handler success, a guard rejection, an unknown
21
+ path — produced **one** sample. A deployment could be under a credential-stuffing
22
+ run, a privilege probe or route enumeration with a flat error graph, which makes
23
+ this a security fix rather than an observability improvement.
24
+
25
+ **Apply to a derived backend:** bump the dependency. No code change is needed.
26
+ Expect new `401`/`403`/`429` series on routes that already existed, a new
27
+ `route="<unmatched>"` series for `404`s, and a **lower success rate** — the
28
+ denominator finally includes the rejections. Existing `status_code="200"` series
29
+ keep their values.
30
+
31
+ ### Security
32
+
33
+ - **Requests rejected before a handler are now counted.** The recorder moved
34
+ from `APP_INTERCEPTOR` to middleware (`BymaxTimingMiddleware`), applied to
35
+ every route when `timing.enabled` is `true`. Nest runs middleware, then
36
+ guards, then interceptors: a request a guard rejects never reached
37
+ `intercept()`, and a request matching no route never reached a controller.
38
+ Measured on a real application, three requests — a handler success, a guard
39
+ rejection and an unknown path — produced exactly **one** sample. `401`, `403`,
40
+ `429` and `404` were all invisible, which is to say a deployment could be under
41
+ a credential-stuffing run, a privilege probe or route enumeration with a flat
42
+ error graph. All six cases are now counted, and the sample is emitted on the
43
+ response's `'close'` event rather than `'finish'`, so a client that hangs up
44
+ mid-request — what a scanner does — is counted too.
45
+ - **The root path is recorded.** On Express the middleware is mounted at `'/'`
46
+ rather than through a wildcard pattern. The unbraced `'*splat'` skips the root outright,
47
+ and the braced `'{*splat}'` that Nest 11's migration guide prescribes stops
48
+ matching the _prefixed_ root once an application calls `setGlobalPrefix` —
49
+ which production applications almost always do. That was reported as
50
+ nest#14520 and fixed by nest#14522, whose regression test covers Fastify;
51
+ measured on `@nestjs/core` 11.1.28 with the Express adapter, the prefixed root
52
+ still reaches no middleware while the route itself answers `200`. Both
53
+ patterns were measured against the mount, which matched every path in both
54
+ configurations. Fastify needs the opposite choice — see the next entry. One
55
+ limit remains and is documented: module middleware is scoped to the global
56
+ prefix, so a request outside it entirely reaches no middleware.
57
+ - **Fastify records the same labels as Express**, which the documented support
58
+ for both platforms had been promising without any test behind it. Nest runs
59
+ middleware on Fastify through `@fastify/middie`, whose `runMiddie` calls
60
+ `run(req.raw, reply.raw, next)` and copies only `id`, `hostname`, `protocol`,
61
+ `ip`, `ips`, `log`, `query` and `body` onto that raw request — never
62
+ `routeOptions`. The recorder therefore saw no route metadata at all and would
63
+ have labelled every Fastify request `<unmatched>`, destroying the per-route
64
+ breakdown and making a scan indistinguishable from ordinary traffic. Worse,
65
+ `forRoutes('/')` is a mount on Express but an **exact match** on Fastify, so
66
+ most requests produced no sample whatsoever. The module now selects the mount
67
+ per adapter and registers an `onRequest` hook on Fastify that carries the
68
+ resolved template to the recorder. Covered by a new Fastify end-to-end suite.
69
+ - **Unmatched requests record a bounded label.** A request that matched no route
70
+ is recorded as `<unmatched>` (exported as `UNMATCHED_ROUTE`), never the
71
+ requested path. The previous raw-URL fallback would have let anyone mint one
72
+ Prometheus time series per probe, so counting scanner traffic under it would
73
+ have turned this fix into a memory-exhaustion vector.
74
+
75
+ ### Changed
76
+
77
+ - **`ITimingSink` implementations now receive more samples**, including requests
78
+ that never reached a handler. Sinks that assumed "one sample per completed
79
+ request" should expect "one sample per closed request". The built-in metrics
80
+ bridge needs no change; a dashboard filtering on `2xx` sees its numbers
81
+ unchanged and its error rates become correct.
82
+ - **No status is relabelled**, and that is deliberate. A client that hangs up
83
+ mid-handler was already counted — destroying the socket does not cancel the
84
+ JavaScript already running, so the handler finished and the interceptor
85
+ recorded an ordinary `200` — and it still is, once, under the same `200`.
86
+ Introducing a sentinel status for aborts would rewrite the value of
87
+ `status_code="200"` series that already exist in every deployment, moving
88
+ error-rate panels with no change in traffic. Whether an abort deserves its own
89
+ status is a separate decision from whether the request is counted at all, and
90
+ this release makes only the second one.
91
+ - **The timing recorder is registered once, not twice.** The middleware
92
+ **replaced** the interceptor rather than joining it — two recorders would
93
+ double every rate an alert threshold is tuned against, which is a quieter
94
+ failure than the one being fixed.
95
+
96
+ ### Deprecated
97
+
98
+ - **`TimingInterceptor`** is superseded by `BymaxTimingMiddleware` and is no
99
+ longer registered by `BymaxCoreModule`. It stays exported so an application
100
+ that wired it by hand keeps compiling; registering it alongside the middleware
101
+ records a second sample for every request that reaches a handler.
102
+
103
+ ### Added
104
+
105
+ - **`BymaxTimingMiddleware` and `UNMATCHED_ROUTE`** are exported from the package
106
+ root.
107
+ - **A library can describe its own routes in a consumer's document.** A provider
108
+ marked `@BymaxOpenApiContributor()` returns OpenAPI fragments keyed by handler
109
+ identity — `'AuthController.login'` — and they are merged onto the operations
110
+ those handlers produced. It exists because the two obvious alternatives do
111
+ not work: decorating a library's controllers with `@nestjs/swagger` would load
112
+ that peer in every application importing the library, and a consumer-side map
113
+ keyed by path cannot be written by a library mounted through
114
+ `RouterModule.register`, which does not know its own final paths.
115
+ - **`openapi.operationIdFactory`**, plus the exported `OpenApiOperationIdFactory`
116
+ type. This package now always installs a factory so it can learn which handler
117
+ produced which operation, and **delegates the id string** — to this option when
118
+ set, to the format `@nestjs/swagger` itself produces otherwise. Choosing the id
119
+ instead would have renamed every operation in every published document and
120
+ broken any client generated from one; a test compares both documents to keep
121
+ that true if the peer's format ever changes.
122
+ - **The contract types** `IOpenApiContributor`, `OpenApiFragment`,
123
+ `OpenApiFragmentObject` and `OpenApiHandlerKey`, exported from `./openapi` so a
124
+ sibling library can target them at its own compile time.
125
+ - **`BYMAX_OPENAPI_CONTRACT_VERSION`**, and a required `contractVersion` on every
126
+ fragment. A fragment crosses a boundary between independently released
127
+ packages, and on that boundary compile-time types protect nothing: each side
128
+ type-checks against its own installed copy, so only the value travelling at
129
+ runtime can say which shape it is. A revision this package does not speak fails
130
+ the build naming both. Required rather than inferred from absence, following
131
+ the pattern Kubernetes objects use for `apiVersion` — an optional discriminator
132
+ is unambiguous only while exactly one revision exists, which is precisely when
133
+ nobody checks it.
134
+
135
+ ### Fixed
136
+
137
+ - **`DiscoveryModule` is imported when only the document is enabled.** It was
138
+ imported on the synchronous registration path only when readiness discovery or
139
+ metrics could scan, so an application enabling nothing but OpenAPI had no
140
+ scanner — and a library's description of its own routes would have been
141
+ silently dropped.
142
+
143
+ ### Notes
144
+
145
+ - Deriving fragments from validation decorators is deliberately **not** in this
146
+ package: it takes no dependency on any validation library. A library that
147
+ wants its schemas to track its own decorators generates them in its own build,
148
+ where that dependency already exists, and commits the result with a test
149
+ asserting generated matches committed — so drift fails in the repository that
150
+ caused it. An application's own DTOs need none of this; `@nestjs/swagger`'s CLI
151
+ plugin already derives them, which is the route a precompiled library lacks.
152
+
153
+ ## [1.3.2] - 2026-08-12
15
154
 
16
155
  A consumer audit of the served document found that it described the library's
17
156
  promises rather than the deployment: routes of features that were switched off
@@ -509,4 +648,5 @@ have regressed from. They are kept because the reasoning is worth having.
509
648
  [1.2.2]: https://github.com/bymaxone/nest-core/compare/v1.2.1...v1.2.2
510
649
  [1.2.1]: https://github.com/bymaxone/nest-core/compare/v1.2.0...v1.2.1
511
650
  [1.2.0]: https://github.com/bymaxone/nest-core/compare/v1.1.1...v1.2.0
512
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.3.2...HEAD
651
+ [1.4.0]: https://github.com/bymaxone/nest-core/compare/v1.3.2...v1.4.0
652
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.4.0...HEAD
package/README.md CHANGED
@@ -75,7 +75,7 @@ never loads its peer, which the release gate asserts against the packed tarball.
75
75
 
76
76
  ### ⏱️ Observability
77
77
 
78
- - ✅ **Request timing** — one sample per completed request, handed to the sink you register;
78
+ - ✅ **Request timing** — one sample per closed request, rejections included, handed to the sink you register;
79
79
  the library stores nothing itself
80
80
  - ✅ **Slow-request flag** — samples above `slowRequestThresholdMs` are marked, so a sink can
81
81
  branch without re-deriving the threshold
@@ -120,7 +120,7 @@ never loads its peer, which the release gate asserts against the packed tarball.
120
120
 
121
121
  | Subpath | Contents |
122
122
  | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
- | `.` | `BymaxCoreModule`, the error envelope and its code catalog, the timing interceptor, the DI tokens, and every option type |
123
+ | `.` | `BymaxCoreModule`, the error envelope and its code catalog, the request-timing middleware, the DI tokens, and every option type |
124
124
  | `./pagination` | `normalizePageQuery`, `buildPageResult`, `normalizeCursorQuery`, `buildCursorResult`, `encodeCursor`, `decodeCursor` and their types — pure functions, no NestJS provider involved |
125
125
  | `./health` | `IHealthIndicator`, `HealthResponse`, the indicator contracts and the `@BymaxHealthIndicator()` marker, so a package that only implements an indicator does not import the module |
126
126
  | `./metrics` | `IMetricsContributor` and the `@BymaxMetricsContributor()` marker, so a package that only publishes metrics imports neither the module nor its DI tokens. The one subpath whose types name `prom-client`, which anyone implementing the contract already depends on |
@@ -213,7 +213,7 @@ falls back to the documented default. Pass only what you want to change.
213
213
 
214
214
  | Option | Type | Default | Description |
215
215
  | ------------------------ | --------- | ------- | ------------------------------------------------------------------------------ |
216
- | `enabled` | `boolean` | `true` | Registers the request-timing interceptor. |
216
+ | `enabled` | `boolean` | `true` | Applies the request-timing middleware to every route. |
217
217
  | `slowRequestThresholdMs` | `number` | unset | Samples above this duration are flagged `slow: true`. Absent means never slow. |
218
218
 
219
219
  ### `health`
@@ -272,19 +272,20 @@ BymaxCoreModule.forRoot({
272
272
 
273
273
  ### `openapi`
274
274
 
275
- | Option | Type | Default | Description |
276
- | -------------------- | ------------------------- | ------------- | ------------------------------------------------------------------------------ |
277
- | `enabled` | `boolean` | `false` | Builds and serves the document. Ignored in production, where it is always off. |
278
- | `path` | `string` | `'docs'` | Route serving the interactive UI. |
279
- | `jsonPath` | `string` | `'docs-json'` | Route serving the raw JSON document. |
280
- | `title` | `string` | `'API'` | Document title. |
281
- | `description` | `string` | `''` | Document description. |
282
- | `version` | `string` | `'1.0.0'` | Document version, independent of the package version. |
283
- | `servers` | `{ url, description? }[]` | `[]` | Servers advertised by the document. |
284
- | `securitySchemes` | `Record<string, object>` | `{}` | Security schemes copied into the document's components. |
285
- | `security` | `SecurityRequirement[]` | `[]` | The requirement every operation carries unless it says otherwise. |
286
- | `operationSecurity` | `OperationSecurityMap` | `{}` | Per-operation overrides. An empty array marks that operation public. |
287
- | `includeCoreSchemas` | `boolean` | `true` | Contributes this package's own schemas and references them from the responses. |
275
+ | Option | Type | Default | Description |
276
+ | -------------------- | ------------------------------------------ | ------------- | ----------------------------------------------------------------------------------- |
277
+ | `enabled` | `boolean` | `false` | Builds and serves the document. Ignored in production, where it is always off. |
278
+ | `path` | `string` | `'docs'` | Route serving the interactive UI. |
279
+ | `jsonPath` | `string` | `'docs-json'` | Route serving the raw JSON document. |
280
+ | `title` | `string` | `'API'` | Document title. |
281
+ | `description` | `string` | `''` | Document description. |
282
+ | `version` | `string` | `'1.0.0'` | Document version, independent of the package version. |
283
+ | `servers` | `{ url, description? }[]` | `[]` | Servers advertised by the document. |
284
+ | `securitySchemes` | `Record<string, object>` | `{}` | Security schemes copied into the document's components. |
285
+ | `security` | `SecurityRequirement[]` | `[]` | The requirement every operation carries unless it says otherwise. |
286
+ | `operationSecurity` | `OperationSecurityMap` | `{}` | Per-operation overrides. An empty array marks that operation public. |
287
+ | `operationIdFactory` | `(controller, method, version?) => string` | peer default | Names the operations. Leave unset and nothing an existing client generated changes. |
288
+ | `includeCoreSchemas` | `boolean` | `true` | Contributes this package's own schemas and references them from the responses. |
288
289
 
289
290
  Unlike `health` and `metrics`, this block behaves identically on `forRoot` and
290
291
  `forRootAsync`: the document is mounted from the bootstrap helper, after the
@@ -443,8 +444,8 @@ throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is ov
443
444
 
444
445
  ## ⏱️ Request Timing
445
446
 
446
- One `RequestTimingSample` is delivered per completed request, success or
447
- error, to whatever implements `ITimingSink`:
447
+ One `RequestTimingSample` is delivered to whatever implements `ITimingSink` for
448
+ **every request the server closes** — not only the ones a handler answered:
448
449
 
449
450
  ```typescript
450
451
  export interface RequestTimingSample {
@@ -456,6 +457,59 @@ export interface RequestTimingSample {
456
457
  }
457
458
  ```
458
459
 
460
+ ### Rejected requests are counted too
461
+
462
+ The recorder is middleware (`BymaxTimingMiddleware`), applied to every route by
463
+ `BymaxCoreModule` when `timing.enabled` is `true`. That placement is the whole
464
+ point. Nest runs **middleware → guards → interceptors → pipes → handler**, so a
465
+ request rejected by a guard never reaches an interceptor, and a request matching
466
+ no route never reaches a controller. A recorder sitting in either place is blind
467
+ to exactly the traffic that matters during an incident:
468
+
469
+ | What happens | Status | Visible to an interceptor | Visible here |
470
+ | ------------------------------ | ------ | ------------------------- | ------------ |
471
+ | Handler answers | `2xx` | ✅ | ✅ |
472
+ | Authentication guard rejects | `401` | ❌ | ✅ |
473
+ | Authorization guard rejects | `403` | ❌ | ✅ |
474
+ | Rate limiter sheds the request | `429` | ❌ | ✅ |
475
+ | No route matches | `404` | ❌ | ✅ |
476
+ | Client hangs up mid-request | — | ❌ | ✅ |
477
+
478
+ A credential-stuffing run is a flood of `401`s, route enumeration is a flood of
479
+ `404`s, and a throttler doing its job is a flood of `429`s. All three used to
480
+ leave the error graph flat.
481
+
482
+ Requests that matched no route are recorded under the fixed label
483
+ `UNMATCHED_ROUTE` (`<unmatched>`), never the path that was requested. The raw
484
+ path is attacker-controlled, and a metrics label that follows it lets anyone
485
+ mint one time series per probe until the process runs out of memory.
486
+
487
+ The sample is emitted when the connection closes, so a client that hangs up
488
+ before the response finishes is still counted — that is what a scanner does, and
489
+ `durationMs` covers guards and middleware as well as the handler.
490
+
491
+ An aborted request keeps whatever status the response held, which is `200` in
492
+ Node unless something settled another one. No sentinel status is introduced:
493
+ that would change the value of `status_code="200"` series that already exist in
494
+ your dashboards, without any change in traffic.
495
+
496
+ **Express and Fastify behave identically here**, and that is asserted end to
497
+ end on both. It needs saying because the two platforms disagree underneath: Nest
498
+ runs middleware on Fastify through `@fastify/middie`, which hands it the raw
499
+ `IncomingMessage` carrying no route metadata, and `forRoutes('/')` is a mount on
500
+ Express but an exact match on Fastify. The module resolves both for you.
501
+
502
+ > **One gap remains, and it is Nest's scoping rule, not a setting.** Module
503
+ > middleware is scoped to the global prefix, so with `setGlobalPrefix('api')` a
504
+ > request to `/nope` — outside the prefix entirely — reaches no middleware and
505
+ > is not recorded. Requests to `/api/nope` are recorded normally, under
506
+ > `<unmatched>`, so a scan that probes below your prefix is still visible; only
507
+ > one that probes above it is not. Covering that too is not supported yet: the
508
+ > module has no way to register the recorder outside its own scope, and
509
+ > `BymaxTimingMiddleware` is only provided when `timing` is enabled — at which
510
+ > point the module already applies it, so resolving and re-registering it would
511
+ > double-count. If you need it, open an issue rather than wiring it by hand.
512
+
459
513
  Bind your own sink by providing `BYMAX_TIMING_SINK` from your own module, the
460
514
  same override pattern shown below for the correlation provider. This applies on
461
515
  the `forRoot` path; on `forRootAsync` the module owns `BYMAX_TIMING_SINK` (the
@@ -856,6 +910,105 @@ Both halves are the same switch. Referencing a schema that was not contributed
856
910
  would leave a dangling `$ref`, and a document that resolves nowhere is worse
857
911
  than one that says less — so `includeCoreSchemas: false` opts out of both.
858
912
 
913
+ ### A library can describe its own routes
914
+
915
+ A library that ships controllers cannot document them itself. Decorating them
916
+ with `@nestjs/swagger` would load that peer in every application importing the
917
+ library, including the ones that never build a document — and a consumer-side
918
+ map keyed by path does not work either, because a library mounted through
919
+ `RouterModule.register` does not know its own final paths: the same route is
920
+ `/auth/login` in one deployment and `/api/v2/identity/login` in another, from one
921
+ build.
922
+
923
+ So a library marks a provider and returns fragments keyed by **handler
924
+ identity**, which survives every prefix, version and mount point:
925
+
926
+ ```ts
927
+ import { BymaxOpenApiContributor } from '@bymax-one/nest-core/openapi'
928
+ import type { IOpenApiContributor, OpenApiFragment } from '@bymax-one/nest-core/openapi'
929
+
930
+ @BymaxOpenApiContributor()
931
+ @Injectable()
932
+ export class AuthOpenApi implements IOpenApiContributor {
933
+ constructor(private readonly options: ResolvedAuthOptions) {}
934
+
935
+ contributeOpenApi(): OpenApiFragment {
936
+ return {
937
+ // Required, and declared rather than inferred: a fragment crosses a
938
+ // boundary between independently released packages, where each side
939
+ // type-checked against its own copy.
940
+ contractVersion: 1,
941
+ components: {
942
+ securitySchemes: {
943
+ // Derived from resolved options — which is why this cannot be a
944
+ // static map a consumer writes by hand.
945
+ authCookie: { type: 'apiKey', in: 'cookie', name: this.options.cookies.accessTokenName },
946
+ // Declared because the refresh operation below requires it: a
947
+ // requirement naming an undeclared scheme fails the document build.
948
+ refreshCookie: {
949
+ type: 'apiKey',
950
+ in: 'cookie',
951
+ name: this.options.cookies.refreshTokenName
952
+ }
953
+ }
954
+ },
955
+ operations: {
956
+ 'AuthController.login': { security: [] },
957
+ 'AuthController.refresh': { security: [{ refreshCookie: [] }] }
958
+ }
959
+ }
960
+ }
961
+ }
962
+ ```
963
+
964
+ Nothing is wired by the application: enabling the document runs the scan, and a
965
+ library that is never imported contributes nothing.
966
+
967
+ #### What the merge guarantees
968
+
969
+ | Rule | Behavior |
970
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
971
+ | Marked, not shaped | Only providers carrying the marker are called. A class that merely exposes `contributeOpenApi` is never touched. |
972
+ | Called once | While the document is built, after options resolve — so a contributor may derive its contribution from its own configuration. |
973
+ | Stable order | Contributors run sorted by class name, so two libraries describing the same operation resolve the same way on every boot. |
974
+ | Data, not mutation | A contributor returns fragments; this package decides what to write. That is what makes precedence enforceable. |
975
+ | Named failures | A marked class that cannot contribute, one that throws, or a fragment addressing a handler the application does not have all fail the document build naming the contributor. |
976
+ | Off with the document | With `openapi.enabled` false, no contributor runs. |
977
+
978
+ **Precedence, weakest first:** what this package infers about its own routes,
979
+ then what a library contributed, then what the consumer configured, and above
980
+ all of them whatever the operation already declared — a decorated handler is the
981
+ consumer speaking directly and is never overwritten. So a deployment can always
982
+ overrule a dependency's description of its own routes through
983
+ `operationSecurity`.
984
+
985
+ **Operation ids are untouched.** This package installs an operation-id factory
986
+ to learn which handler produced which operation, and delegates the id string —
987
+ to `openapi.operationIdFactory` when you set one, to the format `@nestjs/swagger`
988
+ itself produces otherwise. A client generated from your document before adopting
989
+ this keeps working after.
990
+
991
+ **The fragment shape is translatable to an OpenAPI Overlay, deliberately.** The
992
+ [Overlay Specification](https://spec.openapis.org/overlay/v1.0.0.html) is the
993
+ OpenAPI Initiative's format for describing changes to a document, and a
994
+ contributor's fragments are that in all but notation: a handler key resolves to
995
+ an operation id, which resolves to a JSONPath target, which is what an overlay
996
+ action addresses. Overlays are not used as the mechanism — their targets are
997
+ paths, which is the one thing a library mounted through `RouterModule.register`
998
+ cannot write, and a JSONPath engine would be a runtime dependency this package
999
+ does not have. But the translation is mechanical, so a tool that emits a
1000
+ library's contribution as a standalone overlay can exist the day a pipeline
1001
+ wants one. No such tool ships here, and none is promised.
1002
+
1003
+ **Deriving the fragments is the library's business, not this package's.** A
1004
+ library that wants its schemas to track its own validation decorators should
1005
+ generate them in its own build or test suite, where that dependency already
1006
+ exists, and commit the result — with a test asserting generated matches
1007
+ committed, so drift fails in the repository that caused it. This package takes
1008
+ no dependency on any validation library and merges what it is given. An
1009
+ application's own DTOs need none of this: `@nestjs/swagger`'s CLI plugin already
1010
+ derives them, which is a route a precompiled library does not have.
1011
+
859
1012
  ### The document describes _this_ deployment
860
1013
 
861
1014
  A feature you turned off has its routes removed from the document. With
@@ -961,7 +1114,7 @@ identically.
961
1114
  │ │ │ │ │
962
1115
  envelope/ timing/ health/ pagination/ metrics/
963
1116
  │ │ │ │ │
964
- APP_FILTER APP_INTERCEPTOR liveness + pure functions Prometheus
1117
+ APP_FILTER middleware liveness + pure functions Prometheus
965
1118
  │ │ readiness on their own scrape route
966
1119
  │ │ │ subpath (opt-in)
967
1120
  ▼ ▼ ▼ │ │
@@ -996,7 +1149,7 @@ never imported, which is why it can stay an optional peer. The same holds for
996
1149
  `@nestjs/swagger` and `@opentelemetry/api`: the release gate loads the packed
997
1150
  tarball and fails if any of the three is reachable with its feature off.
998
1151
 
999
- Nothing here holds state across requests. The timing interceptor emits and forgets;
1152
+ Nothing here holds state across requests. The timing middleware emits and forgets;
1000
1153
  the health service runs the indicators the app registered and folds their results;
1001
1154
  the pagination helpers are functions of their arguments.
1002
1155
 
@@ -1050,6 +1203,26 @@ A slow indicator is converted to `down` by the aggregator rather than hanging th
1050
1203
  and its `timedOutAfterMs` stays in the response either way, because that number is one this
1051
1204
  library chose rather than text an indicator produced.
1052
1205
 
1206
+ ### A metric an attack cannot be seen in is not a control
1207
+
1208
+ Request timing is counted as a security signal, not a performance one. A
1209
+ credential-stuffing run is a flood of `401`s, a privilege probe a flood of
1210
+ `403`s, route enumeration a flood of `404`s, and a rate limiter doing its job a
1211
+ flood of `429`s. None of those reaches a handler, so a recorder placed after the
1212
+ guards sees none of them — and an operator watching a flat error graph concludes
1213
+ nothing is happening. The recorder is middleware for that reason, and every
1214
+ closed request is counted whatever ended it.
1215
+
1216
+ ### A route label is attacker-controlled input
1217
+
1218
+ The label on a timing sample comes from the matched route **template**, and a
1219
+ request that matched nothing is recorded under the single constant
1220
+ `UNMATCHED_ROUTE` (`<unmatched>`) rather than the path that was asked for.
1221
+ Following the path would let anyone mint one Prometheus time series per probe:
1222
+ a scan would grow the registry without bound, make the scrape endpoint the most
1223
+ expensive route in the service, and end as an out-of-memory kill. The bound is
1224
+ why the unmatched case is a fixed string and not a fallback to the URL.
1225
+
1053
1226
  ### Cursors are opaque, not secret
1054
1227
 
1055
1228
  `encodeCursor` produces a token a client can round-trip; it is not encrypted and not
@@ -1081,6 +1254,8 @@ most likely to be exposed.
1081
1254
  | 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 |
1082
1255
  | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
1083
1256
  | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
1257
+ | 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 |
1258
+ | 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 |
1084
1259
  | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
1085
1260
  | Metrics | Opt-in; `prom-client` never imported while it is off |
1086
1261
  | OpenAPI | Opt-in and development-only; refused in production by two independent guards, `@nestjs/swagger` never imported while it is off |
@@ -1099,13 +1274,13 @@ most likely to be exposed.
1099
1274
  ## 🧱 Tech Stack
1100
1275
 
1101
1276
  - **Runtime:** Node.js 24+
1102
- - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `APP_INTERCEPTOR`)
1277
+ - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `NestModule.configure`)
1103
1278
  - **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
1104
1279
  - **Optional peers:** `prom-client ^15` when metrics are enabled, `@nestjs/swagger ^11` when
1105
1280
  OpenAPI is enabled, `@opentelemetry/api ^1.9` when trace correlation is enabled — none is
1106
1281
  imported while its feature is off
1107
1282
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
1108
- - **Tests:** Jest (unit + e2e over a real Nest application) + Stryker (mutation)
1283
+ - **Tests:** Jest (unit + e2e over real Nest applications, Express **and** Fastify) + Stryker (mutation)
1109
1284
  - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
1110
1285
 
1111
1286
  ---
@@ -1120,9 +1295,11 @@ installs it, so the suite is held to a bar beyond "the tests pass".
1120
1295
  `break: 95`; every killable survivor was killed by a strengthened test, with no production
1121
1296
  change, and the nine equivalents that no test can kill each carry their reason on the line
1122
1297
  they apply to ([report](./docs/mutation_testing_results.md))
1123
- - ✅ **End-to-end against a real application** — the filter, the interceptor, the health and
1124
- metrics routes, the served OpenAPI document, discovered indicators, contributed metrics and
1125
- trace correlation are all exercised through a booted Nest app, not against mocks of it
1298
+ - ✅ **End-to-end against real applications, on both platforms** — the filter, the middleware,
1299
+ the health and metrics routes, the served OpenAPI document, discovered indicators,
1300
+ contributed metrics and trace correlation are all exercised through a booted Nest app, not
1301
+ against mocks of it. Timing is asserted on **Express and Fastify**, because the two differ
1302
+ underneath in ways that make a passing Express suite say nothing about Fastify
1126
1303
  - ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
1127
1304
  system does, `check:runtime` loads every subpath from the packed tarball in ESM and
1128
1305
  CommonJS, and `check:published` compiles this README's snippets against `dist/`
@@ -1134,7 +1311,7 @@ installs it, so the suite is held to a bar beyond "the tests pass".
1134
1311
  ```bash
1135
1312
  pnpm test # unit suite
1136
1313
  pnpm test:cov # unit suite with the 100% coverage gate
1137
- pnpm test:e2e # end-to-end against a real Nest application
1314
+ pnpm test:e2e # end-to-end against real Nest applications (Express and Fastify)
1138
1315
  pnpm mutation # Stryker mutation testing (break: 95)
1139
1316
  pnpm typecheck # tsc strict check
1140
1317
  pnpm lint # ESLint
@@ -1154,6 +1331,7 @@ in the sections above.
1154
1331
  | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
1155
1332
  | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
1156
1333
  | `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
1334
+ | `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
1157
1335
  | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY` | tokens | The DI tokens; see the [token table](#-di-tokens). |
1158
1336
  | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1159
1337
  | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
@@ -1161,7 +1339,9 @@ in the sections above.
1161
1339
  | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
1162
1340
  | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
1163
1341
  | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
1164
- | `TimingInterceptor` | class | The request-timing interceptor. |
1342
+ | `BymaxTimingMiddleware` | class | The request-timing middleware, applied to every route when timing is enabled. |
1343
+ | `UNMATCHED_ROUTE` | constant | The bounded label recorded when a request matched no route (`<unmatched>`). |
1344
+ | `TimingInterceptor` | class | Deprecated: superseded by `BymaxTimingMiddleware`, which the module registers. |
1165
1345
  | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
1166
1346
  | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
1167
1347
  | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
@@ -1197,20 +1377,26 @@ in the sections above.
1197
1377
 
1198
1378
  ### `./openapi`
1199
1379
 
1200
- | Export | Kind | Description |
1201
- | --------------------- | -------- | ----------------------------------------------------------------- |
1202
- | `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
1203
- | `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
1204
- | `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
1380
+ | Export | Kind | Description |
1381
+ | -------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------ |
1382
+ | `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
1383
+ | `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
1384
+ | `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
1385
+ | `BymaxOpenApiContributor`, `BYMAX_OPENAPI_CONTRIBUTOR_METADATA` | decorator, constant | Marks a provider as describing its own routes, and the metadata key behind it. |
1386
+ | `IOpenApiContributor`, `OpenApiFragment`, `OpenApiFragmentObject`, `OpenApiHandlerKey` | types | The contributor contract and the shape of what it returns. |
1205
1387
 
1206
1388
  ## 🧩 Compatibility
1207
1389
 
1208
1390
  - Node.js `>= 24`
1209
1391
  - NestJS `^11`
1210
- - Express and Fastify, through framework-agnostic accessors for path, method,
1211
- and status. GraphQL and RPC execution contexts are out of scope for the
1212
- error envelope and the timing interceptor in this release; both pass errors
1213
- and requests through untouched.
1392
+ - Express and Fastify, both covered end to end. The accessors are
1393
+ framework-agnostic for path, method and status, and the module absorbs the
1394
+ two places the platforms genuinely differ: the route mount, and the fact that
1395
+ Nest runs middleware on Fastify through `@fastify/middie`, which strips the
1396
+ route metadata off the object the middleware receives.
1397
+ - GraphQL and RPC execution contexts are out of scope for the error envelope and
1398
+ the request timing in this release; both pass errors and requests through
1399
+ untouched.
1214
1400
 
1215
1401
  ## 🤝 Contributing
1216
1402