@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 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
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.5.2...HEAD
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` and
519
- `BYMAX_HEALTH_INDICATORS` are consumed with `@Optional()` and are not bound by
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 | 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_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
538
- | `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
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` | Bearer-protected **iff** `metrics.authToken` is set |
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` | tokens | The DI tokens; see the [token table](#-di-tokens). |
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
 
@@ -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
@@ -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