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