@bymax-one/nest-core 1.3.2 → 1.5.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 +183 -2
- package/README.md +272 -36
- package/dist/index.cjs +335 -161
- package/dist/index.d.cts +242 -16
- package/dist/index.d.ts +242 -16
- package/dist/index.mjs +336 -163
- package/dist/openapi/index.cjs +240 -45
- package/dist/openapi/index.d.cts +147 -2
- package/dist/openapi/index.d.ts +147 -2
- package/dist/openapi/index.mjs +240 -48
- package/package.json +3 -1
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
|
|
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
|
|
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` |
|
|
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
|
|
276
|
-
| -------------------- |
|
|
277
|
-
| `enabled` | `boolean`
|
|
278
|
-
| `path` | `string`
|
|
279
|
-
| `jsonPath` | `string`
|
|
280
|
-
| `title` | `string`
|
|
281
|
-
| `description` | `string`
|
|
282
|
-
| `version` | `string`
|
|
283
|
-
| `servers` | `{ url, description? }[]`
|
|
284
|
-
| `securitySchemes` | `Record<string, object>`
|
|
285
|
-
| `security` | `SecurityRequirement[]`
|
|
286
|
-
| `operationSecurity` | `OperationSecurityMap`
|
|
287
|
-
| `
|
|
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
|
|
@@ -378,6 +379,56 @@ configuration went wrong. If you gate the document behind an environment flag,
|
|
|
378
379
|
make sure at least one environment that runs your tests has it on, or these
|
|
379
380
|
checks never fire.
|
|
380
381
|
|
|
382
|
+
#### When an operation ends up requiring nothing
|
|
383
|
+
|
|
384
|
+
The checks above catch a requirement that points at nothing. The opposite
|
|
385
|
+
mistake — an operation that ends up pointing at _no requirement at all_ — breaks
|
|
386
|
+
nothing, and that is what makes it dangerous. It is a valid document. No name
|
|
387
|
+
dangles. The runtime still answers `401`. A document test that asserts only the
|
|
388
|
+
operations you enumerated stays green. The only thing that changed is that a
|
|
389
|
+
client generated from the document now sends no credentials.
|
|
390
|
+
|
|
391
|
+
The edit that produces it is ordinary: a library starts describing its own
|
|
392
|
+
routes, its adoption note tells you to delete the entries you had written for
|
|
393
|
+
them, and the document-level `security` default is sitting in the same options
|
|
394
|
+
block and goes with them. Every route _you_ own then reads as public.
|
|
395
|
+
|
|
396
|
+
So the document build says so, once, naming the operations:
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
[BymaxCoreModule] a client generated from the OpenAPI document will send no
|
|
400
|
+
credentials to 2 operation(s): GET /examples, POST /orders. They state no
|
|
401
|
+
security requirement, the document declares no default, and other operations in
|
|
402
|
+
it do state one — so this is more often a missing openapi.security default than
|
|
403
|
+
a public API. Set openapi.security, or state the intent per operation with an
|
|
404
|
+
explicit [] in openapi.operationSecurity.
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
It is a **warning, not a failure**: an API that is public on purpose is a
|
|
408
|
+
legitimate configuration, and failing a boot over one would be worse than the
|
|
409
|
+
silence it replaces. The trigger is deliberately narrow, so the line stays worth
|
|
410
|
+
reading:
|
|
411
|
+
|
|
412
|
+
| Condition | Why |
|
|
413
|
+
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
414
|
+
| The document declares no top-level `security` | With a default present, a bare operation inherits it. An explicit `security: []` counts as an answer, not an omission. |
|
|
415
|
+
| At least one other operation _does_ state a requirement | Somebody described a posture on purpose, so the bare ones beside it are an omission rather than a public API. |
|
|
416
|
+
| The operation is not one of this package's own | A health probe carrying nothing is the correct description of a route an orchestrator polls without a credential. |
|
|
417
|
+
|
|
418
|
+
The escape hatch is to say what you mean, in the document's own vocabulary
|
|
419
|
+
rather than by silencing output: `operationSecurity: { 'GET /examples': [] }`
|
|
420
|
+
marks the operation public, and it stops being reported. A library can do the
|
|
421
|
+
same for its own routes by contributing `security: []` in its fragment.
|
|
422
|
+
|
|
423
|
+
**And there is one shape nothing can warn you about, so the diff is the only
|
|
424
|
+
check that covers it.** If you remove _every_ requirement at once — no library
|
|
425
|
+
describing anything, no decorator, no override, no document default — what
|
|
426
|
+
remains is indistinguishable from the document of an API that is public on
|
|
427
|
+
purpose. Both are a set of operations that ask for nothing. No tool can separate
|
|
428
|
+
the two without also shouting at every genuinely public API, which is how a
|
|
429
|
+
warning earns the right to be ignored. Render the document twice and compare; it
|
|
430
|
+
is the one step that does not depend on somebody having anticipated your case.
|
|
431
|
+
|
|
381
432
|
## 🔑 DI Tokens
|
|
382
433
|
|
|
383
434
|
Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
|
|
@@ -443,8 +494,8 @@ throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is ov
|
|
|
443
494
|
|
|
444
495
|
## ⏱️ Request Timing
|
|
445
496
|
|
|
446
|
-
One `RequestTimingSample` is delivered
|
|
447
|
-
|
|
497
|
+
One `RequestTimingSample` is delivered to whatever implements `ITimingSink` for
|
|
498
|
+
**every request the server closes** — not only the ones a handler answered:
|
|
448
499
|
|
|
449
500
|
```typescript
|
|
450
501
|
export interface RequestTimingSample {
|
|
@@ -456,6 +507,59 @@ export interface RequestTimingSample {
|
|
|
456
507
|
}
|
|
457
508
|
```
|
|
458
509
|
|
|
510
|
+
### Rejected requests are counted too
|
|
511
|
+
|
|
512
|
+
The recorder is middleware (`BymaxTimingMiddleware`), applied to every route by
|
|
513
|
+
`BymaxCoreModule` when `timing.enabled` is `true`. That placement is the whole
|
|
514
|
+
point. Nest runs **middleware → guards → interceptors → pipes → handler**, so a
|
|
515
|
+
request rejected by a guard never reaches an interceptor, and a request matching
|
|
516
|
+
no route never reaches a controller. A recorder sitting in either place is blind
|
|
517
|
+
to exactly the traffic that matters during an incident:
|
|
518
|
+
|
|
519
|
+
| What happens | Status | Visible to an interceptor | Visible here |
|
|
520
|
+
| ------------------------------ | ------ | ------------------------- | ------------ |
|
|
521
|
+
| Handler answers | `2xx` | ✅ | ✅ |
|
|
522
|
+
| Authentication guard rejects | `401` | ❌ | ✅ |
|
|
523
|
+
| Authorization guard rejects | `403` | ❌ | ✅ |
|
|
524
|
+
| Rate limiter sheds the request | `429` | ❌ | ✅ |
|
|
525
|
+
| No route matches | `404` | ❌ | ✅ |
|
|
526
|
+
| Client hangs up mid-request | — | ❌ | ✅ |
|
|
527
|
+
|
|
528
|
+
A credential-stuffing run is a flood of `401`s, route enumeration is a flood of
|
|
529
|
+
`404`s, and a throttler doing its job is a flood of `429`s. All three used to
|
|
530
|
+
leave the error graph flat.
|
|
531
|
+
|
|
532
|
+
Requests that matched no route are recorded under the fixed label
|
|
533
|
+
`UNMATCHED_ROUTE` (`<unmatched>`), never the path that was requested. The raw
|
|
534
|
+
path is attacker-controlled, and a metrics label that follows it lets anyone
|
|
535
|
+
mint one time series per probe until the process runs out of memory.
|
|
536
|
+
|
|
537
|
+
The sample is emitted when the connection closes, so a client that hangs up
|
|
538
|
+
before the response finishes is still counted — that is what a scanner does, and
|
|
539
|
+
`durationMs` covers guards and middleware as well as the handler.
|
|
540
|
+
|
|
541
|
+
An aborted request keeps whatever status the response held, which is `200` in
|
|
542
|
+
Node unless something settled another one. No sentinel status is introduced:
|
|
543
|
+
that would change the value of `status_code="200"` series that already exist in
|
|
544
|
+
your dashboards, without any change in traffic.
|
|
545
|
+
|
|
546
|
+
**Express and Fastify behave identically here**, and that is asserted end to
|
|
547
|
+
end on both. It needs saying because the two platforms disagree underneath: Nest
|
|
548
|
+
runs middleware on Fastify through `@fastify/middie`, which hands it the raw
|
|
549
|
+
`IncomingMessage` carrying no route metadata, and `forRoutes('/')` is a mount on
|
|
550
|
+
Express but an exact match on Fastify. The module resolves both for you.
|
|
551
|
+
|
|
552
|
+
> **One gap remains, and it is Nest's scoping rule, not a setting.** Module
|
|
553
|
+
> middleware is scoped to the global prefix, so with `setGlobalPrefix('api')` a
|
|
554
|
+
> request to `/nope` — outside the prefix entirely — reaches no middleware and
|
|
555
|
+
> is not recorded. Requests to `/api/nope` are recorded normally, under
|
|
556
|
+
> `<unmatched>`, so a scan that probes below your prefix is still visible; only
|
|
557
|
+
> one that probes above it is not. Covering that too is not supported yet: the
|
|
558
|
+
> module has no way to register the recorder outside its own scope, and
|
|
559
|
+
> `BymaxTimingMiddleware` is only provided when `timing` is enabled — at which
|
|
560
|
+
> point the module already applies it, so resolving and re-registering it would
|
|
561
|
+
> double-count. If you need it, open an issue rather than wiring it by hand.
|
|
562
|
+
|
|
459
563
|
Bind your own sink by providing `BYMAX_TIMING_SINK` from your own module, the
|
|
460
564
|
same override pattern shown below for the correlation provider. This applies on
|
|
461
565
|
the `forRoot` path; on `forRootAsync` the module owns `BYMAX_TIMING_SINK` (the
|
|
@@ -856,6 +960,105 @@ Both halves are the same switch. Referencing a schema that was not contributed
|
|
|
856
960
|
would leave a dangling `$ref`, and a document that resolves nowhere is worse
|
|
857
961
|
than one that says less — so `includeCoreSchemas: false` opts out of both.
|
|
858
962
|
|
|
963
|
+
### A library can describe its own routes
|
|
964
|
+
|
|
965
|
+
A library that ships controllers cannot document them itself. Decorating them
|
|
966
|
+
with `@nestjs/swagger` would load that peer in every application importing the
|
|
967
|
+
library, including the ones that never build a document — and a consumer-side
|
|
968
|
+
map keyed by path does not work either, because a library mounted through
|
|
969
|
+
`RouterModule.register` does not know its own final paths: the same route is
|
|
970
|
+
`/auth/login` in one deployment and `/api/v2/identity/login` in another, from one
|
|
971
|
+
build.
|
|
972
|
+
|
|
973
|
+
So a library marks a provider and returns fragments keyed by **handler
|
|
974
|
+
identity**, which survives every prefix, version and mount point:
|
|
975
|
+
|
|
976
|
+
```ts
|
|
977
|
+
import { BymaxOpenApiContributor } from '@bymax-one/nest-core/openapi'
|
|
978
|
+
import type { IOpenApiContributor, OpenApiFragment } from '@bymax-one/nest-core/openapi'
|
|
979
|
+
|
|
980
|
+
@BymaxOpenApiContributor()
|
|
981
|
+
@Injectable()
|
|
982
|
+
export class AuthOpenApi implements IOpenApiContributor {
|
|
983
|
+
constructor(private readonly options: ResolvedAuthOptions) {}
|
|
984
|
+
|
|
985
|
+
contributeOpenApi(): OpenApiFragment {
|
|
986
|
+
return {
|
|
987
|
+
// Required, and declared rather than inferred: a fragment crosses a
|
|
988
|
+
// boundary between independently released packages, where each side
|
|
989
|
+
// type-checked against its own copy.
|
|
990
|
+
contractVersion: 1,
|
|
991
|
+
components: {
|
|
992
|
+
securitySchemes: {
|
|
993
|
+
// Derived from resolved options — which is why this cannot be a
|
|
994
|
+
// static map a consumer writes by hand.
|
|
995
|
+
authCookie: { type: 'apiKey', in: 'cookie', name: this.options.cookies.accessTokenName },
|
|
996
|
+
// Declared because the refresh operation below requires it: a
|
|
997
|
+
// requirement naming an undeclared scheme fails the document build.
|
|
998
|
+
refreshCookie: {
|
|
999
|
+
type: 'apiKey',
|
|
1000
|
+
in: 'cookie',
|
|
1001
|
+
name: this.options.cookies.refreshTokenName
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
},
|
|
1005
|
+
operations: {
|
|
1006
|
+
'AuthController.login': { security: [] },
|
|
1007
|
+
'AuthController.refresh': { security: [{ refreshCookie: [] }] }
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
Nothing is wired by the application: enabling the document runs the scan, and a
|
|
1015
|
+
library that is never imported contributes nothing.
|
|
1016
|
+
|
|
1017
|
+
#### What the merge guarantees
|
|
1018
|
+
|
|
1019
|
+
| Rule | Behavior |
|
|
1020
|
+
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1021
|
+
| Marked, not shaped | Only providers carrying the marker are called. A class that merely exposes `contributeOpenApi` is never touched. |
|
|
1022
|
+
| Called once | While the document is built, after options resolve — so a contributor may derive its contribution from its own configuration. |
|
|
1023
|
+
| Stable order | Contributors run sorted by class name, so two libraries describing the same operation resolve the same way on every boot. |
|
|
1024
|
+
| Data, not mutation | A contributor returns fragments; this package decides what to write. That is what makes precedence enforceable. |
|
|
1025
|
+
| 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. |
|
|
1026
|
+
| Off with the document | With `openapi.enabled` false, no contributor runs. |
|
|
1027
|
+
|
|
1028
|
+
**Precedence, weakest first:** what this package infers about its own routes,
|
|
1029
|
+
then what a library contributed, then what the consumer configured, and above
|
|
1030
|
+
all of them whatever the operation already declared — a decorated handler is the
|
|
1031
|
+
consumer speaking directly and is never overwritten. So a deployment can always
|
|
1032
|
+
overrule a dependency's description of its own routes through
|
|
1033
|
+
`operationSecurity`.
|
|
1034
|
+
|
|
1035
|
+
**Operation ids are untouched.** This package installs an operation-id factory
|
|
1036
|
+
to learn which handler produced which operation, and delegates the id string —
|
|
1037
|
+
to `openapi.operationIdFactory` when you set one, to the format `@nestjs/swagger`
|
|
1038
|
+
itself produces otherwise. A client generated from your document before adopting
|
|
1039
|
+
this keeps working after.
|
|
1040
|
+
|
|
1041
|
+
**The fragment shape is translatable to an OpenAPI Overlay, deliberately.** The
|
|
1042
|
+
[Overlay Specification](https://spec.openapis.org/overlay/v1.0.0.html) is the
|
|
1043
|
+
OpenAPI Initiative's format for describing changes to a document, and a
|
|
1044
|
+
contributor's fragments are that in all but notation: a handler key resolves to
|
|
1045
|
+
an operation id, which resolves to a JSONPath target, which is what an overlay
|
|
1046
|
+
action addresses. Overlays are not used as the mechanism — their targets are
|
|
1047
|
+
paths, which is the one thing a library mounted through `RouterModule.register`
|
|
1048
|
+
cannot write, and a JSONPath engine would be a runtime dependency this package
|
|
1049
|
+
does not have. But the translation is mechanical, so a tool that emits a
|
|
1050
|
+
library's contribution as a standalone overlay can exist the day a pipeline
|
|
1051
|
+
wants one. No such tool ships here, and none is promised.
|
|
1052
|
+
|
|
1053
|
+
**Deriving the fragments is the library's business, not this package's.** A
|
|
1054
|
+
library that wants its schemas to track its own validation decorators should
|
|
1055
|
+
generate them in its own build or test suite, where that dependency already
|
|
1056
|
+
exists, and commit the result — with a test asserting generated matches
|
|
1057
|
+
committed, so drift fails in the repository that caused it. This package takes
|
|
1058
|
+
no dependency on any validation library and merges what it is given. An
|
|
1059
|
+
application's own DTOs need none of this: `@nestjs/swagger`'s CLI plugin already
|
|
1060
|
+
derives them, which is a route a precompiled library does not have.
|
|
1061
|
+
|
|
859
1062
|
### The document describes _this_ deployment
|
|
860
1063
|
|
|
861
1064
|
A feature you turned off has its routes removed from the document. With
|
|
@@ -961,7 +1164,7 @@ identically.
|
|
|
961
1164
|
│ │ │ │ │
|
|
962
1165
|
envelope/ timing/ health/ pagination/ metrics/
|
|
963
1166
|
│ │ │ │ │
|
|
964
|
-
APP_FILTER
|
|
1167
|
+
APP_FILTER middleware liveness + pure functions Prometheus
|
|
965
1168
|
│ │ readiness on their own scrape route
|
|
966
1169
|
│ │ │ subpath (opt-in)
|
|
967
1170
|
▼ ▼ ▼ │ │
|
|
@@ -996,7 +1199,7 @@ never imported, which is why it can stay an optional peer. The same holds for
|
|
|
996
1199
|
`@nestjs/swagger` and `@opentelemetry/api`: the release gate loads the packed
|
|
997
1200
|
tarball and fails if any of the three is reachable with its feature off.
|
|
998
1201
|
|
|
999
|
-
Nothing here holds state across requests. The timing
|
|
1202
|
+
Nothing here holds state across requests. The timing middleware emits and forgets;
|
|
1000
1203
|
the health service runs the indicators the app registered and folds their results;
|
|
1001
1204
|
the pagination helpers are functions of their arguments.
|
|
1002
1205
|
|
|
@@ -1050,6 +1253,26 @@ A slow indicator is converted to `down` by the aggregator rather than hanging th
|
|
|
1050
1253
|
and its `timedOutAfterMs` stays in the response either way, because that number is one this
|
|
1051
1254
|
library chose rather than text an indicator produced.
|
|
1052
1255
|
|
|
1256
|
+
### A metric an attack cannot be seen in is not a control
|
|
1257
|
+
|
|
1258
|
+
Request timing is counted as a security signal, not a performance one. A
|
|
1259
|
+
credential-stuffing run is a flood of `401`s, a privilege probe a flood of
|
|
1260
|
+
`403`s, route enumeration a flood of `404`s, and a rate limiter doing its job a
|
|
1261
|
+
flood of `429`s. None of those reaches a handler, so a recorder placed after the
|
|
1262
|
+
guards sees none of them — and an operator watching a flat error graph concludes
|
|
1263
|
+
nothing is happening. The recorder is middleware for that reason, and every
|
|
1264
|
+
closed request is counted whatever ended it.
|
|
1265
|
+
|
|
1266
|
+
### A route label is attacker-controlled input
|
|
1267
|
+
|
|
1268
|
+
The label on a timing sample comes from the matched route **template**, and a
|
|
1269
|
+
request that matched nothing is recorded under the single constant
|
|
1270
|
+
`UNMATCHED_ROUTE` (`<unmatched>`) rather than the path that was asked for.
|
|
1271
|
+
Following the path would let anyone mint one Prometheus time series per probe:
|
|
1272
|
+
a scan would grow the registry without bound, make the scrape endpoint the most
|
|
1273
|
+
expensive route in the service, and end as an out-of-memory kill. The bound is
|
|
1274
|
+
why the unmatched case is a fixed string and not a fallback to the URL.
|
|
1275
|
+
|
|
1053
1276
|
### Cursors are opaque, not secret
|
|
1054
1277
|
|
|
1055
1278
|
`encodeCursor` produces a token a client can round-trip; it is not encrypted and not
|
|
@@ -1081,6 +1304,8 @@ most likely to be exposed.
|
|
|
1081
1304
|
| 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
1305
|
| Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
|
|
1083
1306
|
| Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
|
|
1307
|
+
| 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 |
|
|
1308
|
+
| 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
1309
|
| Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
|
|
1085
1310
|
| Metrics | Opt-in; `prom-client` never imported while it is off |
|
|
1086
1311
|
| OpenAPI | Opt-in and development-only; refused in production by two independent guards, `@nestjs/swagger` never imported while it is off |
|
|
@@ -1099,13 +1324,13 @@ most likely to be exposed.
|
|
|
1099
1324
|
## 🧱 Tech Stack
|
|
1100
1325
|
|
|
1101
1326
|
- **Runtime:** Node.js 24+
|
|
1102
|
-
- **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `
|
|
1327
|
+
- **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `NestModule.configure`)
|
|
1103
1328
|
- **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
|
|
1104
1329
|
- **Optional peers:** `prom-client ^15` when metrics are enabled, `@nestjs/swagger ^11` when
|
|
1105
1330
|
OpenAPI is enabled, `@opentelemetry/api ^1.9` when trace correlation is enabled — none is
|
|
1106
1331
|
imported while its feature is off
|
|
1107
1332
|
- **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
|
|
1108
|
-
- **Tests:** Jest (unit + e2e over
|
|
1333
|
+
- **Tests:** Jest (unit + e2e over real Nest applications, Express **and** Fastify) + Stryker (mutation)
|
|
1109
1334
|
- **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
|
|
1110
1335
|
|
|
1111
1336
|
---
|
|
@@ -1120,9 +1345,11 @@ installs it, so the suite is held to a bar beyond "the tests pass".
|
|
|
1120
1345
|
`break: 95`; every killable survivor was killed by a strengthened test, with no production
|
|
1121
1346
|
change, and the nine equivalents that no test can kill each carry their reason on the line
|
|
1122
1347
|
they apply to ([report](./docs/mutation_testing_results.md))
|
|
1123
|
-
- ✅ **End-to-end against
|
|
1124
|
-
metrics routes, the served OpenAPI document, discovered indicators,
|
|
1125
|
-
trace correlation are all exercised through a booted Nest app, not
|
|
1348
|
+
- ✅ **End-to-end against real applications, on both platforms** — the filter, the middleware,
|
|
1349
|
+
the health and metrics routes, the served OpenAPI document, discovered indicators,
|
|
1350
|
+
contributed metrics and trace correlation are all exercised through a booted Nest app, not
|
|
1351
|
+
against mocks of it. Timing is asserted on **Express and Fastify**, because the two differ
|
|
1352
|
+
underneath in ways that make a passing Express suite say nothing about Fastify
|
|
1126
1353
|
- ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
|
|
1127
1354
|
system does, `check:runtime` loads every subpath from the packed tarball in ESM and
|
|
1128
1355
|
CommonJS, and `check:published` compiles this README's snippets against `dist/`
|
|
@@ -1134,7 +1361,7 @@ installs it, so the suite is held to a bar beyond "the tests pass".
|
|
|
1134
1361
|
```bash
|
|
1135
1362
|
pnpm test # unit suite
|
|
1136
1363
|
pnpm test:cov # unit suite with the 100% coverage gate
|
|
1137
|
-
pnpm test:e2e # end-to-end against
|
|
1364
|
+
pnpm test:e2e # end-to-end against real Nest applications (Express and Fastify)
|
|
1138
1365
|
pnpm mutation # Stryker mutation testing (break: 95)
|
|
1139
1366
|
pnpm typecheck # tsc strict check
|
|
1140
1367
|
pnpm lint # ESLint
|
|
@@ -1154,6 +1381,7 @@ in the sections above.
|
|
|
1154
1381
|
| `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
|
|
1155
1382
|
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
|
|
1156
1383
|
| `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
|
|
1384
|
+
| `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
|
|
1157
1385
|
| `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
1386
|
| `ICorrelationIdProvider` | type | The correlation-provider contract. |
|
|
1159
1387
|
| `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
|
|
@@ -1161,7 +1389,9 @@ in the sections above.
|
|
|
1161
1389
|
| `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
|
|
1162
1390
|
| `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
|
|
1163
1391
|
| `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
|
|
1164
|
-
| `
|
|
1392
|
+
| `BymaxTimingMiddleware` | class | The request-timing middleware, applied to every route when timing is enabled. |
|
|
1393
|
+
| `UNMATCHED_ROUTE` | constant | The bounded label recorded when a request matched no route (`<unmatched>`). |
|
|
1394
|
+
| `TimingInterceptor` | class | Deprecated: superseded by `BymaxTimingMiddleware`, which the module registers. |
|
|
1165
1395
|
| `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
|
|
1166
1396
|
| `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
|
|
1167
1397
|
| `codeForStatus` | function | Derives a catalog code from an HTTP status. |
|
|
@@ -1197,20 +1427,26 @@ in the sections above.
|
|
|
1197
1427
|
|
|
1198
1428
|
### `./openapi`
|
|
1199
1429
|
|
|
1200
|
-
| Export
|
|
1201
|
-
|
|
|
1202
|
-
| `applyBymaxOpenApi`
|
|
1203
|
-
| `OpenApiMountOutcome`
|
|
1204
|
-
| `OpenApiSkipReason`
|
|
1430
|
+
| Export | Kind | Description |
|
|
1431
|
+
| -------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------ |
|
|
1432
|
+
| `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
|
|
1433
|
+
| `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
|
|
1434
|
+
| `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
|
|
1435
|
+
| `BymaxOpenApiContributor`, `BYMAX_OPENAPI_CONTRIBUTOR_METADATA` | decorator, constant | Marks a provider as describing its own routes, and the metadata key behind it. |
|
|
1436
|
+
| `IOpenApiContributor`, `OpenApiFragment`, `OpenApiFragmentObject`, `OpenApiHandlerKey` | types | The contributor contract and the shape of what it returns. |
|
|
1205
1437
|
|
|
1206
1438
|
## 🧩 Compatibility
|
|
1207
1439
|
|
|
1208
1440
|
- Node.js `>= 24`
|
|
1209
1441
|
- NestJS `^11`
|
|
1210
|
-
- Express and Fastify,
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1442
|
+
- Express and Fastify, both covered end to end. The accessors are
|
|
1443
|
+
framework-agnostic for path, method and status, and the module absorbs the
|
|
1444
|
+
two places the platforms genuinely differ: the route mount, and the fact that
|
|
1445
|
+
Nest runs middleware on Fastify through `@fastify/middie`, which strips the
|
|
1446
|
+
route metadata off the object the middleware receives.
|
|
1447
|
+
- GraphQL and RPC execution contexts are out of scope for the error envelope and
|
|
1448
|
+
the request timing in this release; both pass errors and requests through
|
|
1449
|
+
untouched.
|
|
1214
1450
|
|
|
1215
1451
|
## 🤝 Contributing
|
|
1216
1452
|
|