@bymax-one/nest-core 1.3.1 → 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,6 +11,254 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
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
154
+
155
+ A consumer audit of the served document found that it described the library's
156
+ promises rather than the deployment: routes of features that were switched off
157
+ were still listed, the contributed schemas were never referenced by any
158
+ operation, and nothing said which operations needed authentication. Everything
159
+ below is additive — no option changes meaning, no existing document loses an
160
+ entry it had.
161
+
162
+ **Apply to a derived backend:** bump the dependency. The document improves with
163
+ no code change; the two new options are opt-in.
164
+
165
+ ### Added
166
+
167
+ - **`openapi.security` and `openapi.operationSecurity`.** A document-level
168
+ default requirement, plus per-operation overrides keyed `"<METHOD> <path>"`.
169
+ An empty array marks an operation public, which is the specification's own way
170
+ of overriding the default — and it matters for generated clients, since an
171
+ operation with _absent_ security inherits the document default and a client
172
+ would attach credentials to a public registration endpoint.
173
+ - **The operation key is a documented contract**, with `OpenApiOperationKey` and
174
+ `OperationSecurityMap` exported as types so a sibling library can ship a
175
+ plain-data map of its own operations and have it checked at its own compile
176
+ time, with no runtime coupling. The path is written exactly as documented,
177
+ **including any global prefix** — `@nestjs/swagger` puts `setGlobalPrefix` into
178
+ the documented paths, so a library shipping such a map should expose a function
179
+ taking the prefix rather than a frozen constant.
180
+ - **A key addressing no operation fails the document build**, listing both the
181
+ keys that missed and the operations that exist. A stale key would otherwise
182
+ leave a route silently documented as authenticated when it is not, or the
183
+ reverse. Failing is safe here: the document is only ever built outside
184
+ production.
185
+ - **A requirement naming an undeclared security scheme fails the same way.** A
186
+ requirement is a reference, and a reference to nothing produces a document
187
+ whose security cannot be resolved — a client generator looks the name up in
188
+ `components.securitySchemes`, finds nothing, and either fails or emits an
189
+ unauthenticated client. Configuring the requirement and forgetting the scheme
190
+ is one edit apart. A scheme the document itself declares counts as declared,
191
+ and marking an operation public names no scheme, so it needs none.
192
+
193
+ ### Fixed
194
+
195
+ - **A disabled feature's routes are no longer documented.** With
196
+ `metrics: { enabled: false }` the runtime answers `GET /metrics` with a 404
197
+ envelope — on `forRootAsync` the controller is mounted unconditionally and
198
+ guards each request, because route metadata is fixed before the async options
199
+ resolve — while the document still advertised it. The filter reads the same
200
+ resolved snapshot the guard reads, so the two cannot drift. `@nestjs/swagger`
201
+ documents paths as the application serves them — the global prefix, and under
202
+ `enableVersioning({ type: URI })` the version segment that follows it, so
203
+ `/api/v1/metrics` — and both are **read from the application** rather than
204
+ inferred from the document. Versioning matters as much as the prefix: without
205
+ it, every versioned application kept advertising the routes of a feature it
206
+ had switched off, and its health probes lost the payload schema and the public
207
+ marking this package contributes. Inference is the trap: an application whose routes all sit under one
208
+ controller prefix would have that treated as the global one, and a consumer
209
+ route ending in `/health/live` deleted as though this package owned it. What
210
+ leaves is also the **operation**, not the path item — a method the consumer
211
+ mounted on the same path survives, and the path disappears only once nothing
212
+ is left under it.
213
+ - **The automatic security policy is stated for `GET` alone.** These controllers
214
+ expose no other method, so a consumer's `POST` on the same path is theirs and
215
+ no longer inherits a requirement written for ours.
216
+ - **The envelope response follows the envelope feature.** With
217
+ `envelope.enabled` off, errors are shaped by Nest or by the consumer's own
218
+ handler, so documenting this package's envelope described a body the
219
+ deployment never sends. The health response is a separate feature and is
220
+ unaffected.
221
+ - **A response written as a bare `$ref` is a declaration.** It carries no
222
+ `content`, so the placeholder rule would have overwritten it — discarding the
223
+ reference and leaving `$ref` beside sibling keys, which is not a valid
224
+ response object.
225
+ - **`BymaxMetricsAuth` is reserved while a scrape token is configured.** The
226
+ name was silently overwritten or silently lost depending on where the other
227
+ definition came from, and the losing case left the scrape operation pointing
228
+ at a scheme that is not the bearer token the runtime checks. It now fails the
229
+ document build with the collision named.
230
+ - **The contributed schemas are referenced by the operations that return them.**
231
+ They shipped orphaned: `components.schemas` carried the envelope, the health
232
+ response and the pagination shapes while no operation pointed at any of them,
233
+ so a generated client had no error type at all. Every operation now carries a
234
+ `default` response referencing `BymaxErrorEnvelope`, and the health endpoints
235
+ an explicit `200` referencing `BymaxHealthResponse`. Gated by
236
+ `includeCoreSchemas`, because referencing a schema that was not contributed
237
+ would leave a dangling `$ref`.
238
+ - **A response is judged by whether it declares a shape.** `@nestjs/swagger`
239
+ emits a placeholder `200` with a description and no content for every handler,
240
+ so a plain "existing always wins" rule would never have written a contributed
241
+ schema. A response carrying `content` is a real declaration and is untouched;
242
+ one without it is filled in, keeping any description already written.
243
+ - **The library documents the security of its own three routes.** The health
244
+ probes are marked public — an orchestrator polls them holding no credential —
245
+ and the scrape endpoint carries a bearer requirement, with its scheme, exactly
246
+ when `metrics.authToken` is set. The library owns both the routes and the
247
+ option, so no consumer should have to restate either.
248
+
249
+ ### Documentation
250
+
251
+ - The metrics naming rules are framed as an adoption guideline for sibling
252
+ libraries, with the reason the rules live here: a Prometheus registry is a flat
253
+ namespace, so two libraries picking the same metric name collide at the
254
+ _consumer's_ boot, in an application neither library's CI ever assembles. A
255
+ contributing library is asked to publish its own metric list; this package
256
+ deliberately keeps no central catalogue.
257
+ - `applyBymaxOpenApi` documents that testing its enabled path under Jest needs
258
+ `NODE_OPTIONS=--experimental-vm-modules`, because the optional peer is reached
259
+ through a dynamic `import()`. Only the enabled case fails without it, which is
260
+ what makes the omission confusing.
261
+
14
262
  ## [1.3.1] - 2026-08-11
15
263
 
16
264
  A patch fixing a defect that existed only in the published artifact: `applyBymaxOpenApi` threw on
@@ -394,9 +642,11 @@ have regressed from. They are kept because the reasoning is worth having.
394
642
  [1.0.1]: https://github.com/bymaxone/nest-core/compare/v1.0.0...v1.0.1
395
643
  [1.0.0]: https://github.com/bymaxone/nest-core/releases/tag/v1.0.0
396
644
  [1.1.1]: https://github.com/bymaxone/nest-core/compare/v1.1.0...v1.1.1
645
+ [1.3.2]: https://github.com/bymaxone/nest-core/compare/v1.3.1...v1.3.2
397
646
  [1.3.1]: https://github.com/bymaxone/nest-core/compare/v1.3.0...v1.3.1
398
647
  [1.3.0]: https://github.com/bymaxone/nest-core/compare/v1.2.2...v1.3.0
399
648
  [1.2.2]: https://github.com/bymaxone/nest-core/compare/v1.2.1...v1.2.2
400
649
  [1.2.1]: https://github.com/bymaxone/nest-core/compare/v1.2.0...v1.2.1
401
650
  [1.2.0]: https://github.com/bymaxone/nest-core/compare/v1.1.1...v1.2.0
402
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.3.1...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