@bymax-one/nest-core 1.5.2 → 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,101 @@ 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
+
14
109
  ## [1.5.2] - 2026-08-15
15
110
 
16
111
  The production guard read `NODE_ENV` and nothing else, and treated an unset
@@ -779,4 +874,5 @@ have regressed from. They are kept because the reasoning is worth having.
779
874
  [1.5.0]: https://github.com/bymaxone/nest-core/compare/v1.4.0...v1.5.0
780
875
  [1.5.1]: https://github.com/bymaxone/nest-core/compare/v1.5.0...v1.5.1
781
876
  [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
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
@@ -576,6 +576,96 @@ import { BadRequestException } from '@nestjs/common'
576
576
  throw new BadRequestException({ code: 'INVOICE_OVERDUE', message: 'Invoice is overdue' })
577
577
  ```
578
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
+
579
669
  ## ⏱️ Request Timing
580
670
 
581
671
  One `RequestTimingSample` is delivered to whatever implements `ITimingSink` for
@@ -689,13 +779,36 @@ export class InvoiceController {
689
779
 
690
780
  @Get()
691
781
  async list(@Query() raw: Record<string, unknown>): Promise<PageResult<Invoice>> {
692
- const query = normalizePageQuery(raw, { maxLimit: 50 })
782
+ const query = normalizePageQuery(raw, { maxLimit: 50, maxOffset: 100_000 })
693
783
  const { rows, total } = await this.invoices.findPage(query)
694
784
  return buildPageResult(rows, total, query)
695
785
  }
696
786
  }
697
787
  ```
698
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
+
699
812
  ### Cursor pagination
700
813
 
701
814
  ```typescript
@@ -1212,13 +1325,23 @@ being asked:
1212
1325
  | Route | Documented as |
1213
1326
  | ----------------------------------- | ------------------------------------------------------- |
1214
1327
  | `GET /health/live`, `/health/ready` | Public (`security: []`), when a document default exists |
1215
- | `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 |
1216
1330
 
1217
1331
  The probes are polled by an orchestrator holding no credential, and the scrape
1218
1332
  endpoint is protected exactly when you configured a token — this package owns
1219
1333
  both the route and the option, so you should not have to restate either. Your
1220
1334
  own `operationSecurity` entry still wins.
1221
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
+
1222
1345
  ## 🧵 Trace correlation
1223
1346
 
1224
1347
  Off by default. Enabled, it reads the span your instrumentation already opened
package/dist/index.cjs CHANGED
@@ -1397,9 +1397,12 @@ exports.BymaxCoreModule = class BymaxCoreModule extends BymaxCoreModuleBase {
1397
1397
  * it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
1398
1398
  * prescribes for "all routes" — stops matching the prefixed root once an
1399
1399
  * application calls `setGlobalPrefix`. That was reported as nest#14520 and
1400
- * fixed by nest#14522, whose regression test covers Fastify; on
1401
- * `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
1402
- * 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.
1403
1406
  *
1404
1407
  * On Fastify the same `'/'` is an exact match rather than a mount — one of
1405
1408
  * three requests reached the middleware — so the wildcard is the only form
package/dist/index.d.cts CHANGED
@@ -430,9 +430,12 @@ declare class BymaxCoreModule extends BymaxCoreModuleBase implements NestModule
430
430
  * it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
431
431
  * prescribes for "all routes" — stops matching the prefixed root once an
432
432
  * application calls `setGlobalPrefix`. That was reported as nest#14520 and
433
- * fixed by nest#14522, whose regression test covers Fastify; on
434
- * `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
435
- * 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.
436
439
  *
437
440
  * On Fastify the same `'/'` is an exact match rather than a mount — one of
438
441
  * three requests reached the middleware — so the wildcard is the only form
package/dist/index.d.ts CHANGED
@@ -430,9 +430,12 @@ declare class BymaxCoreModule extends BymaxCoreModuleBase implements NestModule
430
430
  * it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
431
431
  * prescribes for "all routes" — stops matching the prefixed root once an
432
432
  * application calls `setGlobalPrefix`. That was reported as nest#14520 and
433
- * fixed by nest#14522, whose regression test covers Fastify; on
434
- * `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
435
- * 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.
436
439
  *
437
440
  * On Fastify the same `'/'` is an exact match rather than a mount — one of
438
441
  * three requests reached the middleware — so the wildcard is the only form
package/dist/index.mjs CHANGED
@@ -1395,9 +1395,12 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
1395
1395
  * it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
1396
1396
  * prescribes for "all routes" — stops matching the prefixed root once an
1397
1397
  * application calls `setGlobalPrefix`. That was reported as nest#14520 and
1398
- * fixed by nest#14522, whose regression test covers Fastify; on
1399
- * `@nestjs/core` 11.1.28 with the Express adapter the prefixed root still
1400
- * 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.
1401
1404
  *
1402
1405
  * On Fastify the same `'/'` is an exact match rather than a mount — one of
1403
1406
  * three requests reached the middleware — so the wildcard is the only form
@@ -326,7 +326,7 @@ var CORE_PARAMETERS = {
326
326
  }
327
327
  };
328
328
 
329
- // src/openapi/openapi.document.ts
329
+ // src/openapi/openapi.shape.ts
330
330
  var OPERATION_METHODS = [
331
331
  "get",
332
332
  "post",
@@ -337,21 +337,26 @@ var OPERATION_METHODS = [
337
337
  "options",
338
338
  "trace"
339
339
  ];
340
- var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
341
- var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
342
- var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
343
340
  function asRecord(value) {
344
341
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
345
342
  return {};
346
343
  }
347
344
  return value;
348
345
  }
349
- function mergeAbsent(existing, additions) {
350
- return { ...additions, ...existing };
351
- }
352
346
  function operationsOf(item) {
353
347
  return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
354
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
+ }
355
360
  function mergeResponses(existing, additions) {
356
361
  const merged = new Map(Object.entries(existing));
357
362
  for (const [status, value] of Object.entries(additions)) {
@@ -377,21 +382,24 @@ function withoutDisabledRoutes(paths, options, routes) {
377
382
  });
378
383
  return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
379
384
  }
380
- 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) {
381
392
  if (method !== "get") {
382
393
  return void 0;
383
394
  }
384
395
  if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
385
396
  return [{ [METRICS_SCHEME_NAME]: [] }];
386
397
  }
387
- if (options.openapi.security.length > 0 && routes.isHealth(path)) {
398
+ if (inherits && (routes.isHealth(path) || routes.isMetrics(path))) {
388
399
  return [];
389
400
  }
390
401
  return void 0;
391
402
  }
392
- function operationKey(method, path) {
393
- return `${method.toUpperCase()} ${path}`;
394
- }
395
403
  function coreResponses(path, options, routes) {
396
404
  const responses = {};
397
405
  if (options.envelope.enabled) {
@@ -425,7 +433,7 @@ function mergeFragment(operation, fragment) {
425
433
  }
426
434
  return merged;
427
435
  }
428
- function augmentOperation(operation, path, method, options, routes, contributions) {
436
+ function augmentOperation(operation, path, method, options, routes, contributions, inherits) {
429
437
  const declaredByDocument = operation["security"] !== void 0;
430
438
  let result = { ...operation };
431
439
  for (const fragment of fragmentsFor(result["operationId"], contributions)) {
@@ -434,7 +442,7 @@ function augmentOperation(operation, path, method, options, routes, contribution
434
442
  if (!declaredByDocument) {
435
443
  const override = options.openapi.operationSecurity[operationKey(method, path)];
436
444
  const describedByLibrary = result["security"] !== void 0;
437
- const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes));
445
+ const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes, inherits));
438
446
  if (security !== void 0) {
439
447
  result["security"] = security;
440
448
  }
@@ -509,12 +517,20 @@ function assertOverridesMatch(paths, openapi) {
509
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(", ")}.`
510
518
  );
511
519
  }
512
- function augmentPaths(paths, options, routes, contributions) {
520
+ function augmentPaths(paths, options, routes, contributions, inherits) {
513
521
  return Object.fromEntries(
514
522
  Object.entries(paths).map(([path, item]) => {
515
523
  const augmented = operationsOf(item).map(([method, operation]) => [
516
524
  method,
517
- 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
+ )
518
534
  ]);
519
535
  return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
520
536
  })
@@ -552,7 +568,15 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
552
568
  const routes = indexOwnRoutes(options, pathPrefixes);
553
569
  const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
554
570
  assertOverridesMatch(served, openapi);
555
- 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
+ };
556
580
  const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
557
581
  return { ...document, components: Object.fromEntries(merged), ...paths, ...security };
558
582
  }
@@ -324,7 +324,7 @@ var CORE_PARAMETERS = {
324
324
  }
325
325
  };
326
326
 
327
- // src/openapi/openapi.document.ts
327
+ // src/openapi/openapi.shape.ts
328
328
  var OPERATION_METHODS = [
329
329
  "get",
330
330
  "post",
@@ -335,21 +335,26 @@ var OPERATION_METHODS = [
335
335
  "options",
336
336
  "trace"
337
337
  ];
338
- var METRICS_SCHEME_NAME = "BymaxMetricsAuth";
339
- var ERROR_ENVELOPE_SCHEMA = "BymaxErrorEnvelope";
340
- var HEALTH_RESPONSE_SCHEMA = "BymaxHealthResponse";
341
338
  function asRecord(value) {
342
339
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
343
340
  return {};
344
341
  }
345
342
  return value;
346
343
  }
347
- function mergeAbsent(existing, additions) {
348
- return { ...additions, ...existing };
349
- }
350
344
  function operationsOf(item) {
351
345
  return Object.entries(asRecord(item)).filter(([key]) => OPERATION_METHODS.includes(key));
352
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
+ }
353
358
  function mergeResponses(existing, additions) {
354
359
  const merged = new Map(Object.entries(existing));
355
360
  for (const [status, value] of Object.entries(additions)) {
@@ -375,21 +380,24 @@ function withoutDisabledRoutes(paths, options, routes) {
375
380
  });
376
381
  return Object.fromEntries(kept.filter(([, item]) => item !== void 0));
377
382
  }
378
- 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) {
379
390
  if (method !== "get") {
380
391
  return void 0;
381
392
  }
382
393
  if (options.metrics.authToken !== void 0 && routes.isMetrics(path)) {
383
394
  return [{ [METRICS_SCHEME_NAME]: [] }];
384
395
  }
385
- if (options.openapi.security.length > 0 && routes.isHealth(path)) {
396
+ if (inherits && (routes.isHealth(path) || routes.isMetrics(path))) {
386
397
  return [];
387
398
  }
388
399
  return void 0;
389
400
  }
390
- function operationKey(method, path) {
391
- return `${method.toUpperCase()} ${path}`;
392
- }
393
401
  function coreResponses(path, options, routes) {
394
402
  const responses = {};
395
403
  if (options.envelope.enabled) {
@@ -423,7 +431,7 @@ function mergeFragment(operation, fragment) {
423
431
  }
424
432
  return merged;
425
433
  }
426
- function augmentOperation(operation, path, method, options, routes, contributions) {
434
+ function augmentOperation(operation, path, method, options, routes, contributions, inherits) {
427
435
  const declaredByDocument = operation["security"] !== void 0;
428
436
  let result = { ...operation };
429
437
  for (const fragment of fragmentsFor(result["operationId"], contributions)) {
@@ -432,7 +440,7 @@ function augmentOperation(operation, path, method, options, routes, contribution
432
440
  if (!declaredByDocument) {
433
441
  const override = options.openapi.operationSecurity[operationKey(method, path)];
434
442
  const describedByLibrary = result["security"] !== void 0;
435
- const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes));
443
+ const security = override ?? (describedByLibrary ? void 0 : ownRouteSecurity(path, method, options, routes, inherits));
436
444
  if (security !== void 0) {
437
445
  result["security"] = security;
438
446
  }
@@ -507,12 +515,20 @@ function assertOverridesMatch(paths, openapi) {
507
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(", ")}.`
508
516
  );
509
517
  }
510
- function augmentPaths(paths, options, routes, contributions) {
518
+ function augmentPaths(paths, options, routes, contributions, inherits) {
511
519
  return Object.fromEntries(
512
520
  Object.entries(paths).map(([path, item]) => {
513
521
  const augmented = operationsOf(item).map(([method, operation]) => [
514
522
  method,
515
- 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
+ )
516
532
  ]);
517
533
  return [path, { ...asRecord(item), ...Object.fromEntries(augmented) }];
518
534
  })
@@ -550,7 +566,15 @@ function augmentDocument(document, options, pathPrefixes = [""], contributions =
550
566
  const routes = indexOwnRoutes(options, pathPrefixes);
551
567
  const served = withoutDisabledRoutes(asRecord(document.paths), options, routes);
552
568
  assertOverridesMatch(served, openapi);
553
- 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
+ };
554
578
  const security = openapi.security.length > 0 && document.security === void 0 ? { security: openapi.security } : {};
555
579
  return { ...document, components: Object.fromEntries(merged), ...paths, ...security };
556
580
  }
@@ -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.2",
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",