@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/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,22 +272,113 @@ 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
- | `includeCoreSchemas` | `boolean` | `true` | Contributes this package's own schemas envelope, health, pagination. |
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. |
286
289
 
287
290
  Unlike `health` and `metrics`, this block behaves identically on `forRoot` and
288
291
  `forRootAsync`: the document is mounted from the bootstrap helper, after the
289
292
  options have resolved, so a custom `path` is honored on both registration paths.
290
293
 
294
+ #### Documenting authentication
295
+
296
+ Set the default on the document and mark the exceptions. `security` names
297
+ schemes declared in `securitySchemes`; `operationSecurity` overrides it for one
298
+ operation, and an **empty array is how the specification says "public"** — which
299
+ matters more than it looks, because an operation with _absent_ security inherits
300
+ the document default, so a generated client would attach credentials to your
301
+ registration endpoint.
302
+
303
+ ```ts
304
+ openapi: {
305
+ enabled: true,
306
+ securitySchemes: {
307
+ cookieAuth: { type: 'apiKey', in: 'cookie', name: 'access_token' },
308
+ refreshCookie: { type: 'apiKey', in: 'cookie', name: 'refresh_token' }
309
+ },
310
+ security: [{ cookieAuth: [] }],
311
+ operationSecurity: {
312
+ 'POST /auth/login': [],
313
+ 'POST /auth/register': [],
314
+ 'POST /auth/refresh': [{ refreshCookie: [] }]
315
+ }
316
+ }
317
+ ```
318
+
319
+ An operation that already declares its own requirement — because you decorated
320
+ the handler — is never overwritten, on either path.
321
+
322
+ #### The operation key is a contract
323
+
324
+ Keys are `"<METHOD> <path>"`, and the format is documented rather than
325
+ incidental: a sibling library can ship a plain-data map of its own operations
326
+ keyed this way, so you spread it in instead of restating which of its routes are
327
+ public. Import `OperationSecurityMap` to have that map checked at the library's
328
+ own compile time — it is a type-only export, so nothing couples at runtime.
329
+
330
+ - The method is **uppercase**, separated by exactly one space.
331
+ - The path is written **exactly as it appears in the generated document**:
332
+ leading slash, OpenAPI template braces (`/users/{id}`), no trailing slash, and
333
+ **including any global prefix**. `@nestjs/swagger` puts
334
+ `app.setGlobalPrefix('api')` into the documented paths, so the key becomes
335
+ `'POST /api/auth/login'` in an application that sets one.
336
+
337
+ Because of that last point, a library shipping such a map should expose a
338
+ **function taking the prefix**, not a frozen constant — the call site is the only
339
+ place that knows it:
340
+
341
+ ```ts
342
+ // in the library
343
+ export function authOperationSecurity(prefix = ''): OperationSecurityMap { /* … */ }
344
+
345
+ // in the application
346
+ operationSecurity: { ...authOperationSecurity('api'), ...myOwnOverrides }
347
+ ```
348
+
349
+ A requirement naming a scheme that is not declared **fails the document build**
350
+ too, listing the names that missed and the ones the document defines. A
351
+ requirement is a reference, and a reference to nothing yields a document whose
352
+ security cannot be resolved: a client generator looks the name up, finds
353
+ nothing, and either fails or emits an unauthenticated client.
354
+
355
+ A key matching no operation **fails the document build**, listing both the keys
356
+ that missed and the operations that exist. Silence would be worse: a route
357
+ renamed out from under a stale key would quietly inherit the document default
358
+ and be documented as authenticated when it is not, or the reverse. Failing is
359
+ safe here in a way it rarely is — the document is only ever built outside
360
+ production, so this can only stop a developer.
361
+
362
+ One consequence for conditionally-registered routes: the map is static wiring
363
+ while a route may not be. If an operation belongs to a feature you register per
364
+ environment — your own conditional module, or a library feature toggled off
365
+ somewhere — a key naming it fails the boot in whichever docs-enabled environment
366
+ lacks that route. That is the intended loud behavior, so build the map the same
367
+ way you build the modules: assemble it per feature and spread the fragments in,
368
+ rather than writing one flat literal that outlives the routes it names.
369
+
370
+ That last sentence cuts both ways, and the consequence is worth stating rather
371
+ than discovering. **These checks only run when the document is actually built.**
372
+ With `openapi.enabled` false, or in a production runtime where the feature is
373
+ forced off, nothing validates: a stale key, a renamed route, or a requirement
374
+ naming a scheme you deleted all sit there quietly until someone turns the
375
+ document on. That is deliberate — refusing to boot a production service over a
376
+ documentation setting it never serves would be the wrong trade — but it means
377
+ the errors surface on a developer's machine or in CI, **not** at the moment the
378
+ configuration went wrong. If you gate the document behind an environment flag,
379
+ make sure at least one environment that runs your tests has it on, or these
380
+ checks never fire.
381
+
291
382
  ## 🔑 DI Tokens
292
383
 
293
384
  Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
@@ -353,8 +444,8 @@ throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is ov
353
444
 
354
445
  ## ⏱️ Request Timing
355
446
 
356
- One `RequestTimingSample` is delivered per completed request, success or
357
- 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:
358
449
 
359
450
  ```typescript
360
451
  export interface RequestTimingSample {
@@ -366,6 +457,59 @@ export interface RequestTimingSample {
366
457
  }
367
458
  ```
368
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
+
369
513
  Bind your own sink by providing `BYMAX_TIMING_SINK` from your own module, the
370
514
  same override pattern shown below for the correlation provider. This applies on
371
515
  the `forRoot` path; on `forRootAsync` the module owns `BYMAX_TIMING_SINK` (the
@@ -627,6 +771,28 @@ conventions are part of the contract:
627
771
  - Keep labels bounded. Route templates, never raw paths; status codes, never
628
772
  messages. **Never** a tenant, user, or request id — one unbounded label is
629
773
  enough to make a scrape endpoint the most expensive route in a service.
774
+ `tenantId` deserves naming twice: every library in this family is
775
+ tenant-aware, so it is the first label anyone reaches for and it is unbounded
776
+ by construction.
777
+ - **Publish the list.** A library that contributes metrics documents them in its
778
+ own README — name, type, labels. This package deliberately keeps no central
779
+ catalogue: a list of everyone else's metrics rots the moment a library ships a
780
+ new one. What it does require is that the list exists somewhere an operator
781
+ can find it.
782
+
783
+ **Why these rules live here.** A Prometheus registry is a flat namespace, and
784
+ `prom-client` rejects a duplicate metric name. If two libraries independently
785
+ pick `bymax_operations_total`, the collision surfaces at the **consumer's** boot
786
+ — in an application neither library's CI ever assembles, as a hard failure, in
787
+ front of whoever wired the app. Neither library can test for it. A namespace
788
+ rule is the only thing that prevents it, and it can only be arbitrated by the
789
+ dependency they share, which is this package.
790
+
791
+ The rules are documentation, not enforcement. This package could inspect the
792
+ registry around each contributor and reject an unprefixed name, but that would
793
+ need a per-contributor prefix on the contract, and it would wrongly reject the
794
+ case that matters most — an **application's own** contributor, which has no
795
+ business being pushed into a `bymax_` namespace.
630
796
 
631
797
  ## 📘 OpenAPI
632
798
 
@@ -683,6 +849,24 @@ Enabling it in production is not an error, it is a no-op with a warning naming
683
849
  the option that was ignored, so a single configuration can be shared across
684
850
  environments.
685
851
 
852
+ ### Testing the enabled path under Jest
853
+
854
+ `applyBymaxOpenApi` loads `@nestjs/swagger` through a dynamic `import()` — that
855
+ is what keeps the peer optional for everyone who never enables the document —
856
+ and Jest's module registry cannot service a dynamic import without a flag:
857
+
858
+ ```jsonc
859
+ // package.json
860
+ "scripts": {
861
+ "test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config jest.e2e.config.ts"
862
+ }
863
+ ```
864
+
865
+ Without it, only the **enabled** case fails, with `dynamic import callback
866
+ invoked without --experimental-vm-modules`. The disabled and production cases
867
+ never reach the loader and pass either way, which is what makes the omission
868
+ confusing when you meet it.
869
+
686
870
  ### What the library contributes
687
871
 
688
872
  With `includeCoreSchemas` on, the document carries the contracts this package
@@ -704,6 +888,158 @@ that never enable the feature.
704
888
  A contributed entry never overwrites one the document already has: if you
705
889
  document your own `BymaxErrorEnvelope`, yours wins.
706
890
 
891
+ Contributing the schemas is only half of it — the operations **reference** them,
892
+ which is what a generated client actually reads:
893
+
894
+ - every operation gains a `default` response pointing at `BymaxErrorEnvelope`,
895
+ because every error path in this package answers with that envelope. It is
896
+ attached as `default` rather than guessed per status code: this package knows
897
+ what an error looks like and does not know which statuses your handler emits.
898
+ It follows the feature: with `envelope.enabled` off, errors are shaped by Nest
899
+ or by your own handler, so nothing is documented;
900
+ - the health endpoints gain an explicit `200` pointing at `BymaxHealthResponse`,
901
+ which this package _does_ know precisely, having registered them itself.
902
+
903
+ `@nestjs/swagger` emits a placeholder response for every handler — a `200` with
904
+ a description and no content — so "already documented" is judged on whether a
905
+ response declares a **shape**: one carrying `content` — or written as a bare
906
+ `$ref`, which points at a shape declared elsewhere — is yours and is left alone,
907
+ one without either gets filled in while keeping any description you wrote.
908
+
909
+ Both halves are the same switch. Referencing a schema that was not contributed
910
+ would leave a dangling `$ref`, and a document that resolves nowhere is worse
911
+ than one that says less — so `includeCoreSchemas: false` opts out of both.
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
+
1012
+ ### The document describes _this_ deployment
1013
+
1014
+ A feature you turned off has its routes removed from the document. With
1015
+ `metrics: { enabled: false }` the runtime answers `GET /metrics` with a 404
1016
+ envelope — on `forRootAsync` the controller is mounted unconditionally and
1017
+ guards each request, because route metadata is fixed before the async options
1018
+ resolve — so a document still listing the route would describe something this
1019
+ deployment does not serve. The filter reads the same resolved snapshot the
1020
+ runtime guard reads, which is what keeps the two from drifting.
1021
+
1022
+ Your own routes are safe from it. `@nestjs/swagger` documents paths including
1023
+ `app.setGlobalPrefix()`, so the match cannot be on equality — but a bare tail
1024
+ match would treat `/tenants/{id}/health/live` as this package's probe and delete
1025
+ it from your document. What separates the two is that a global prefix prefixes
1026
+ _everything_: a tail match counts only when whatever precedes it also precedes
1027
+ every other path in the document. `/api/v2` qualifies; `/tenants/{id}` does not,
1028
+ because it does not prefix `/invoices`.
1029
+
1030
+ This package also documents the security of the three routes it owns, without
1031
+ being asked:
1032
+
1033
+ | Route | Documented as |
1034
+ | ----------------------------------- | ------------------------------------------------------- |
1035
+ | `GET /health/live`, `/health/ready` | Public (`security: []`), when a document default exists |
1036
+ | `GET /metrics` | Bearer-protected **iff** `metrics.authToken` is set |
1037
+
1038
+ The probes are polled by an orchestrator holding no credential, and the scrape
1039
+ endpoint is protected exactly when you configured a token — this package owns
1040
+ both the route and the option, so you should not have to restate either. Your
1041
+ own `operationSecurity` entry still wins.
1042
+
707
1043
  ## 🧵 Trace correlation
708
1044
 
709
1045
  Off by default. Enabled, it reads the span your instrumentation already opened
@@ -778,7 +1114,7 @@ identically.
778
1114
  │ │ │ │ │
779
1115
  envelope/ timing/ health/ pagination/ metrics/
780
1116
  │ │ │ │ │
781
- APP_FILTER APP_INTERCEPTOR liveness + pure functions Prometheus
1117
+ APP_FILTER middleware liveness + pure functions Prometheus
782
1118
  │ │ readiness on their own scrape route
783
1119
  │ │ │ subpath (opt-in)
784
1120
  ▼ ▼ ▼ │ │
@@ -813,7 +1149,7 @@ never imported, which is why it can stay an optional peer. The same holds for
813
1149
  `@nestjs/swagger` and `@opentelemetry/api`: the release gate loads the packed
814
1150
  tarball and fails if any of the three is reachable with its feature off.
815
1151
 
816
- 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;
817
1153
  the health service runs the indicators the app registered and folds their results;
818
1154
  the pagination helpers are functions of their arguments.
819
1155
 
@@ -867,6 +1203,26 @@ A slow indicator is converted to `down` by the aggregator rather than hanging th
867
1203
  and its `timedOutAfterMs` stays in the response either way, because that number is one this
868
1204
  library chose rather than text an indicator produced.
869
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
+
870
1226
  ### Cursors are opaque, not secret
871
1227
 
872
1228
  `encodeCursor` produces a token a client can round-trip; it is not encrypted and not
@@ -898,6 +1254,8 @@ most likely to be exposed.
898
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 |
899
1255
  | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
900
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 |
901
1259
  | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
902
1260
  | Metrics | Opt-in; `prom-client` never imported while it is off |
903
1261
  | OpenAPI | Opt-in and development-only; refused in production by two independent guards, `@nestjs/swagger` never imported while it is off |
@@ -916,13 +1274,13 @@ most likely to be exposed.
916
1274
  ## 🧱 Tech Stack
917
1275
 
918
1276
  - **Runtime:** Node.js 24+
919
- - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `APP_INTERCEPTOR`)
1277
+ - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `NestModule.configure`)
920
1278
  - **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
921
1279
  - **Optional peers:** `prom-client ^15` when metrics are enabled, `@nestjs/swagger ^11` when
922
1280
  OpenAPI is enabled, `@opentelemetry/api ^1.9` when trace correlation is enabled — none is
923
1281
  imported while its feature is off
924
1282
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
925
- - **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)
926
1284
  - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
927
1285
 
928
1286
  ---
@@ -937,9 +1295,11 @@ installs it, so the suite is held to a bar beyond "the tests pass".
937
1295
  `break: 95`; every killable survivor was killed by a strengthened test, with no production
938
1296
  change, and the nine equivalents that no test can kill each carry their reason on the line
939
1297
  they apply to ([report](./docs/mutation_testing_results.md))
940
- - ✅ **End-to-end against a real application** — the filter, the interceptor, the health and
941
- metrics routes, the served OpenAPI document, discovered indicators, contributed metrics and
942
- 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
943
1303
  - ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
944
1304
  system does, `check:runtime` loads every subpath from the packed tarball in ESM and
945
1305
  CommonJS, and `check:published` compiles this README's snippets against `dist/`
@@ -951,7 +1311,7 @@ installs it, so the suite is held to a bar beyond "the tests pass".
951
1311
  ```bash
952
1312
  pnpm test # unit suite
953
1313
  pnpm test:cov # unit suite with the 100% coverage gate
954
- 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)
955
1315
  pnpm mutation # Stryker mutation testing (break: 95)
956
1316
  pnpm typecheck # tsc strict check
957
1317
  pnpm lint # ESLint
@@ -966,21 +1326,25 @@ in the sections above.
966
1326
 
967
1327
  ### `.` (root)
968
1328
 
969
- | Export | Kind | Description |
970
- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------- |
971
- | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
972
- | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
973
- | `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). |
974
- | `ICorrelationIdProvider` | type | The correlation-provider contract. |
975
- | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
976
- | `BymaxExceptionFilter` | class | The envelope exception filter. |
977
- | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
978
- | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
979
- | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
980
- | `TimingInterceptor` | class | The request-timing interceptor. |
981
- | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
982
- | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
983
- | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
1329
+ | Export | Kind | Description |
1330
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------- |
1331
+ | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
1332
+ | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
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. |
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). |
1336
+ | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1337
+ | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
1338
+ | `BymaxExceptionFilter` | class | The envelope exception filter. |
1339
+ | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
1340
+ | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
1341
+ | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
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. |
1345
+ | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
1346
+ | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
1347
+ | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
984
1348
 
985
1349
  ### `./pagination`
986
1350
 
@@ -1013,20 +1377,26 @@ in the sections above.
1013
1377
 
1014
1378
  ### `./openapi`
1015
1379
 
1016
- | Export | Kind | Description |
1017
- | --------------------- | -------- | ----------------------------------------------------------------- |
1018
- | `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
1019
- | `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
1020
- | `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. |
1021
1387
 
1022
1388
  ## 🧩 Compatibility
1023
1389
 
1024
1390
  - Node.js `>= 24`
1025
1391
  - NestJS `^11`
1026
- - Express and Fastify, through framework-agnostic accessors for path, method,
1027
- and status. GraphQL and RPC execution contexts are out of scope for the
1028
- error envelope and the timing interceptor in this release; both pass errors
1029
- 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.
1030
1400
 
1031
1401
  ## 🤝 Contributing
1032
1402