@bymax-one/nest-core 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +225 -1
- package/README.md +225 -14
- package/dist/health/index.d.cts +1 -0
- package/dist/health/index.d.ts +1 -0
- package/dist/health.transition-B_IgXJTz.d.cts +119 -0
- package/dist/health.transition-B_IgXJTz.d.ts +119 -0
- package/dist/index.cjs +218 -27
- package/dist/index.d.cts +29 -11
- package/dist/index.d.ts +29 -11
- package/dist/index.mjs +218 -28
- package/dist/openapi/index.cjs +41 -17
- package/dist/openapi/index.mjs +41 -17
- package/dist/pagination/index.cjs +11 -1
- package/dist/pagination/index.d.cts +32 -3
- package/dist/pagination/index.d.ts +32 -3
- package/dist/pagination/index.mjs +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,228 @@ heading here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.6.0] - 2026-08-29
|
|
15
|
+
|
|
16
|
+
A readiness check that fails without rejecting left no record an operator would
|
|
17
|
+
ever read, and one that fails by rejecting left one per probe. Both are the same
|
|
18
|
+
missing seam, and this release adds it: the aggregator now holds the last state
|
|
19
|
+
of every check and reports each **change** — to its own logger, and to an
|
|
20
|
+
optional sink a consumer binds.
|
|
21
|
+
|
|
22
|
+
**Apply to a derived backend:** nothing is required. Transitions now reach Nest's
|
|
23
|
+
logger on every path, where before only a rejection did. Bind
|
|
24
|
+
`BYMAX_HEALTH_TRANSITION_SINK` to route them into a structured logging surface,
|
|
25
|
+
and read `cause.kind` to tell the three failure shapes apart. If you have written
|
|
26
|
+
a health reporter of your own, delete it — including any wrapper that races a
|
|
27
|
+
timer beneath `health.indicatorTimeoutMs` to recover a verdict, which this seam
|
|
28
|
+
makes unnecessary.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`IHealthTransitionSink`, bound under `BYMAX_HEALTH_TRANSITION_SINK`.**
|
|
33
|
+
Receives one `HealthTransition` per change of readiness state, per check name —
|
|
34
|
+
never one per probe. `@Optional()` and unbound by default, like
|
|
35
|
+
`BYMAX_HEALTH_INDICATORS`, so a consumer's binding is never shadowed by a local
|
|
36
|
+
one.
|
|
37
|
+
|
|
38
|
+
With no sink the aggregator writes the transition to Nest's logger, so a
|
|
39
|
+
readiness failure is never silent by default; binding one stands that line down
|
|
40
|
+
in favour of the sink. Both destinations are usually the same logger in a
|
|
41
|
+
consuming application, and two records of one transition side by side is the
|
|
42
|
+
noise this feature exists to remove. The sink is handed the cause as structured
|
|
43
|
+
data, strictly more than the line renders, so what reaches the log after that
|
|
44
|
+
is the consumer's decision rather than this package's.
|
|
45
|
+
|
|
46
|
+
The de-duplication rule lives in the aggregator rather than in the sink, and
|
|
47
|
+
that is the whole point. A readiness check runs every few seconds, so a line
|
|
48
|
+
per failing probe turns one outage into thousands of identical records that
|
|
49
|
+
bury the one carrying the cause; leaving that rule to each consumer means every
|
|
50
|
+
backend re-deriving it, slightly differently. A sink that never sees raw
|
|
51
|
+
outcomes cannot get it wrong.
|
|
52
|
+
|
|
53
|
+
Overlapping probes are ordered by when they started, not by when they
|
|
54
|
+
finished. Readiness is not called one at a time — an orchestrator's probe and a
|
|
55
|
+
load balancer's health check reach it concurrently — and a dependency that
|
|
56
|
+
hangs until the bound elapses is exactly what makes an earlier probe finish
|
|
57
|
+
last. Comparing states alone would write such an outage backwards: the later
|
|
58
|
+
probe reports the recovery, and the earlier one's timeout lands behind it and
|
|
59
|
+
reports the dependency down again on evidence that is already stale.
|
|
60
|
+
|
|
61
|
+
The rule is asymmetric on a first observation, deliberately. A first
|
|
62
|
+
observation that is **failing** is reported — a process that boots against a
|
|
63
|
+
dependency already down would otherwise look healthy in the log forever — while
|
|
64
|
+
a first observation that is healthy is not, since announcing the expected state
|
|
65
|
+
would write one line per dependency on every boot.
|
|
66
|
+
|
|
67
|
+
- **`HealthTransitionCause`, distinguishing the three ways a check is down.**
|
|
68
|
+
`reported-down` (the indicator answered and said so), `rejected` (carrying the
|
|
69
|
+
summarized message, bounded to 300 characters), and `timed-out` (carrying the
|
|
70
|
+
`timeoutMs` that elapsed). A discriminated union rather than a string, so a
|
|
71
|
+
consumer switches exhaustively and the compiler names the arm it has not
|
|
72
|
+
handled if a future version adds one.
|
|
73
|
+
|
|
74
|
+
`timed-out` is the one that exists nowhere else. An indicator this package
|
|
75
|
+
gave up on is never told, so it reports nothing, and a consumer racing its own
|
|
76
|
+
timer beneath `indicatorTimeoutMs` still never learns the bound that actually
|
|
77
|
+
applied. A hung dependency is also the common shape rather than a refused one:
|
|
78
|
+
a database under load, a network partition and a paused container all hang,
|
|
79
|
+
while a refusal returns immediately and would have been reported. The obstacle
|
|
80
|
+
for a consumer is information, not effort — which is the argument for this
|
|
81
|
+
living in the library at all.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- **An `Error` whose `message` is not a string broke the readiness aggregation.**
|
|
86
|
+
`message` is a writable property, so `Object.assign(new Error(), { message:
|
|
87
|
+
null })` is an `Error` that reads without throwing and then throws on every
|
|
88
|
+
string operation. The summarizer coerced a non-`Error` reason but trusted an
|
|
89
|
+
`Error`'s own `message`, then measured and truncated it outside that guard.
|
|
90
|
+
|
|
91
|
+
It surfaced where the guard was supposed to hold. Summarizing runs inside the
|
|
92
|
+
rejection-to-`down` conversion, so an indicator rejecting with such an error
|
|
93
|
+
rejected the whole aggregation: the probe answered `500` instead of `503`, and
|
|
94
|
+
reported nothing about the dependencies that were healthy — the exact outcome
|
|
95
|
+
the conversion exists to prevent.
|
|
96
|
+
|
|
97
|
+
**This is present in 1.5.3 and earlier**, on the indicator path. The transition
|
|
98
|
+
sink added in this release reaches the same summarizer, so the fix covers both.
|
|
99
|
+
|
|
100
|
+
- **An `async` timing sink could take the process down.** `ITimingSink.record`
|
|
101
|
+
is declared to return `void`, and TypeScript accepts any return value in a
|
|
102
|
+
void-returning position, so `async record()` compiles — and it is the natural
|
|
103
|
+
shape when the backend behind the sink is async. Its rejection settled a
|
|
104
|
+
microtask after the recorder's `try`/`catch` had exited, so instead of the
|
|
105
|
+
contained failure the contract promises it became an unhandled rejection, able
|
|
106
|
+
to kill the process under `--unhandled-rejections=strict`. An observer that can
|
|
107
|
+
break what it observes is the one thing a fire-and-forget contract exists to
|
|
108
|
+
rule out.
|
|
109
|
+
|
|
110
|
+
Both the middleware and the deprecated interceptor were affected, and both now
|
|
111
|
+
route delivery through one implementation rather than two copies of the same
|
|
112
|
+
`try`/`catch` — the containment guarantee is worth exactly as much as its least
|
|
113
|
+
careful copy. Nothing to change in a consumer: a sink that already returned
|
|
114
|
+
synchronously behaves identically, and an `async` one is now caught.
|
|
115
|
+
|
|
116
|
+
Found while reviewing the health transition sink, which had the same hole
|
|
117
|
+
before release.
|
|
118
|
+
|
|
119
|
+
### Changed
|
|
120
|
+
|
|
121
|
+
- **A rejecting indicator is logged once per outage, not once per probe.** The
|
|
122
|
+
aggregator already wrote a warning for a rejection, on every readiness check
|
|
123
|
+
for as long as the dependency stayed down: roughly sixty identical lines for a
|
|
124
|
+
ten-minute outage probed every ten seconds. That line now follows the
|
|
125
|
+
transition rule like every other path. No API moves; log volume does, and a
|
|
126
|
+
recovery now writes a line where nothing did before.
|
|
127
|
+
|
|
128
|
+
This arrives with the upgrade, not with the binding — a deployment that wires
|
|
129
|
+
no sink still stops repeating.
|
|
130
|
+
|
|
131
|
+
The cost is stated rather than hidden: a dependency that stays down while its
|
|
132
|
+
failure mode changes underneath keeps the cause observed **at the transition**.
|
|
133
|
+
That is the trade for one line per outage instead of one per probe.
|
|
134
|
+
|
|
135
|
+
- **The root bundle's size budget moved from 15 to 17 KiB brotli**, measured
|
|
136
|
+
14.25 → 15.82. Checked before the number moved: the transition contract is
|
|
137
|
+
types-only and erases at build time, no module entered the root that was not
|
|
138
|
+
already there, and the rationale prose was moved into the erased file — this
|
|
139
|
+
bundle ships its comments — before the budget was touched.
|
|
140
|
+
|
|
141
|
+
## [1.5.3] - 2026-08-18
|
|
142
|
+
|
|
143
|
+
Three findings from a functional and security audit of a derived backend
|
|
144
|
+
running against real Postgres, Redis and MinIO, plus the corrections that
|
|
145
|
+
review found inside those fixes.
|
|
146
|
+
|
|
147
|
+
The one that reaches a running deployment: an unprotected `/metrics` was
|
|
148
|
+
documented as **requiring a credential**. The endpoint answers anyone when no
|
|
149
|
+
`metrics.authToken` is set — the documented "protected at the edge"
|
|
150
|
+
arrangement — and it inherited the document-level default instead of declaring
|
|
151
|
+
itself public. That is the opposite of what 1.5.0 fixed and the more dangerous
|
|
152
|
+
direction: documenting a guarded route as open fails loudly at the first
|
|
153
|
+
generated client that omits the credential, while documenting an open route as
|
|
154
|
+
guarded fails nowhere and hands the wrong answer to whoever opened the document
|
|
155
|
+
to ask what is exposed.
|
|
156
|
+
|
|
157
|
+
**Apply to a derived backend:** bump the dependency. Nothing to change in code.
|
|
158
|
+
If you serve a document and leave the scrape endpoint unprotected, re-render it
|
|
159
|
+
and confirm `/metrics` now carries `security: []`. If your page indexes reach
|
|
160
|
+
SQL, `maxOffset` is now available and is opt-in.
|
|
161
|
+
|
|
162
|
+
### Fixed
|
|
163
|
+
|
|
164
|
+
- **An unprotected `/metrics` was documented as requiring a credential.** This
|
|
165
|
+
package writes an explicit `security: []` on its health probes so they do not
|
|
166
|
+
inherit a document-level default, and did not do the same for the scrape
|
|
167
|
+
endpoint. With `metrics.authToken` unset — the documented "protected at the
|
|
168
|
+
edge" arrangement, where the endpoint answers anyone — `GET /metrics` fell
|
|
169
|
+
through and inherited the default, so a document served by any backend with a
|
|
170
|
+
default claimed a credential was required for an endpoint serving process
|
|
171
|
+
metrics to whoever asked.
|
|
172
|
+
|
|
173
|
+
Measured on a running derived backend, not reasoned about: no credential →
|
|
174
|
+
`200` with the full Prometheus body, while the served document said
|
|
175
|
+
`security: [{ bymaxAuthAccessCookie: [] }]`.
|
|
176
|
+
|
|
177
|
+
**One half of the fix is covered by unit tests only, and that is worth saying
|
|
178
|
+
rather than leaving it to look field-verified.** The reported symptom reaches
|
|
179
|
+
a deployment through `openapi.security`, and that path was measured. Review
|
|
180
|
+
then found the same hole on the other path — a document that arrives carrying
|
|
181
|
+
its own default, whose `openapi.security` is therefore empty — and it is fixed
|
|
182
|
+
by reading the effective default from the document that will be served. No
|
|
183
|
+
consumer known to this project reaches that state today, so the only coverage
|
|
184
|
+
that can be pointed at is this repository's tests — which is a statement about
|
|
185
|
+
what is known, not a guarantee that nothing else exercises it. The health probes carried the same defect on
|
|
186
|
+
that path and are fixed by the same change.
|
|
187
|
+
|
|
188
|
+
This is the more dangerous of the two ways to describe a route wrongly, and
|
|
189
|
+
the opposite of what 1.5.0 fixed. Documenting a **guarded** route as open
|
|
190
|
+
fails loudly — a generated client omits the credential and gets a `401`.
|
|
191
|
+
Documenting an **open** route as guarded fails nowhere, and hands the wrong
|
|
192
|
+
answer to whoever opened the document to ask what is exposed.
|
|
193
|
+
|
|
194
|
+
### Added
|
|
195
|
+
|
|
196
|
+
- **`maxOffset`, a bound on how far into a dataset a request may start.**
|
|
197
|
+
`normalizePageQuery` capped the page size through `maxLimit` and bounded the
|
|
198
|
+
page index only for arithmetic safety, so `?page=1000000000&limit=20` resolved
|
|
199
|
+
to `OFFSET 19999999980`. Harmless against an in-memory repository and paid in
|
|
200
|
+
full by an offset-paginated database: twenty bytes of query for a table scan.
|
|
201
|
+
|
|
202
|
+
It is **absent by default and deliberately so** — legitimate deep paging
|
|
203
|
+
exists, and a silent ceiling would change the rows a working query returns.
|
|
204
|
+
Set it wherever the page index reaches SQL. `0` is a valid bound meaning "the
|
|
205
|
+
first page only"; any value that is not a non-negative safe integer reads as
|
|
206
|
+
absent rather than as an invented cap. Clamping matches how `maxLimit` already
|
|
207
|
+
behaves, and the resolved values come back in `meta`.
|
|
208
|
+
|
|
209
|
+
### Documentation
|
|
210
|
+
|
|
211
|
+
- **What the error filter classifies from, and what it cannot.** An error raised
|
|
212
|
+
before any handler ran becomes a clean `4xx` because the filter recognizes it
|
|
213
|
+
by **shape** — `expose: true` with a `4xx` status, the convention Node's body
|
|
214
|
+
pipeline follows — not by class. An error carrying no such marking is a `500`
|
|
215
|
+
even when a client caused it: a few kilobytes nested thousands of levels deep
|
|
216
|
+
overflows the stack during validation and surfaces as `RangeError`, well under
|
|
217
|
+
any size limit.
|
|
218
|
+
|
|
219
|
+
That is deliberate. Mapping `RangeError` to a `4xx` would make the filter
|
|
220
|
+
infer causation from an error class and would be wrong where it matters most —
|
|
221
|
+
a genuine stack overflow in application code is a `500` that should page
|
|
222
|
+
someone. Body-shape limits are the application's floor, applied in the one
|
|
223
|
+
window where the body exists and nothing has walked it yet.
|
|
224
|
+
|
|
225
|
+
**Apply to a derived backend:** cap nesting depth **after the body parser and
|
|
226
|
+
before validation**, so a hostile body is rejected as the `400` it is instead
|
|
227
|
+
of becoming a `5xx` that pollutes your error rate and writes a stack per
|
|
228
|
+
request. On Express that means module middleware, not `app.use()` during
|
|
229
|
+
bootstrap — measured, a middleware registered there runs ahead of Nest's own
|
|
230
|
+
parser and sees `req.body` as `undefined`, so the guard inspects nothing and
|
|
231
|
+
protects nothing while reading as present. Walk the parsed body iteratively; a
|
|
232
|
+
recursive depth check on a hostile payload overflows the stack it exists to
|
|
233
|
+
protect. The README carries the per-adapter table and a test that proves the
|
|
234
|
+
floor by behaviour rather than by where it is registered.
|
|
235
|
+
|
|
14
236
|
## [1.5.2] - 2026-08-15
|
|
15
237
|
|
|
16
238
|
The production guard read `NODE_ENV` and nothing else, and treated an unset
|
|
@@ -779,4 +1001,6 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
779
1001
|
[1.5.0]: https://github.com/bymaxone/nest-core/compare/v1.4.0...v1.5.0
|
|
780
1002
|
[1.5.1]: https://github.com/bymaxone/nest-core/compare/v1.5.0...v1.5.1
|
|
781
1003
|
[1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
|
|
782
|
-
[
|
|
1004
|
+
[1.5.3]: https://github.com/bymaxone/nest-core/compare/v1.5.2...v1.5.3
|
|
1005
|
+
[1.6.0]: https://github.com/bymaxone/nest-core/compare/v1.5.3...v1.6.0
|
|
1006
|
+
[Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.0...HEAD
|
package/README.md
CHANGED
|
@@ -515,9 +515,9 @@ requirement.
|
|
|
515
515
|
|
|
516
516
|
## 🔑 DI Tokens
|
|
517
517
|
|
|
518
|
-
Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER
|
|
519
|
-
`BYMAX_HEALTH_INDICATORS`
|
|
520
|
-
the module: provide either from your own module to supply your own
|
|
518
|
+
Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER`,
|
|
519
|
+
`BYMAX_HEALTH_INDICATORS` and `BYMAX_HEALTH_TRANSITION_SINK` are consumed with
|
|
520
|
+
`@Optional()` and are not bound by the module: provide either from your own module to supply your own
|
|
521
521
|
implementation, otherwise the internal fallback in the last column applies.
|
|
522
522
|
`BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY` behave differently on
|
|
523
523
|
`forRootAsync`, where options resolve after the module is defined: there the
|
|
@@ -528,14 +528,15 @@ consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
|
|
|
528
528
|
[Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
|
|
529
529
|
below.
|
|
530
530
|
|
|
531
|
-
| Token
|
|
532
|
-
|
|
|
533
|
-
| `BYMAX_CORE_OPTIONS`
|
|
534
|
-
| `BYMAX_CORRELATION_PROVIDER`
|
|
535
|
-
| `BYMAX_TIMING_SINK`
|
|
536
|
-
| `BYMAX_HEALTH_INDICATORS`
|
|
537
|
-
| `
|
|
538
|
-
| `
|
|
531
|
+
| Token | Provides | When you do not provide one |
|
|
532
|
+
| ------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
533
|
+
| `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module |
|
|
534
|
+
| `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`) |
|
|
535
|
+
| `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
|
|
536
|
+
| `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
|
|
537
|
+
| `BYMAX_HEALTH_TRANSITION_SINK` | `IHealthTransitionSink` | no sink; readiness transitions go to Nest's logger instead (binding one stands that line down) |
|
|
538
|
+
| `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
|
|
539
|
+
| `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
|
|
539
540
|
|
|
540
541
|
## 🚨 Error Envelope
|
|
541
542
|
|
|
@@ -576,6 +577,96 @@ import { BadRequestException } from '@nestjs/common'
|
|
|
576
577
|
throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is overdue' })
|
|
577
578
|
```
|
|
578
579
|
|
|
580
|
+
### What the filter classifies from, and what it cannot
|
|
581
|
+
|
|
582
|
+
An error raised before any handler ran — a malformed JSON body, a payload over
|
|
583
|
+
the size limit — still becomes a clean `4xx` envelope rather than a 500. The
|
|
584
|
+
filter recognizes those by **shape**, not by class: it honours an error that
|
|
585
|
+
marks itself `expose: true` with a `4xx` status, which is the `http-errors`
|
|
586
|
+
convention Node's body pipeline follows. Nothing in the application failed, so
|
|
587
|
+
it is not routed through the unexpected-error seam either.
|
|
588
|
+
|
|
589
|
+
**An error that carries no such marking is a 500, including when a client
|
|
590
|
+
caused it.** The clearest case is depth: a body of a few kilobytes nested
|
|
591
|
+
thousands of levels deep overflows the stack during validation and surfaces as
|
|
592
|
+
`RangeError: Maximum call stack size exceeded` — well under any size limit, and
|
|
593
|
+
answered `500 BYMAX_INTERNAL_ERROR`.
|
|
594
|
+
|
|
595
|
+
That is deliberate, and the alternative is worse. Mapping `RangeError` to a
|
|
596
|
+
`4xx` would make the filter infer causation from an error class, and it would be
|
|
597
|
+
wrong exactly where it matters: a genuine stack overflow in your own code is a
|
|
598
|
+
`500` that should page someone, and relabelling it as a client error would hide
|
|
599
|
+
the failure the `500` exists to surface. By the time the filter sees the error,
|
|
600
|
+
the body that caused it is gone.
|
|
601
|
+
|
|
602
|
+
**So body-shape limits are the application's floor, not the filter's.** Cap
|
|
603
|
+
nesting depth and the request is rejected as the `400` it is, instead of
|
|
604
|
+
becoming a `5xx` that pollutes your error rate and writes a stack per request.
|
|
605
|
+
|
|
606
|
+
**Position it after the body parser and before validation** — that window is the
|
|
607
|
+
only place the body exists in a form you can measure and nothing has walked it
|
|
608
|
+
yet. Earlier there is nothing to inspect; later the overflow has already
|
|
609
|
+
happened, which is the failure you are trying to prevent.
|
|
610
|
+
|
|
611
|
+
**Where that window is depends on the adapter, and the obvious answer is wrong
|
|
612
|
+
on Express.** Measured against a real Nest application:
|
|
613
|
+
|
|
614
|
+
| Registration point | `req.body` when it runs |
|
|
615
|
+
| ---------------------------------------- | ----------------------- |
|
|
616
|
+
| `app.use(...)` in `bootstrap.ts` | **`undefined`** |
|
|
617
|
+
| Module middleware, `configure(consumer)` | the parsed body |
|
|
618
|
+
|
|
619
|
+
Nest registers its own parser during `app.init()`, so a middleware added with
|
|
620
|
+
`app.use()` before that is mounted _ahead_ of it — a depth guard there inspects
|
|
621
|
+
nothing and silently protects nothing. Register it as module middleware
|
|
622
|
+
instead. On Fastify the ordering differs again, since Nest middleware runs
|
|
623
|
+
through `@fastify/middie` ahead of body parsing; a `preValidation` hook is the
|
|
624
|
+
place to look, and it is worth measuring rather than assuming.
|
|
625
|
+
|
|
626
|
+
**Registering it in the right place is not enough — the route pattern silently
|
|
627
|
+
skips paths too.** This package hit the same trap with its own timing
|
|
628
|
+
middleware, and the measured behaviour is in `core.module.ts`:
|
|
629
|
+
|
|
630
|
+
| `forRoutes(...)` | Express | Fastify |
|
|
631
|
+
| ---------------- | -------------- | ---------------- |
|
|
632
|
+
| `'*splat'` | skips the root | — |
|
|
633
|
+
| `'{*splat}'` | skips `/api` | every path |
|
|
634
|
+
| `'/'` | every path | matches `/` only |
|
|
635
|
+
|
|
636
|
+
So the named-wildcard form every migration guide reaches for leaves `POST /`
|
|
637
|
+
unguarded on Express. A consumer measured exactly that: with `'*path'`, a
|
|
638
|
+
2000-level body to the root returned `404` because the middleware never ran;
|
|
639
|
+
with `'{*path}'`, `400`. If your application mounts nothing at the root, both
|
|
640
|
+
forms answer `4xx` and the status alone cannot tell you which one you have.
|
|
641
|
+
|
|
642
|
+
**Verify by behaviour, not by wiring — this is the part worth insisting on.**
|
|
643
|
+
Checking where the middleware is registered is what the consumer above did; it
|
|
644
|
+
looked correct, they confirmed it to us, and the guard was inert. Send a body
|
|
645
|
+
nested past your ceiling and require your own rejection:
|
|
646
|
+
|
|
647
|
+
```ts
|
|
648
|
+
it('refuses a body nested past the ceiling', async () => {
|
|
649
|
+
const deep = JSON.parse(`${'['.repeat(2000)}${']'.repeat(2000)}`)
|
|
650
|
+
|
|
651
|
+
const res = await request(app.getHttpServer()).post('/anything').send({ name: deep })
|
|
652
|
+
|
|
653
|
+
// Match your guard's own message, not the status: a validation pipe rejects
|
|
654
|
+
// this shape with a 400 as well, so a status assertion passes with the floor
|
|
655
|
+
// removed and proves nothing.
|
|
656
|
+
expect(res.body.message).toBe('Request body is nested too deeply.')
|
|
657
|
+
})
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Use a depth that actually overflows, and assert the guard's own message. A test
|
|
661
|
+
at a depth your DTO validation already rejects passes identically with the guard
|
|
662
|
+
deleted — which is a check that cannot produce a negative result, and the reason
|
|
663
|
+
this defect survived a green suite.
|
|
664
|
+
|
|
665
|
+
A depth ceiling well above anything a legitimate payload nests and well below
|
|
666
|
+
what exhausts the stack leaves a wide margin: one consumer runs `32`, against
|
|
667
|
+
the ~2000 levels that overflow. Walk the parsed body iteratively — a recursive
|
|
668
|
+
depth check on a hostile payload overflows the stack it was written to protect.
|
|
669
|
+
|
|
579
670
|
## ⏱️ Request Timing
|
|
580
671
|
|
|
581
672
|
One `RequestTimingSample` is delivered to whatever implements `ITimingSink` for
|
|
@@ -667,6 +758,13 @@ class LoggerTimingSink implements ITimingSink {
|
|
|
667
758
|
export class ObservabilityModule {}
|
|
668
759
|
```
|
|
669
760
|
|
|
761
|
+
A sink that fails cannot reach the request: both a synchronous throw and a
|
|
762
|
+
rejection from an `async record()` are absorbed. Write it `async` if the backend
|
|
763
|
+
behind it is async — the `void` return type accepts it, and the rejection is
|
|
764
|
+
caught. The failure is swallowed rather than logged, unlike a health transition
|
|
765
|
+
sink, because this runs on every request and a systematically failing sink would
|
|
766
|
+
otherwise become a second flood beside the first.
|
|
767
|
+
|
|
670
768
|
## 📄 Pagination
|
|
671
769
|
|
|
672
770
|
Framework-neutral, pure functions on the `./pagination` subpath: no NestJS
|
|
@@ -689,13 +787,36 @@ export class InvoiceController {
|
|
|
689
787
|
|
|
690
788
|
@Get()
|
|
691
789
|
async list(@Query() raw: Record<string, unknown>): Promise<PageResult<Invoice>> {
|
|
692
|
-
const query = normalizePageQuery(raw, { maxLimit: 50 })
|
|
790
|
+
const query = normalizePageQuery(raw, { maxLimit: 50, maxOffset: 100_000 })
|
|
693
791
|
const { rows, total } = await this.invoices.findPage(query)
|
|
694
792
|
return buildPageResult(rows, total, query)
|
|
695
793
|
}
|
|
696
794
|
}
|
|
697
795
|
```
|
|
698
796
|
|
|
797
|
+
#### Bound the offset, not just the page size
|
|
798
|
+
|
|
799
|
+
`maxLimit` caps how many rows a request reads. `maxOffset` caps how far in it
|
|
800
|
+
starts — and on an offset-paginated database that is the half that costs:
|
|
801
|
+
|
|
802
|
+
```
|
|
803
|
+
GET /invoices?page=1000000000&limit=20 → OFFSET 19999999980
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
Twenty bytes of query, and Postgres walks the table to reach a page that does
|
|
807
|
+
not exist. The page index has a floor of `1` and an arithmetic guard that keeps
|
|
808
|
+
`(page - 1) * limit` an exact integer, but nothing bounds the product itself
|
|
809
|
+
unless you say so.
|
|
810
|
+
|
|
811
|
+
`maxOffset` is **absent by default and deliberately so**: legitimate deep paging
|
|
812
|
+
exists, and a silent ceiling would change the rows a working query returns. Set
|
|
813
|
+
it wherever the page index reaches SQL and your dataset has a knowable ceiling.
|
|
814
|
+
`0` is a valid bound and means "the first page only".
|
|
815
|
+
|
|
816
|
+
Clamping matches how `maxLimit` already behaves — the resolved values come back
|
|
817
|
+
in `meta`, so a caller that cares can compare what it asked for against what it
|
|
818
|
+
got.
|
|
819
|
+
|
|
699
820
|
### Cursor pagination
|
|
700
821
|
|
|
701
822
|
```typescript
|
|
@@ -787,6 +908,82 @@ A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
|
|
|
787
908
|
converted to a `down` entry with a safe, bounded diagnostic detail; it never
|
|
788
909
|
hides the results of the other registered indicators.
|
|
789
910
|
|
|
911
|
+
### A failing check leaves a record
|
|
912
|
+
|
|
913
|
+
A readiness failure that nothing records is a `503` an orchestrator acts on with
|
|
914
|
+
an empty log behind it, and diagnosing it means reproducing it. That is easy to
|
|
915
|
+
arrive at without deciding to: probe paths are the highest-volume request a
|
|
916
|
+
backend serves, so they are usually excluded from the HTTP log surface, and a
|
|
917
|
+
well-written indicator returns `{ status: 'down' }` rather than throwing —
|
|
918
|
+
because readiness is typically unauthenticated and a driver's error carries
|
|
919
|
+
hosts, ports and sometimes credentials.
|
|
920
|
+
|
|
921
|
+
So the aggregator records it. It holds the last state of every check and reports
|
|
922
|
+
each **change** — never once per probe — to its own logger, and to a sink you
|
|
923
|
+
bind:
|
|
924
|
+
|
|
925
|
+
```typescript
|
|
926
|
+
import { Injectable } from '@nestjs/common'
|
|
927
|
+
import type { HealthTransition, IHealthTransitionSink } from '@bymax-one/nest-core/health'
|
|
928
|
+
|
|
929
|
+
@Injectable()
|
|
930
|
+
export class HealthTransitionLogger implements IHealthTransitionSink {
|
|
931
|
+
constructor(private readonly logger: MyStructuredLogger) {}
|
|
932
|
+
|
|
933
|
+
record(transition: HealthTransition): void {
|
|
934
|
+
if (transition.isUp) {
|
|
935
|
+
this.logger.info('HEALTH_CHECK_RECOVERED', { check: transition.name })
|
|
936
|
+
return
|
|
937
|
+
}
|
|
938
|
+
this.logger.warn('HEALTH_CHECK_DEGRADED', {
|
|
939
|
+
check: transition.name,
|
|
940
|
+
cause: transition.cause.kind
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
Bind it under `BYMAX_HEALTH_TRANSITION_SINK` from your own `@Global()` module,
|
|
947
|
+
the same override pattern as the indicator token above.
|
|
948
|
+
|
|
949
|
+
Binding nothing is supported, and is not the silent case: the transitions reach
|
|
950
|
+
Nest's logger instead, so a readiness failure always leaves a record. **Binding a
|
|
951
|
+
sink stands that line down** — both destinations are usually the same logger, so
|
|
952
|
+
keeping it would put two records of one transition side by side, which is the
|
|
953
|
+
noise this feature exists to remove. Your sink receives the cause as structured
|
|
954
|
+
data, strictly more than the line renders, so what reaches the log after that is
|
|
955
|
+
your decision rather than this package's.
|
|
956
|
+
|
|
957
|
+
`transition.cause` distinguishes the three ways a check can be down, which the
|
|
958
|
+
`up`/`down` response body cannot:
|
|
959
|
+
|
|
960
|
+
| `cause.kind` | Meaning | Carries |
|
|
961
|
+
| --------------- | -------------------------------------------------- | ----------- |
|
|
962
|
+
| `reported-down` | The indicator answered, and reported it down. | — |
|
|
963
|
+
| `rejected` | The indicator rejected or threw. | `message` |
|
|
964
|
+
| `timed-out` | The aggregator gave up after `indicatorTimeoutMs`. | `timeoutMs` |
|
|
965
|
+
|
|
966
|
+
`timed-out` is the one that cannot be observed anywhere else. An indicator the
|
|
967
|
+
aggregator abandoned is never told, so it reports nothing — and a hung
|
|
968
|
+
dependency is the common shape, not a refused one: a database under load, a
|
|
969
|
+
network partition and a paused container all hang, while a refusal comes back
|
|
970
|
+
immediately and would have been reported. That is why the rule lives in the
|
|
971
|
+
aggregator rather than in each backend: the obstacle is information, not effort.
|
|
972
|
+
|
|
973
|
+
Two details worth knowing before you rely on it. A first observation that is
|
|
974
|
+
**failing** is reported, while a first observation that is healthy is not — a
|
|
975
|
+
process that boots against a dependency already down would otherwise look
|
|
976
|
+
healthy in the log forever, whereas announcing every healthy check would write a
|
|
977
|
+
line per dependency on every boot. And the cause is the one seen **at the
|
|
978
|
+
transition**: a dependency that stays down while its failure mode changes
|
|
979
|
+
underneath keeps the first cause, which is the cost of one line per outage
|
|
980
|
+
instead of one per probe.
|
|
981
|
+
|
|
982
|
+
The sink is called synchronously on the readiness path, so keep it cheap. A
|
|
983
|
+
throw is caught and logged rather than failing the probe — readiness answering
|
|
984
|
+
`500` because its own logging broke would take a healthy deployment out of
|
|
985
|
+
rotation — but do not use that as flow control.
|
|
986
|
+
|
|
790
987
|
### Discovered indicators
|
|
791
988
|
|
|
792
989
|
Registering every indicator by hand stops scaling once the libraries an
|
|
@@ -1212,13 +1409,23 @@ being asked:
|
|
|
1212
1409
|
| Route | Documented as |
|
|
1213
1410
|
| ----------------------------------- | ------------------------------------------------------- |
|
|
1214
1411
|
| `GET /health/live`, `/health/ready` | Public (`security: []`), when a document default exists |
|
|
1215
|
-
| `GET /metrics
|
|
1412
|
+
| `GET /metrics`, token set | Bearer-protected |
|
|
1413
|
+
| `GET /metrics`, no token | Public (`security: []`), when a document default exists |
|
|
1216
1414
|
|
|
1217
1415
|
The probes are polled by an orchestrator holding no credential, and the scrape
|
|
1218
1416
|
endpoint is protected exactly when you configured a token — this package owns
|
|
1219
1417
|
both the route and the option, so you should not have to restate either. Your
|
|
1220
1418
|
own `operationSecurity` entry still wins.
|
|
1221
1419
|
|
|
1420
|
+
The last row matters more than it looks. Without a token the scrape endpoint
|
|
1421
|
+
answers anyone, which is the deliberate "protected at the edge" arrangement — and
|
|
1422
|
+
an open route must **say** it is open rather than inherit your document default.
|
|
1423
|
+
Of the two ways to describe a route wrongly, this is the direction that hides:
|
|
1424
|
+
documenting a guarded route as open fails loudly at the first generated client
|
|
1425
|
+
that omits the credential and gets a `401`, while documenting an open route as
|
|
1426
|
+
guarded fails nowhere at all, and hands the wrong answer to whoever opened the
|
|
1427
|
+
document to ask what is exposed.
|
|
1428
|
+
|
|
1222
1429
|
## 🧵 Trace correlation
|
|
1223
1430
|
|
|
1224
1431
|
Off by default. Enabled, it reads the span your instrumentation already opened
|
|
@@ -1513,7 +1720,8 @@ in the sections above.
|
|
|
1513
1720
|
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
|
|
1514
1721
|
| `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
|
|
1515
1722
|
| `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
|
|
1516
|
-
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY`
|
|
1723
|
+
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT` | tokens | The DI tokens; see the [token table](#-di-tokens). |
|
|
1724
|
+
| `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
|
|
1517
1725
|
| `ICorrelationIdProvider` | type | The correlation-provider contract. |
|
|
1518
1726
|
| `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
|
|
1519
1727
|
| `BymaxExceptionFilter` | class | The envelope exception filter. |
|
|
@@ -1544,6 +1752,9 @@ in the sections above.
|
|
|
1544
1752
|
| `HealthIndicatorResult` | type | The outcome of a single indicator check. |
|
|
1545
1753
|
| `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
|
|
1546
1754
|
| `HealthResponse` | type | The stable liveness and readiness response shape. |
|
|
1755
|
+
| `IHealthTransitionSink` | type | Receives one event per change of readiness state. |
|
|
1756
|
+
| `HealthTransition` | type | One change of state, for one check. |
|
|
1757
|
+
| `HealthTransitionCause` | type | Why a check went down, as the aggregator sees it. |
|
|
1547
1758
|
| `BymaxHealthIndicator` | function | Class decorator marking a provider as discoverable. |
|
|
1548
1759
|
| `BYMAX_HEALTH_INDICATOR_METADATA` | constant | The metadata key the marker writes. |
|
|
1549
1760
|
|
package/dist/health/index.d.cts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CustomDecorator } from '@nestjs/common';
|
|
2
|
+
export { H as HealthTransition, a as HealthTransitionCause, I as IHealthTransitionSink } from '../health.transition-B_IgXJTz.cjs';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Reflect metadata key carrying the discoverable marker. Namespaced so it cannot
|
package/dist/health/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CustomDecorator } from '@nestjs/common';
|
|
2
|
+
export { H as HealthTransition, a as HealthTransitionCause, I as IHealthTransitionSink } from '../health.transition-B_IgXJTz.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Reflect metadata key carrying the discoverable marker. Namespaced so it cannot
|