@bymax-one/nest-core 1.5.1 → 1.5.3

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,143 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.5.3] - 2026-08-18
15
+
16
+ Three findings from a functional and security audit of a derived backend
17
+ running against real Postgres, Redis and MinIO, plus the corrections that
18
+ review found inside those fixes.
19
+
20
+ The one that reaches a running deployment: an unprotected `/metrics` was
21
+ documented as **requiring a credential**. The endpoint answers anyone when no
22
+ `metrics.authToken` is set — the documented "protected at the edge"
23
+ arrangement — and it inherited the document-level default instead of declaring
24
+ itself public. That is the opposite of what 1.5.0 fixed and the more dangerous
25
+ direction: documenting a guarded route as open fails loudly at the first
26
+ generated client that omits the credential, while documenting an open route as
27
+ guarded fails nowhere and hands the wrong answer to whoever opened the document
28
+ to ask what is exposed.
29
+
30
+ **Apply to a derived backend:** bump the dependency. Nothing to change in code.
31
+ If you serve a document and leave the scrape endpoint unprotected, re-render it
32
+ and confirm `/metrics` now carries `security: []`. If your page indexes reach
33
+ SQL, `maxOffset` is now available and is opt-in.
34
+
35
+ ### Fixed
36
+
37
+ - **An unprotected `/metrics` was documented as requiring a credential.** This
38
+ package writes an explicit `security: []` on its health probes so they do not
39
+ inherit a document-level default, and did not do the same for the scrape
40
+ endpoint. With `metrics.authToken` unset — the documented "protected at the
41
+ edge" arrangement, where the endpoint answers anyone — `GET /metrics` fell
42
+ through and inherited the default, so a document served by any backend with a
43
+ default claimed a credential was required for an endpoint serving process
44
+ metrics to whoever asked.
45
+
46
+ Measured on a running derived backend, not reasoned about: no credential →
47
+ `200` with the full Prometheus body, while the served document said
48
+ `security: [{ bymaxAuthAccessCookie: [] }]`.
49
+
50
+ **One half of the fix is covered by unit tests only, and that is worth saying
51
+ rather than leaving it to look field-verified.** The reported symptom reaches
52
+ a deployment through `openapi.security`, and that path was measured. Review
53
+ then found the same hole on the other path — a document that arrives carrying
54
+ its own default, whose `openapi.security` is therefore empty — and it is fixed
55
+ by reading the effective default from the document that will be served. No
56
+ consumer known to this project reaches that state today, so the only coverage
57
+ that can be pointed at is this repository's tests — which is a statement about
58
+ what is known, not a guarantee that nothing else exercises it. The health probes carried the same defect on
59
+ that path and are fixed by the same change.
60
+
61
+ This is the more dangerous of the two ways to describe a route wrongly, and
62
+ the opposite of what 1.5.0 fixed. Documenting a **guarded** route as open
63
+ fails loudly — a generated client omits the credential and gets a `401`.
64
+ Documenting an **open** route as guarded fails nowhere, and hands the wrong
65
+ answer to whoever opened the document to ask what is exposed.
66
+
67
+ ### Added
68
+
69
+ - **`maxOffset`, a bound on how far into a dataset a request may start.**
70
+ `normalizePageQuery` capped the page size through `maxLimit` and bounded the
71
+ page index only for arithmetic safety, so `?page=1000000000&limit=20` resolved
72
+ to `OFFSET 19999999980`. Harmless against an in-memory repository and paid in
73
+ full by an offset-paginated database: twenty bytes of query for a table scan.
74
+
75
+ It is **absent by default and deliberately so** — legitimate deep paging
76
+ exists, and a silent ceiling would change the rows a working query returns.
77
+ Set it wherever the page index reaches SQL. `0` is a valid bound meaning "the
78
+ first page only"; any value that is not a non-negative safe integer reads as
79
+ absent rather than as an invented cap. Clamping matches how `maxLimit` already
80
+ behaves, and the resolved values come back in `meta`.
81
+
82
+ ### Documentation
83
+
84
+ - **What the error filter classifies from, and what it cannot.** An error raised
85
+ before any handler ran becomes a clean `4xx` because the filter recognizes it
86
+ by **shape** — `expose: true` with a `4xx` status, the convention Node's body
87
+ pipeline follows — not by class. An error carrying no such marking is a `500`
88
+ even when a client caused it: a few kilobytes nested thousands of levels deep
89
+ overflows the stack during validation and surfaces as `RangeError`, well under
90
+ any size limit.
91
+
92
+ That is deliberate. Mapping `RangeError` to a `4xx` would make the filter
93
+ infer causation from an error class and would be wrong where it matters most —
94
+ a genuine stack overflow in application code is a `500` that should page
95
+ someone. Body-shape limits are the application's floor, applied in the one
96
+ window where the body exists and nothing has walked it yet.
97
+
98
+ **Apply to a derived backend:** cap nesting depth **after the body parser and
99
+ before validation**, so a hostile body is rejected as the `400` it is instead
100
+ of becoming a `5xx` that pollutes your error rate and writes a stack per
101
+ request. On Express that means module middleware, not `app.use()` during
102
+ bootstrap — measured, a middleware registered there runs ahead of Nest's own
103
+ parser and sees `req.body` as `undefined`, so the guard inspects nothing and
104
+ protects nothing while reading as present. Walk the parsed body iteratively; a
105
+ recursive depth check on a hostile payload overflows the stack it exists to
106
+ protect. The README carries the per-adapter table and a test that proves the
107
+ floor by behaviour rather than by where it is registered.
108
+
109
+ ## [1.5.2] - 2026-08-15
110
+
111
+ The production guard read `NODE_ENV` and nothing else, and treated an unset
112
+ variable as production. An application that validates its own `APP_ENV` and
113
+ never sets `NODE_ENV` was therefore classified as production on evidence it
114
+ never gave — the OpenAPI document was refused in a development deployment, with
115
+ no way to answer back. Two independent consumers reported the same split.
116
+
117
+ **Apply to a derived backend:** nothing to change. A deployment that sets
118
+ `NODE_ENV` behaves exactly as before. If yours validates its own variable
119
+ instead, pass it as `environment` and the document is served where that variable
120
+ says `development` or `test`.
121
+
122
+ ### Added
123
+
124
+ - **`environment`, for applications that validate their own environment
125
+ variable.** The production guard read `NODE_ENV` and nothing else, and treated
126
+ an unset variable as production. An application that parses an `APP_ENV`
127
+ through its config schema and never sets `NODE_ENV` was therefore classified
128
+ as production on evidence it never gave — the OpenAPI document was refused in
129
+ a development deployment, with no way to answer back. Two independent
130
+ consumers reported the same split between the library's view of the
131
+ environment and their own validated one.
132
+
133
+ A top-level `environment` option is now consulted **where the process declares
134
+ nothing**: `NODE_ENV` unset, or set to whitespace. `NODE_ENV` wins whenever it
135
+ says anything at all, so no configured value can make a runtime that named
136
+ itself production serve the document — asserted in both guards rather than in
137
+ one. The declaration enters the same fail-closed classification, so an
138
+ unrecognized name is production like any other: this is a second source for
139
+ the value, never a second set of rules.
140
+
141
+ The narrowing is stated rather than buried. Both guards previously classified
142
+ from the process alone; now, in the single case where the process says
143
+ nothing, the snapshot a consumer bound decides the answer, because there is
144
+ nothing else to decide it with. Replacing a guess with a declaration is not an
145
+ override, but it is a real change to what the second guard depends on.
146
+
147
+ **Apply to a derived backend:** nothing to change. The option is optional and
148
+ every existing classification is unchanged — a deployment that sets `NODE_ENV`
149
+ behaves exactly as before.
150
+
14
151
  ## [1.5.1] - 2026-08-15
15
152
 
16
153
  Documentation only; no source change. The 1.5.0 warning's known-limit note told
@@ -736,4 +873,6 @@ have regressed from. They are kept because the reasoning is worth having.
736
873
  [1.4.0]: https://github.com/bymaxone/nest-core/compare/v1.3.2...v1.4.0
737
874
  [1.5.0]: https://github.com/bymaxone/nest-core/compare/v1.4.0...v1.5.0
738
875
  [1.5.1]: https://github.com/bymaxone/nest-core/compare/v1.5.0...v1.5.1
739
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.5.1...HEAD
876
+ [1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
877
+ [1.5.3]: https://github.com/bymaxone/nest-core/compare/v1.5.2...v1.5.3
878
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.5.3...HEAD
package/README.md CHANGED
@@ -202,6 +202,20 @@ BymaxCoreModule.forRoot({ isGlobal: false })
202
202
  Every block is optional; an omitted block, or an omitted field within it,
203
203
  falls back to the documented default. Pass only what you want to change.
204
204
 
205
+ ### `environment`
206
+
207
+ The one top-level option rather than a block, because it describes the
208
+ deployment rather than a feature.
209
+
210
+ | Option | Type | Default | Description |
211
+ | ------------- | -------- | ------- | ------------------------------------------------------------------------------------- |
212
+ | `environment` | `string` | unset | The environment this deployment runs in, read only where `NODE_ENV` declares nothing. |
213
+
214
+ Set it when your application validates its own environment variable and does not
215
+ also set `NODE_ENV`. `NODE_ENV` wins whenever it says anything, so this can never
216
+ serve the OpenAPI document in a runtime that named itself production. Full rules
217
+ and the classification table: [Production is a closed door](#production-is-a-closed-door).
218
+
205
219
  ### `envelope`
206
220
 
207
221
  | Option | Type | Default | Description |
@@ -562,6 +576,96 @@ import { BadRequestException } from '@nestjs/common'
562
576
  throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is overdue' })
563
577
  ```
564
578
 
579
+ ### What the filter classifies from, and what it cannot
580
+
581
+ An error raised before any handler ran — a malformed JSON body, a payload over
582
+ the size limit — still becomes a clean `4xx` envelope rather than a 500. The
583
+ filter recognizes those by **shape**, not by class: it honours an error that
584
+ marks itself `expose: true` with a `4xx` status, which is the `http-errors`
585
+ convention Node's body pipeline follows. Nothing in the application failed, so
586
+ it is not routed through the unexpected-error seam either.
587
+
588
+ **An error that carries no such marking is a 500, including when a client
589
+ caused it.** The clearest case is depth: a body of a few kilobytes nested
590
+ thousands of levels deep overflows the stack during validation and surfaces as
591
+ `RangeError: Maximum call stack size exceeded` — well under any size limit, and
592
+ answered `500 BYMAX_INTERNAL_ERROR`.
593
+
594
+ That is deliberate, and the alternative is worse. Mapping `RangeError` to a
595
+ `4xx` would make the filter infer causation from an error class, and it would be
596
+ wrong exactly where it matters: a genuine stack overflow in your own code is a
597
+ `500` that should page someone, and relabelling it as a client error would hide
598
+ the failure the `500` exists to surface. By the time the filter sees the error,
599
+ the body that caused it is gone.
600
+
601
+ **So body-shape limits are the application's floor, not the filter's.** Cap
602
+ nesting depth and the request is rejected as the `400` it is, instead of
603
+ becoming a `5xx` that pollutes your error rate and writes a stack per request.
604
+
605
+ **Position it after the body parser and before validation** — that window is the
606
+ only place the body exists in a form you can measure and nothing has walked it
607
+ yet. Earlier there is nothing to inspect; later the overflow has already
608
+ happened, which is the failure you are trying to prevent.
609
+
610
+ **Where that window is depends on the adapter, and the obvious answer is wrong
611
+ on Express.** Measured against a real Nest application:
612
+
613
+ | Registration point | `req.body` when it runs |
614
+ | ---------------------------------------- | ----------------------- |
615
+ | `app.use(...)` in `bootstrap.ts` | **`undefined`** |
616
+ | Module middleware, `configure(consumer)` | the parsed body |
617
+
618
+ Nest registers its own parser during `app.init()`, so a middleware added with
619
+ `app.use()` before that is mounted _ahead_ of it — a depth guard there inspects
620
+ nothing and silently protects nothing. Register it as module middleware
621
+ instead. On Fastify the ordering differs again, since Nest middleware runs
622
+ through `@fastify/middie` ahead of body parsing; a `preValidation` hook is the
623
+ place to look, and it is worth measuring rather than assuming.
624
+
625
+ **Registering it in the right place is not enough — the route pattern silently
626
+ skips paths too.** This package hit the same trap with its own timing
627
+ middleware, and the measured behaviour is in `core.module.ts`:
628
+
629
+ | `forRoutes(...)` | Express | Fastify |
630
+ | ---------------- | -------------- | ---------------- |
631
+ | `'*splat'` | skips the root | — |
632
+ | `'{*splat}'` | skips `/api` | every path |
633
+ | `'/'` | every path | matches `/` only |
634
+
635
+ So the named-wildcard form every migration guide reaches for leaves `POST /`
636
+ unguarded on Express. A consumer measured exactly that: with `'*path'`, a
637
+ 2000-level body to the root returned `404` because the middleware never ran;
638
+ with `'{*path}'`, `400`. If your application mounts nothing at the root, both
639
+ forms answer `4xx` and the status alone cannot tell you which one you have.
640
+
641
+ **Verify by behaviour, not by wiring — this is the part worth insisting on.**
642
+ Checking where the middleware is registered is what the consumer above did; it
643
+ looked correct, they confirmed it to us, and the guard was inert. Send a body
644
+ nested past your ceiling and require your own rejection:
645
+
646
+ ```ts
647
+ it('refuses a body nested past the ceiling', async () => {
648
+ const deep = JSON.parse(`${'['.repeat(2000)}${']'.repeat(2000)}`)
649
+
650
+ const res = await request(app.getHttpServer()).post('/anything').send({ name: deep })
651
+
652
+ // Match your guard's own message, not the status: a validation pipe rejects
653
+ // this shape with a 400 as well, so a status assertion passes with the floor
654
+ // removed and proves nothing.
655
+ expect(res.body.message).toBe('Request body is nested too deeply.')
656
+ })
657
+ ```
658
+
659
+ Use a depth that actually overflows, and assert the guard's own message. A test
660
+ at a depth your DTO validation already rejects passes identically with the guard
661
+ deleted — which is a check that cannot produce a negative result, and the reason
662
+ this defect survived a green suite.
663
+
664
+ A depth ceiling well above anything a legitimate payload nests and well below
665
+ what exhausts the stack leaves a wide margin: one consumer runs `32`, against
666
+ the ~2000 levels that overflow. Walk the parsed body iteratively — a recursive
667
+ depth check on a hostile payload overflows the stack it was written to protect.
668
+
565
669
  ## ⏱️ Request Timing
566
670
 
567
671
  One `RequestTimingSample` is delivered to whatever implements `ITimingSink` for
@@ -675,13 +779,36 @@ export class InvoiceController {
675
779
 
676
780
  @Get()
677
781
  async list(@Query() raw: Record<string, unknown>): Promise<PageResult<Invoice>> {
678
- const query = normalizePageQuery(raw, { maxLimit: 50 })
782
+ const query = normalizePageQuery(raw, { maxLimit: 50, maxOffset: 100_000 })
679
783
  const { rows, total } = await this.invoices.findPage(query)
680
784
  return buildPageResult(rows, total, query)
681
785
  }
682
786
  }
683
787
  ```
684
788
 
789
+ #### Bound the offset, not just the page size
790
+
791
+ `maxLimit` caps how many rows a request reads. `maxOffset` caps how far in it
792
+ starts — and on an offset-paginated database that is the half that costs:
793
+
794
+ ```
795
+ GET /invoices?page=1000000000&limit=20 → OFFSET 19999999980
796
+ ```
797
+
798
+ Twenty bytes of query, and Postgres walks the table to reach a page that does
799
+ not exist. The page index has a floor of `1` and an arithmetic guard that keeps
800
+ `(page - 1) * limit` an exact integer, but nothing bounds the product itself
801
+ unless you say so.
802
+
803
+ `maxOffset` is **absent by default and deliberately so**: legitimate deep paging
804
+ exists, and a silent ceiling would change the rows a working query returns. Set
805
+ it wherever the page index reaches SQL and your dataset has a knowable ceiling.
806
+ `0` is a valid bound and means "the first page only".
807
+
808
+ Clamping matches how `maxLimit` already behaves — the resolved values come back
809
+ in `meta`, so a caller that cares can compare what it asked for against what it
810
+ got.
811
+
685
812
  ### Cursor pagination
686
813
 
687
814
  ```typescript
@@ -959,16 +1086,61 @@ can emit it once and never branch:
959
1086
  ### Production is a closed door
960
1087
 
961
1088
  `NODE_ENV` decides, and the decision is fail-closed: only `development` and
962
- `test` are non-production. Any other value including an unset variable
963
- is production, and in production the document is never built and never mounted,
964
- whatever the configuration says. The guard runs twice, independently: the option
965
- resolver forces the feature off, and the bootstrap helper refuses again without
966
- trusting that resolution. There is no override.
1089
+ `test` are non-production. Any other value is production, and in production the
1090
+ document is never built and never mounted, whatever the configuration says. The
1091
+ guard runs twice, independently: the option resolver forces the feature off, and
1092
+ the bootstrap helper classifies the runtime again without trusting that
1093
+ resolution.
1094
+
1095
+ **`NODE_ENV` cannot be overridden.** With it set to anything, no option serves
1096
+ the document in a runtime it named production.
967
1097
 
968
1098
  Enabling it in production is not an error, it is a no-op with a warning naming
969
1099
  the option that was ignored, so a single configuration can be shared across
970
1100
  environments.
971
1101
 
1102
+ #### When your application validates its own environment variable
1103
+
1104
+ Plenty of applications parse an `APP_ENV` through a config schema and never set
1105
+ `NODE_ENV` at all. Those deployments used to be classified as production —
1106
+ absence was the only evidence available — so the document was refused in an
1107
+ environment that never asked for the refusal, with no way to answer back.
1108
+
1109
+ Declare the environment and it is used **where the process declares nothing**:
1110
+
1111
+ ```typescript
1112
+ BymaxCoreModule.forRootAsync({
1113
+ inject: [ConfigService],
1114
+ useFactory: (config: ConfigService) => ({
1115
+ // Your validated value, not a second copy of NODE_ENV.
1116
+ environment: config.get('APP_ENV'),
1117
+ openapi: { enabled: true }
1118
+ })
1119
+ })
1120
+ ```
1121
+
1122
+ | `NODE_ENV` | `environment` | Classified as |
1123
+ | --------------- | ------------- | -------------- |
1124
+ | `production` | `development` | **production** |
1125
+ | `development` | (anything) | development |
1126
+ | unset, or blank | `development` | development |
1127
+ | unset, or blank | `staging` | **production** |
1128
+ | unset, or blank | unset | **production** |
1129
+
1130
+ Two properties are worth reading off that table. A declaration never overrules a
1131
+ process that named its own environment — the first row is the one that matters,
1132
+ and it is asserted in both guards rather than in one. And the declaration enters
1133
+ the same fail-closed classification, so an unrecognized name is production like
1134
+ any other; this is a second **source** for the value, never a second set of
1135
+ rules.
1136
+
1137
+ The narrowing is deliberate and worth naming rather than burying: in the one
1138
+ case where the process declares nothing, the configuration a consumer bound does
1139
+ decide the answer, because there is nothing else to decide it with. Replacing a
1140
+ guess with a declaration is not the same as allowing an override — but it is a
1141
+ real change to what the second guard depends on, and you should know it before
1142
+ relying on either.
1143
+
972
1144
  ### Testing the enabled path under Jest
973
1145
 
974
1146
  `applyBymaxOpenApi` loads `@nestjs/swagger` through a dynamic `import()` — that
@@ -1153,13 +1325,23 @@ being asked:
1153
1325
  | Route | Documented as |
1154
1326
  | ----------------------------------- | ------------------------------------------------------- |
1155
1327
  | `GET /health/live`, `/health/ready` | Public (`security: []`), when a document default exists |
1156
- | `GET /metrics` | Bearer-protected **iff** `metrics.authToken` is set |
1328
+ | `GET /metrics`, token set | Bearer-protected |
1329
+ | `GET /metrics`, no token | Public (`security: []`), when a document default exists |
1157
1330
 
1158
1331
  The probes are polled by an orchestrator holding no credential, and the scrape
1159
1332
  endpoint is protected exactly when you configured a token — this package owns
1160
1333
  both the route and the option, so you should not have to restate either. Your
1161
1334
  own `operationSecurity` entry still wins.
1162
1335
 
1336
+ The last row matters more than it looks. Without a token the scrape endpoint
1337
+ answers anyone, which is the deliberate "protected at the edge" arrangement — and
1338
+ an open route must **say** it is open rather than inherit your document default.
1339
+ Of the two ways to describe a route wrongly, this is the direction that hides:
1340
+ documenting a guarded route as open fails loudly at the first generated client
1341
+ that omits the credential and gets a `401`, while documenting an open route as
1342
+ guarded fails nowhere at all, and hands the wrong answer to whoever opened the
1343
+ document to ask what is exposed.
1344
+
1163
1345
  ## 🧵 Trace correlation
1164
1346
 
1165
1347
  Off by default. Enabled, it reads the span your instrumentation already opened
@@ -1359,9 +1541,11 @@ guard you would apply to any internal endpoint, or keep it off the public listen
1359
1541
  A published document is a map of every route, parameter and error shape an application has —
1360
1542
  useful to a developer, and just as useful to anyone probing the service. So unlike the metrics
1361
1543
  endpoint, it is not left to a guard: it is refused outright whenever the runtime is not
1362
- positively `development` or `test`, in two independent layers, with no option to override.
1363
- An unset `NODE_ENV` counts as production, because the deployment nobody configured is the one
1364
- most likely to be exposed.
1544
+ positively `development` or `test`, in two independent layers. **`NODE_ENV` cannot be
1545
+ overridden** with it set to anything, no option serves the document in a runtime it named
1546
+ production. A runtime that declares nothing is classified from the application's own
1547
+ [`environment`](#environment) when it supplied one, and counts as production otherwise, because
1548
+ the deployment nobody configured is the one most likely to be exposed.
1365
1549
 
1366
1550
  ---
1367
1551
 
package/dist/index.cjs CHANGED
@@ -30,6 +30,13 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
30
30
  }
31
31
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
32
32
  }
33
+ function runtimeEnvironmentName(declared) {
34
+ const fromProcess = process.env["NODE_ENV"];
35
+ if (fromProcess !== void 0 && fromProcess.trim() !== "") {
36
+ return fromProcess;
37
+ }
38
+ return declared;
39
+ }
33
40
 
34
41
  // src/core.options.ts
35
42
  var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
@@ -92,9 +99,9 @@ function cloneServers(raw) {
92
99
  (server) => server.description === void 0 ? { url: server.url } : { url: server.url, description: server.description }
93
100
  );
94
101
  }
95
- function resolveOpenApi(raw) {
102
+ function resolveOpenApi(raw, declaredEnvironment) {
96
103
  const requested = raw?.enabled ?? false;
97
- const production = isProductionRuntime();
104
+ const production = isProductionRuntime(runtimeEnvironmentName(declaredEnvironment));
98
105
  return {
99
106
  enabled: requested && !production,
100
107
  suppressedInProduction: requested && production,
@@ -131,8 +138,12 @@ function normalizeCoreOptions(raw) {
131
138
  timing: resolveTiming(raw?.timing),
132
139
  health: resolveHealth(raw?.health),
133
140
  metrics: resolveMetrics(raw?.metrics),
134
- openapi: resolveOpenApi(raw?.openapi),
135
- telemetry: resolveTelemetry(raw?.telemetry)
141
+ openapi: resolveOpenApi(raw?.openapi, raw?.environment),
142
+ telemetry: resolveTelemetry(raw?.telemetry),
143
+ // Spread rather than assigned so an application that declared nothing has
144
+ // no `environment` member at all, matching every other optional member on
145
+ // this snapshot under `exactOptionalPropertyTypes`.
146
+ ...raw?.environment === void 0 ? {} : { environment: raw.environment }
136
147
  });
137
148
  }
138
149
  normalizeCoreOptions();
@@ -1386,9 +1397,12 @@ exports.BymaxCoreModule = class BymaxCoreModule extends BymaxCoreModuleBase {
1386
1397
  * it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
1387
1398
  * prescribes for "all routes" — stops matching the prefixed root once an
1388
1399
  * application calls `setGlobalPrefix`. That was reported as nest#14520 and
1389
- * fixed by nest#14522, whose regression test covers Fastify; on
1390
- * `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
1391
- * reaches no middleware while resolving to `200`.
1400
+ * fixed by nest#14522, whose regression test covers Fastify; on the Express
1401
+ * adapter the prefixed root still reaches no middleware while resolving to
1402
+ * `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
1403
+ * 11.2.1 — a minor release is exactly where this would plausibly have been
1404
+ * fixed, so the version this was last confirmed against is part of the
1405
+ * claim rather than a footnote to it.
1392
1406
  *
1393
1407
  * On Fastify the same `'/'` is an exact match rather than a mount — one of
1394
1408
  * three requests reached the middleware — so the wildcard is the only form
package/dist/index.d.cts CHANGED
@@ -273,6 +273,28 @@ interface BymaxCoreModuleOptions {
273
273
  openapi?: OpenApiOptions;
274
274
  /** Trace correlation. Default: disabled. */
275
275
  telemetry?: TelemetryOptions;
276
+ /**
277
+ * The environment this deployment is running in, for the features that must
278
+ * never exist outside development — today, the OpenAPI document and its UI.
279
+ *
280
+ * **`NODE_ENV` always wins.** This is consulted only when the process
281
+ * declares nothing: `NODE_ENV` unset, or set to whitespace. It cannot make a
282
+ * runtime that identified itself as production serve the document, and no
283
+ * value here overrides one there.
284
+ *
285
+ * Set it when your application validates its own environment variable — an
286
+ * `APP_ENV` your config schema parses — and does not also set `NODE_ENV`.
287
+ * Without it, that deployment is classified as production because absence was
288
+ * the only evidence available, and the document is refused in an environment
289
+ * that never asked for the refusal.
290
+ *
291
+ * Recognized non-production values are `development` and `test`, compared
292
+ * case-insensitively and ignoring surrounding whitespace. Anything else,
293
+ * including an unrecognized name, is production.
294
+ *
295
+ * @example 'development'
296
+ */
297
+ environment?: string;
276
298
  }
277
299
  /** Fully-resolved envelope options. */
278
300
  interface ResolvedEnvelopeOptions {
@@ -334,9 +356,9 @@ interface ResolvedOpenApiOptions {
334
356
  }
335
357
  /**
336
358
  * The effective, defaults-applied configuration exposed under
337
- * `BYMAX_CORE_OPTIONS`. Fields with a documented default are always present;
338
- * the only optional field is `timing.slowRequestThresholdMs`, which has no
339
- * default and is absent unless the consumer sets it.
359
+ * `BYMAX_CORE_OPTIONS`. Fields with a documented default are always present.
360
+ * Two fields have no default and are absent unless the consumer supplies them:
361
+ * `timing.slowRequestThresholdMs`, and `environment`.
340
362
  */
341
363
  interface ResolvedCoreOptions {
342
364
  envelope: ResolvedEnvelopeOptions;
@@ -345,6 +367,12 @@ interface ResolvedCoreOptions {
345
367
  metrics: ResolvedMetricsOptions;
346
368
  openapi: ResolvedOpenApiOptions;
347
369
  telemetry: ResolvedTelemetryOptions;
370
+ /**
371
+ * The environment the application declared, carried through so the bootstrap
372
+ * helper classifies the runtime from the same two inputs the resolver did.
373
+ * Absent when the application declared none.
374
+ */
375
+ environment?: string;
348
376
  }
349
377
 
350
378
  /** Non-option extras accepted by `forRoot` / `forRootAsync`. */
@@ -402,9 +430,12 @@ declare class BymaxCoreModule extends BymaxCoreModuleBase implements NestModule
402
430
  * it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
403
431
  * prescribes for "all routes" — stops matching the prefixed root once an
404
432
  * application calls `setGlobalPrefix`. That was reported as nest#14520 and
405
- * fixed by nest#14522, whose regression test covers Fastify; on
406
- * `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
407
- * reaches no middleware while resolving to `200`.
433
+ * fixed by nest#14522, whose regression test covers Fastify; on the Express
434
+ * adapter the prefixed root still reaches no middleware while resolving to
435
+ * `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
436
+ * 11.2.1 — a minor release is exactly where this would plausibly have been
437
+ * fixed, so the version this was last confirmed against is part of the
438
+ * claim rather than a footnote to it.
408
439
  *
409
440
  * On Fastify the same `'/'` is an exact match rather than a mount — one of
410
441
  * three requests reached the middleware — so the wildcard is the only form
package/dist/index.d.ts CHANGED
@@ -273,6 +273,28 @@ interface BymaxCoreModuleOptions {
273
273
  openapi?: OpenApiOptions;
274
274
  /** Trace correlation. Default: disabled. */
275
275
  telemetry?: TelemetryOptions;
276
+ /**
277
+ * The environment this deployment is running in, for the features that must
278
+ * never exist outside development — today, the OpenAPI document and its UI.
279
+ *
280
+ * **`NODE_ENV` always wins.** This is consulted only when the process
281
+ * declares nothing: `NODE_ENV` unset, or set to whitespace. It cannot make a
282
+ * runtime that identified itself as production serve the document, and no
283
+ * value here overrides one there.
284
+ *
285
+ * Set it when your application validates its own environment variable — an
286
+ * `APP_ENV` your config schema parses — and does not also set `NODE_ENV`.
287
+ * Without it, that deployment is classified as production because absence was
288
+ * the only evidence available, and the document is refused in an environment
289
+ * that never asked for the refusal.
290
+ *
291
+ * Recognized non-production values are `development` and `test`, compared
292
+ * case-insensitively and ignoring surrounding whitespace. Anything else,
293
+ * including an unrecognized name, is production.
294
+ *
295
+ * @example 'development'
296
+ */
297
+ environment?: string;
276
298
  }
277
299
  /** Fully-resolved envelope options. */
278
300
  interface ResolvedEnvelopeOptions {
@@ -334,9 +356,9 @@ interface ResolvedOpenApiOptions {
334
356
  }
335
357
  /**
336
358
  * The effective, defaults-applied configuration exposed under
337
- * `BYMAX_CORE_OPTIONS`. Fields with a documented default are always present;
338
- * the only optional field is `timing.slowRequestThresholdMs`, which has no
339
- * default and is absent unless the consumer sets it.
359
+ * `BYMAX_CORE_OPTIONS`. Fields with a documented default are always present.
360
+ * Two fields have no default and are absent unless the consumer supplies them:
361
+ * `timing.slowRequestThresholdMs`, and `environment`.
340
362
  */
341
363
  interface ResolvedCoreOptions {
342
364
  envelope: ResolvedEnvelopeOptions;
@@ -345,6 +367,12 @@ interface ResolvedCoreOptions {
345
367
  metrics: ResolvedMetricsOptions;
346
368
  openapi: ResolvedOpenApiOptions;
347
369
  telemetry: ResolvedTelemetryOptions;
370
+ /**
371
+ * The environment the application declared, carried through so the bootstrap
372
+ * helper classifies the runtime from the same two inputs the resolver did.
373
+ * Absent when the application declared none.
374
+ */
375
+ environment?: string;
348
376
  }
349
377
 
350
378
  /** Non-option extras accepted by `forRoot` / `forRootAsync`. */
@@ -402,9 +430,12 @@ declare class BymaxCoreModule extends BymaxCoreModuleBase implements NestModule
402
430
  * it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
403
431
  * prescribes for "all routes" — stops matching the prefixed root once an
404
432
  * application calls `setGlobalPrefix`. That was reported as nest#14520 and
405
- * fixed by nest#14522, whose regression test covers Fastify; on
406
- * `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
407
- * reaches no middleware while resolving to `200`.
433
+ * fixed by nest#14522, whose regression test covers Fastify; on the Express
434
+ * adapter the prefixed root still reaches no middleware while resolving to
435
+ * `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
436
+ * 11.2.1 — a minor release is exactly where this would plausibly have been
437
+ * fixed, so the version this was last confirmed against is part of the
438
+ * claim rather than a footnote to it.
408
439
  *
409
440
  * On Fastify the same `'/'` is an exact match rather than a mount — one of
410
441
  * three requests reached the middleware — so the wildcard is the only form
package/dist/index.mjs CHANGED
@@ -28,6 +28,13 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
28
28
  }
29
29
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
30
30
  }
31
+ function runtimeEnvironmentName(declared) {
32
+ const fromProcess = process.env["NODE_ENV"];
33
+ if (fromProcess !== void 0 && fromProcess.trim() !== "") {
34
+ return fromProcess;
35
+ }
36
+ return declared;
37
+ }
31
38
 
32
39
  // src/core.options.ts
33
40
  var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
@@ -90,9 +97,9 @@ function cloneServers(raw) {
90
97
  (server) => server.description === void 0 ? { url: server.url } : { url: server.url, description: server.description }
91
98
  );
92
99
  }
93
- function resolveOpenApi(raw) {
100
+ function resolveOpenApi(raw, declaredEnvironment) {
94
101
  const requested = raw?.enabled ?? false;
95
- const production = isProductionRuntime();
102
+ const production = isProductionRuntime(runtimeEnvironmentName(declaredEnvironment));
96
103
  return {
97
104
  enabled: requested && !production,
98
105
  suppressedInProduction: requested && production,
@@ -129,8 +136,12 @@ function normalizeCoreOptions(raw) {
129
136
  timing: resolveTiming(raw?.timing),
130
137
  health: resolveHealth(raw?.health),
131
138
  metrics: resolveMetrics(raw?.metrics),
132
- openapi: resolveOpenApi(raw?.openapi),
133
- telemetry: resolveTelemetry(raw?.telemetry)
139
+ openapi: resolveOpenApi(raw?.openapi, raw?.environment),
140
+ telemetry: resolveTelemetry(raw?.telemetry),
141
+ // Spread rather than assigned so an application that declared nothing has
142
+ // no `environment` member at all, matching every other optional member on
143
+ // this snapshot under `exactOptionalPropertyTypes`.
144
+ ...raw?.environment === void 0 ? {} : { environment: raw.environment }
134
145
  });
135
146
  }
136
147
  normalizeCoreOptions();
@@ -1384,9 +1395,12 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
1384
1395
  * it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
1385
1396
  * prescribes for "all routes" — stops matching the prefixed root once an
1386
1397
  * application calls `setGlobalPrefix`. That was reported as nest#14520 and
1387
- * fixed by nest#14522, whose regression test covers Fastify; on
1388
- * `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
1389
- * reaches no middleware while resolving to `200`.
1398
+ * fixed by nest#14522, whose regression test covers Fastify; on the Express
1399
+ * adapter the prefixed root still reaches no middleware while resolving to
1400
+ * `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
1401
+ * 11.2.1 — a minor release is exactly where this would plausibly have been
1402
+ * fixed, so the version this was last confirmed against is part of the
1403
+ * claim rather than a footnote to it.
1390
1404
  *
1391
1405
  * On Fastify the same `'/'` is an exact match rather than a mount — one of
1392
1406
  * three requests reached the middleware — so the wildcard is the only form
@@ -16,6 +16,13 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
16
16
  }
17
17
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
18
18
  }
19
+ function runtimeEnvironmentName(declared) {
20
+ const fromProcess = process.env["NODE_ENV"];
21
+ if (fromProcess !== void 0 && fromProcess.trim() !== "") {
22
+ return fromProcess;
23
+ }
24
+ return declared;
25
+ }
19
26
 
20
27
  // src/discovery.ts
21
28
  function labelFor(className, token) {
@@ -319,7 +326,7 @@ var CORE_PARAMETERS = {
319
326
  }
320
327
  };
321
328
 
322
- // src/openapi/openapi.document.ts
329
+ // src/openapi/openapi.shape.ts
323
330
  var OPERATION_METHODS = [
324
331
  "get",
325
332
  "post",
@@ -330,21 +337,26 @@ var OPERATION_METHODS = [
330
337
  "options",
331
338
  "trace"
332
339
  ];
333
- var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
334
- var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
335
- var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
336
340
  function asRecord(value) {
337
341
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
338
342
  return {};
339
343
  }
340
344
  return value;
341
345
  }
342
- function mergeAbsent(existing, additions) {
343
- return { ...additions, ...existing };
344
- }
345
346
  function operationsOf(item) {
346
347
  return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
347
348
  }
349
+ function operationKey(method, path) {
350
+ return `${method.toUpperCase()} ${path}`;
351
+ }
352
+
353
+ // src/openapi/openapi.document.ts
354
+ var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
355
+ var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
356
+ var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
357
+ function mergeAbsent(existing, additions) {
358
+ return { ...additions, ...existing };
359
+ }
348
360
  function mergeResponses(existing, additions) {
349
361
  const merged = new Map(Object.entries(existing));
350
362
  for (const [status, value] of Object.entries(additions)) {
@@ -370,21 +382,24 @@ function withoutDisabledRoutes(paths, options, routes) {
370
382
  });
371
383
  return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
372
384
  }
373
- function ownRouteSecurity(path, method, options, routes) {
385
+ function inheritsRequirement(document, openapi) {
386
+ if (document.security === void 0) {
387
+ return openapi.security.length > 0;
388
+ }
389
+ return Array.isArray(document.security) && document.security.length > 0;
390
+ }
391
+ function ownRouteSecurity(path, method, options, routes, inherits) {
374
392
  if (method !== "get") {
375
393
  return void 0;
376
394
  }
377
395
  if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
378
396
  return [{ [METRICS_SCHEME_NAME]: [] }];
379
397
  }
380
- if (options.openapi.security.length > 0 && routes.isHealth(path)) {
398
+ if (inherits && (routes.isHealth(path) || routes.isMetrics(path))) {
381
399
  return [];
382
400
  }
383
401
  return void 0;
384
402
  }
385
- function operationKey(method, path) {
386
- return `${method.toUpperCase()} ${path}`;
387
- }
388
403
  function coreResponses(path, options, routes) {
389
404
  const responses = {};
390
405
  if (options.envelope.enabled) {
@@ -418,7 +433,7 @@ function mergeFragment(operation, fragment) {
418
433
  }
419
434
  return merged;
420
435
  }
421
- function augmentOperation(operation, path, method, options, routes, contributions) {
436
+ function augmentOperation(operation, path, method, options, routes, contributions, inherits) {
422
437
  const declaredByDocument = operation["security"] !== void 0;
423
438
  let result = { ...operation };
424
439
  for (const fragment of fragmentsFor(result["operationId"], contributions)) {
@@ -427,7 +442,7 @@ function augmentOperation(operation, path, method, options, routes, contribution
427
442
  if (!declaredByDocument) {
428
443
  const override = options.openapi.operationSecurity[operationKey(method, path)];
429
444
  const describedByLibrary = result["security"] !== void 0;
430
- const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes));
445
+ const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes, inherits));
431
446
  if (security !== void 0) {
432
447
  result["security"] = security;
433
448
  }
@@ -502,12 +517,20 @@ function assertOverridesMatch(paths, openapi) {
502
517
  `[BymaxCoreModule] openapi.operationSecurity addresses ${unmatched.length} operation(s) that the document does not contain: ${unmatched.join(", ")}. Keys are "<METHOD> <path>" with the path exactly as documented, including any global prefix. The document contains: ${documented.length === 0 ? "(none)" : documented.join(", ")}.`
503
518
  );
504
519
  }
505
- function augmentPaths(paths, options, routes, contributions) {
520
+ function augmentPaths(paths, options, routes, contributions, inherits) {
506
521
  return Object.fromEntries(
507
522
  Object.entries(paths).map(([path, item]) => {
508
523
  const augmented = operationsOf(item).map(([method, operation]) => [
509
524
  method,
510
- augmentOperation(asRecord(operation), path, method, options, routes, contributions)
525
+ augmentOperation(
526
+ asRecord(operation),
527
+ path,
528
+ method,
529
+ options,
530
+ routes,
531
+ contributions,
532
+ inherits
533
+ )
511
534
  ]);
512
535
  return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
513
536
  })
@@ -545,7 +568,15 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
545
568
  const routes = indexOwnRoutes(options, pathPrefixes);
546
569
  const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
547
570
  assertOverridesMatch(served, openapi);
548
- const paths = document.paths === void 0 ? {} : { paths: augmentPaths(served, options, routes, contributions) };
571
+ const paths = document.paths === void 0 ? {} : {
572
+ paths: augmentPaths(
573
+ served,
574
+ options,
575
+ routes,
576
+ contributions,
577
+ inheritsRequirement(document, openapi)
578
+ )
579
+ };
549
580
  const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
550
581
  return { ...document, components: Object.fromEntries(merged), ...paths, ...security };
551
582
  }
@@ -653,7 +684,7 @@ async function applyBymaxOpenApi(app) {
653
684
  const logger = new common.Logger("BymaxCoreModule");
654
685
  const resolved = resolveCoreOptions(app);
655
686
  const options = resolved.openapi;
656
- if (isProductionRuntime()) {
687
+ if (isProductionRuntime(runtimeEnvironmentName(resolved.environment))) {
657
688
  if (options.suppressedInProduction || options.enabled) {
658
689
  logger.warn(
659
690
  'openapi.enabled was requested but the OpenAPI document is never served in production. Set NODE_ENV to "development" or "test" to serve it.'
@@ -14,6 +14,13 @@ function isProductionRuntime(value = process.env["NODE_ENV"]) {
14
14
  }
15
15
  return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
16
16
  }
17
+ function runtimeEnvironmentName(declared) {
18
+ const fromProcess = process.env["NODE_ENV"];
19
+ if (fromProcess !== void 0 && fromProcess.trim() !== "") {
20
+ return fromProcess;
21
+ }
22
+ return declared;
23
+ }
17
24
 
18
25
  // src/discovery.ts
19
26
  function labelFor(className, token) {
@@ -317,7 +324,7 @@ var CORE_PARAMETERS = {
317
324
  }
318
325
  };
319
326
 
320
- // src/openapi/openapi.document.ts
327
+ // src/openapi/openapi.shape.ts
321
328
  var OPERATION_METHODS = [
322
329
  "get",
323
330
  "post",
@@ -328,21 +335,26 @@ var OPERATION_METHODS = [
328
335
  "options",
329
336
  "trace"
330
337
  ];
331
- var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
332
- var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
333
- var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
334
338
  function asRecord(value) {
335
339
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
336
340
  return {};
337
341
  }
338
342
  return value;
339
343
  }
340
- function mergeAbsent(existing, additions) {
341
- return { ...additions, ...existing };
342
- }
343
344
  function operationsOf(item) {
344
345
  return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
345
346
  }
347
+ function operationKey(method, path) {
348
+ return `${method.toUpperCase()} ${path}`;
349
+ }
350
+
351
+ // src/openapi/openapi.document.ts
352
+ var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
353
+ var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
354
+ var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
355
+ function mergeAbsent(existing, additions) {
356
+ return { ...additions, ...existing };
357
+ }
346
358
  function mergeResponses(existing, additions) {
347
359
  const merged = new Map(Object.entries(existing));
348
360
  for (const [status, value] of Object.entries(additions)) {
@@ -368,21 +380,24 @@ function withoutDisabledRoutes(paths, options, routes) {
368
380
  });
369
381
  return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
370
382
  }
371
- function ownRouteSecurity(path, method, options, routes) {
383
+ function inheritsRequirement(document, openapi) {
384
+ if (document.security === void 0) {
385
+ return openapi.security.length > 0;
386
+ }
387
+ return Array.isArray(document.security) && document.security.length > 0;
388
+ }
389
+ function ownRouteSecurity(path, method, options, routes, inherits) {
372
390
  if (method !== "get") {
373
391
  return void 0;
374
392
  }
375
393
  if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
376
394
  return [{ [METRICS_SCHEME_NAME]: [] }];
377
395
  }
378
- if (options.openapi.security.length > 0 && routes.isHealth(path)) {
396
+ if (inherits && (routes.isHealth(path) || routes.isMetrics(path))) {
379
397
  return [];
380
398
  }
381
399
  return void 0;
382
400
  }
383
- function operationKey(method, path) {
384
- return `${method.toUpperCase()} ${path}`;
385
- }
386
401
  function coreResponses(path, options, routes) {
387
402
  const responses = {};
388
403
  if (options.envelope.enabled) {
@@ -416,7 +431,7 @@ function mergeFragment(operation, fragment) {
416
431
  }
417
432
  return merged;
418
433
  }
419
- function augmentOperation(operation, path, method, options, routes, contributions) {
434
+ function augmentOperation(operation, path, method, options, routes, contributions, inherits) {
420
435
  const declaredByDocument = operation["security"] !== void 0;
421
436
  let result = { ...operation };
422
437
  for (const fragment of fragmentsFor(result["operationId"], contributions)) {
@@ -425,7 +440,7 @@ function augmentOperation(operation, path, method, options, routes, contribution
425
440
  if (!declaredByDocument) {
426
441
  const override = options.openapi.operationSecurity[operationKey(method, path)];
427
442
  const describedByLibrary = result["security"] !== void 0;
428
- const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes));
443
+ const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes, inherits));
429
444
  if (security !== void 0) {
430
445
  result["security"] = security;
431
446
  }
@@ -500,12 +515,20 @@ function assertOverridesMatch(paths, openapi) {
500
515
  `[BymaxCoreModule] openapi.operationSecurity addresses ${unmatched.length} operation(s) that the document does not contain: ${unmatched.join(", ")}. Keys are "<METHOD> <path>" with the path exactly as documented, including any global prefix. The document contains: ${documented.length === 0 ? "(none)" : documented.join(", ")}.`
501
516
  );
502
517
  }
503
- function augmentPaths(paths, options, routes, contributions) {
518
+ function augmentPaths(paths, options, routes, contributions, inherits) {
504
519
  return Object.fromEntries(
505
520
  Object.entries(paths).map(([path, item]) => {
506
521
  const augmented = operationsOf(item).map(([method, operation]) => [
507
522
  method,
508
- augmentOperation(asRecord(operation), path, method, options, routes, contributions)
523
+ augmentOperation(
524
+ asRecord(operation),
525
+ path,
526
+ method,
527
+ options,
528
+ routes,
529
+ contributions,
530
+ inherits
531
+ )
509
532
  ]);
510
533
  return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
511
534
  })
@@ -543,7 +566,15 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
543
566
  const routes = indexOwnRoutes(options, pathPrefixes);
544
567
  const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
545
568
  assertOverridesMatch(served, openapi);
546
- const paths = document.paths === void 0 ? {} : { paths: augmentPaths(served, options, routes, contributions) };
569
+ const paths = document.paths === void 0 ? {} : {
570
+ paths: augmentPaths(
571
+ served,
572
+ options,
573
+ routes,
574
+ contributions,
575
+ inheritsRequirement(document, openapi)
576
+ )
577
+ };
547
578
  const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
548
579
  return { ...document, components: Object.fromEntries(merged), ...paths, ...security };
549
580
  }
@@ -651,7 +682,7 @@ async function applyBymaxOpenApi(app) {
651
682
  const logger = new Logger("BymaxCoreModule");
652
683
  const resolved = resolveCoreOptions(app);
653
684
  const options = resolved.openapi;
654
- if (isProductionRuntime()) {
685
+ if (isProductionRuntime(runtimeEnvironmentName(resolved.environment))) {
655
686
  if (options.suppressedInProduction || options.enabled) {
656
687
  logger.warn(
657
688
  'openapi.enabled was requested but the OpenAPI document is never served in production. Set NODE_ENV to "development" or "test" to serve it.'
@@ -19,6 +19,12 @@ function coercePositiveInt(value, fallback) {
19
19
  function clampPageToLimit(page, limit) {
20
20
  return Math.min(page, Math.floor(Number.MAX_SAFE_INTEGER / limit) + 1);
21
21
  }
22
+ function clampPageToOffset(page, limit, maxOffset) {
23
+ if (maxOffset === void 0 || !Number.isSafeInteger(maxOffset) || maxOffset < 0) {
24
+ return page;
25
+ }
26
+ return Math.min(page, Math.floor(maxOffset / limit) + 1);
27
+ }
22
28
  function clampLimit(rawLimit, options) {
23
29
  const defaultLimit = coercePositiveInt(options?.defaultLimit, DEFAULT_LIMIT);
24
30
  const maxLimit = coercePositiveInt(options?.maxLimit, DEFAULT_MAX_LIMIT);
@@ -29,7 +35,11 @@ function clampLimit(rawLimit, options) {
29
35
  function normalizePageQuery(raw, options) {
30
36
  const limit = clampLimit(raw.limit, options);
31
37
  return {
32
- page: clampPageToLimit(coercePositiveInt(raw.page, MINIMUM), limit),
38
+ page: clampPageToOffset(
39
+ clampPageToLimit(coercePositiveInt(raw.page, MINIMUM), limit),
40
+ limit,
41
+ options?.maxOffset
42
+ ),
33
43
  limit
34
44
  };
35
45
  }
@@ -51,14 +51,43 @@ interface PageResult<T> {
51
51
  * Options are per-call and never retained between calls.
52
52
  *
53
53
  * @param raw - The untrusted page and limit values from the request.
54
- * @param options - Per-call `defaultLimit` (default `20`) and `maxLimit`
55
- * (default `100`) overrides.
54
+ * @param options - Per-call `defaultLimit` (default `20`), `maxLimit` (default
55
+ * `100`) and `maxOffset` (absent by default) overrides. `maxLimit` bounds how
56
+ * many rows a request reads; `maxOffset` bounds how far in it starts, which is
57
+ * the half an offset-paginated database pays for.
56
58
  * @returns A clamped, safe query ready to hand to a repository.
57
59
  */
60
+ /**
61
+ * Options for {@link normalizePageQuery}: the shared limit bounds, plus the one
62
+ * that only means anything to offset pagination.
63
+ *
64
+ * Declared here rather than beside the shared bounds so it cannot reach the
65
+ * cursor normalizer, which takes {@link PaginationLimitOptions} and has no
66
+ * offset to bound. An option that type-checks on a function that ignores it is
67
+ * worse than a missing one — it reads as configured and does nothing.
68
+ */
69
+ interface PageQueryOptions extends PaginationLimitOptions {
70
+ /**
71
+ * Hard cap applied to the repository offset the query drives,
72
+ * `(page - 1) * limit`. Absent by default, which bounds nothing beyond
73
+ * arithmetic safety.
74
+ *
75
+ * `maxLimit` bounds how many rows a request reads; this bounds how far in it
76
+ * starts, which is the half that costs on an offset-paginated database — a
77
+ * `SELECT … OFFSET 20000000000` is a twenty-byte request that scans a table.
78
+ * Set it wherever the page index reaches SQL and the dataset has a knowable
79
+ * ceiling. There is deliberately no default: legitimate deep paging exists,
80
+ * and a silent cap would change the rows a working query returns.
81
+ *
82
+ * `0` is meaningful and means "the first page only". Any other value that is
83
+ * not a non-negative safe integer is read as absent.
84
+ */
85
+ maxOffset?: number;
86
+ }
58
87
  declare function normalizePageQuery(raw: {
59
88
  page?: unknown;
60
89
  limit?: unknown;
61
- }, options?: PaginationLimitOptions): PageQuery;
90
+ }, options?: PageQueryOptions): PageQuery;
62
91
  /**
63
92
  * Assemble a {@link PageResult} from a page of items and the total count.
64
93
  *
@@ -51,14 +51,43 @@ interface PageResult<T> {
51
51
  * Options are per-call and never retained between calls.
52
52
  *
53
53
  * @param raw - The untrusted page and limit values from the request.
54
- * @param options - Per-call `defaultLimit` (default `20`) and `maxLimit`
55
- * (default `100`) overrides.
54
+ * @param options - Per-call `defaultLimit` (default `20`), `maxLimit` (default
55
+ * `100`) and `maxOffset` (absent by default) overrides. `maxLimit` bounds how
56
+ * many rows a request reads; `maxOffset` bounds how far in it starts, which is
57
+ * the half an offset-paginated database pays for.
56
58
  * @returns A clamped, safe query ready to hand to a repository.
57
59
  */
60
+ /**
61
+ * Options for {@link normalizePageQuery}: the shared limit bounds, plus the one
62
+ * that only means anything to offset pagination.
63
+ *
64
+ * Declared here rather than beside the shared bounds so it cannot reach the
65
+ * cursor normalizer, which takes {@link PaginationLimitOptions} and has no
66
+ * offset to bound. An option that type-checks on a function that ignores it is
67
+ * worse than a missing one — it reads as configured and does nothing.
68
+ */
69
+ interface PageQueryOptions extends PaginationLimitOptions {
70
+ /**
71
+ * Hard cap applied to the repository offset the query drives,
72
+ * `(page - 1) * limit`. Absent by default, which bounds nothing beyond
73
+ * arithmetic safety.
74
+ *
75
+ * `maxLimit` bounds how many rows a request reads; this bounds how far in it
76
+ * starts, which is the half that costs on an offset-paginated database — a
77
+ * `SELECT … OFFSET 20000000000` is a twenty-byte request that scans a table.
78
+ * Set it wherever the page index reaches SQL and the dataset has a knowable
79
+ * ceiling. There is deliberately no default: legitimate deep paging exists,
80
+ * and a silent cap would change the rows a working query returns.
81
+ *
82
+ * `0` is meaningful and means "the first page only". Any other value that is
83
+ * not a non-negative safe integer is read as absent.
84
+ */
85
+ maxOffset?: number;
86
+ }
58
87
  declare function normalizePageQuery(raw: {
59
88
  page?: unknown;
60
89
  limit?: unknown;
61
- }, options?: PaginationLimitOptions): PageQuery;
90
+ }, options?: PageQueryOptions): PageQuery;
62
91
  /**
63
92
  * Assemble a {@link PageResult} from a page of items and the total count.
64
93
  *
@@ -17,6 +17,12 @@ function coercePositiveInt(value, fallback) {
17
17
  function clampPageToLimit(page, limit) {
18
18
  return Math.min(page, Math.floor(Number.MAX_SAFE_INTEGER / limit) + 1);
19
19
  }
20
+ function clampPageToOffset(page, limit, maxOffset) {
21
+ if (maxOffset === void 0 || !Number.isSafeInteger(maxOffset) || maxOffset < 0) {
22
+ return page;
23
+ }
24
+ return Math.min(page, Math.floor(maxOffset / limit) + 1);
25
+ }
20
26
  function clampLimit(rawLimit, options) {
21
27
  const defaultLimit = coercePositiveInt(options?.defaultLimit, DEFAULT_LIMIT);
22
28
  const maxLimit = coercePositiveInt(options?.maxLimit, DEFAULT_MAX_LIMIT);
@@ -27,7 +33,11 @@ function clampLimit(rawLimit, options) {
27
33
  function normalizePageQuery(raw, options) {
28
34
  const limit = clampLimit(raw.limit, options);
29
35
  return {
30
- page: clampPageToLimit(coercePositiveInt(raw.page, MINIMUM), limit),
36
+ page: clampPageToOffset(
37
+ clampPageToLimit(coercePositiveInt(raw.page, MINIMUM), limit),
38
+ limit,
39
+ options?.maxOffset
40
+ ),
31
41
  limit
32
42
  };
33
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing interceptor, pagination helpers, health endpoints with indicator discovery, an optional Prometheus metrics endpoint with a contribution contract, OpenAPI documents in development, and OpenTelemetry trace correlation.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",