@bymax-one/nest-core 1.2.0 → 1.2.2

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,46 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.2] - 2026-08-10
15
+
16
+ Remediation of a local audit's metrics-auth and pagination-bound findings (merged in #62). No
17
+ API changed.
18
+
19
+ ### Fixed
20
+
21
+ - **Offset-safe page cap.** `normalizePageQuery` resolves the limit first and caps `page` to
22
+ `floor(MAX_SAFE_INTEGER / limit) + 1`, so a hostile `page` can no longer drive
23
+ `(page - 1) * limit` past the safe-integer range and lose precision before a repository computes
24
+ its offset.
25
+ - **The `/metrics` bearer scheme is matched case-insensitively.** An HTTP auth scheme is
26
+ case-insensitive (RFC 7235) and may be separated from the credential by more than one space or a
27
+ tab; the check now accepts `bearer`/`BEARER`/mixed case and that whitespace, and anchors the
28
+ scheme to the start of the header to close a mid-string smuggling path.
29
+ - **A misconfigured scrape token fails closed.** A `metrics.authToken` configured empty or
30
+ whitespace-only is now rejected at boot instead of being silently treated as unset — which left
31
+ `/metrics` open. A real token is kept verbatim.
32
+
33
+ ### Documentation
34
+
35
+ - `metrics.authToken` is documented in the README and the technical specification, including a
36
+ protected-scrape example.
37
+
38
+ ## [1.2.1] - 2026-08-08
39
+
40
+ A patch: the envelope fix below changes a response status for a class of client errors, without
41
+ touching the module's API or any option.
42
+
43
+ ### Fixed
44
+
45
+ - **An error carrying a 4xx status it marked exposable keeps that status, instead of collapsing to 500.** Express's body pipeline throws `http-errors` instances before any handler runs — a payload
46
+ past the limit is `PayloadTooLargeError` (413), malformed JSON is a `SyntaxError` (400), an
47
+ unsupported media type is 415. None is a Nest `HttpException`, so each reached the generic
48
+ 500 collapse: a client that sent too large a body, or malformed JSON, was told the server failed,
49
+ and a monitor counted a 5xx for a request that never entered the application. The filter now reads
50
+ the `expose: true` flag and the numeric status these carry and honours it — restricted to the 4xx
51
+ range, because a self-reported 5xx is still a server failure whose account of itself must not
52
+ surface, so it stays a generic 500.
53
+
14
54
  ## [1.2.0] - 2026-08-08
15
55
 
16
56
  Both entries change what a caller receives, which is why this is a minor rather than a patch: an
@@ -301,5 +341,7 @@ have regressed from. They are kept because the reasoning is worth having.
301
341
  [1.0.1]: https://github.com/bymaxone/nest-core/compare/v1.0.0...v1.0.1
302
342
  [1.0.0]: https://github.com/bymaxone/nest-core/releases/tag/v1.0.0
303
343
  [1.1.1]: https://github.com/bymaxone/nest-core/compare/v1.1.0...v1.1.1
344
+ [1.2.2]: https://github.com/bymaxone/nest-core/compare/v1.2.1...v1.2.2
345
+ [1.2.1]: https://github.com/bymaxone/nest-core/compare/v1.2.0...v1.2.1
304
346
  [1.2.0]: https://github.com/bymaxone/nest-core/compare/v1.1.1...v1.2.0
305
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.2.0...HEAD
347
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.2.2...HEAD
package/README.md CHANGED
@@ -235,18 +235,34 @@ configuration fails fast at the route rather than at boot.
235
235
 
236
236
  ### `metrics`
237
237
 
238
- | Option | Type | Default | Description |
239
- | ----------------------- | ------------------------ | ----------- | --------------------------------------------------------------------- |
240
- | `enabled` | `boolean` | `false` | Registers the metrics controller and the registry. |
241
- | `path` | `string` | `'metrics'` | Route serving the Prometheus scrape. |
242
- | `defaultLabels` | `Record<string, string>` | `{}` | Static labels attached to every metric. |
243
- | `collectDefaultMetrics` | `boolean` | `true` | Collects `prom-client`'s process CPU, memory, and event-loop metrics. |
238
+ | Option | Type | Default | Description |
239
+ | ----------------------- | ------------------------ | ----------- | ------------------------------------------------------------------------------------------------ |
240
+ | `enabled` | `boolean` | `false` | Registers the metrics controller and the registry. |
241
+ | `path` | `string` | `'metrics'` | Route serving the Prometheus scrape. |
242
+ | `defaultLabels` | `Record<string, string>` | `{}` | Static labels attached to every metric. |
243
+ | `collectDefaultMetrics` | `boolean` | `true` | Collects `prom-client`'s process CPU, memory, and event-loop metrics. |
244
+ | `authToken` | `string` | _(unset)_ | Bearer required to scrape. Unset leaves the endpoint open; empty/whitespace is rejected at boot. |
244
245
 
245
246
  As with `health`, `enabled` and `path` register conditionally on `forRoot`. On
246
247
  `forRootAsync` the metrics controller is always registered at the default path
247
248
  and enforces `enabled` and the default path with a request-time guard, so a
248
249
  disabled or custom-path async configuration fails fast at the route.
249
250
 
251
+ By default the scrape endpoint is **open** — the exposition publishes the route
252
+ inventory and, with `collectDefaultMetrics`, process internals to any caller. Set
253
+ `authToken` to require `Authorization: Bearer <token>` (the scheme is matched
254
+ case-insensitively; the token is compared in constant time), or protect the route at
255
+ your edge (network policy, ingress auth). A token configured empty or whitespace-only
256
+ is rejected at boot rather than silently ignored, so a mistyped secret fails loud
257
+ instead of leaving the endpoint open:
258
+
259
+ ```ts
260
+ BymaxCoreModule.forRoot({
261
+ metrics: { enabled: true, authToken: process.env.METRICS_TOKEN }
262
+ })
263
+ // Scrape: curl -H "Authorization: Bearer $METRICS_TOKEN" http://host/metrics
264
+ ```
265
+
250
266
  ### `telemetry`
251
267
 
252
268
  | Option | Type | Default | Description |
package/dist/index.cjs CHANGED
@@ -3,6 +3,7 @@
3
3
  var common = require('@nestjs/common');
4
4
  var core = require('@nestjs/core');
5
5
  var rxjs = require('rxjs');
6
+ var crypto = require('crypto');
6
7
 
7
8
  var __defProp = Object.defineProperty;
8
9
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -62,12 +63,25 @@ function resolveHealth(raw) {
62
63
  autoDiscover: raw?.autoDiscover ?? false
63
64
  };
64
65
  }
66
+ function resolveMetricsAuthToken(raw) {
67
+ if (raw === void 0) {
68
+ return void 0;
69
+ }
70
+ if (raw.trim() === "") {
71
+ throw new Error(
72
+ '[BymaxCoreModule] "metrics.authToken" was configured empty or whitespace-only. Leave it unset to expose an open /metrics endpoint (protected at the edge), or set a non-empty bearer token to require credentialed scrapes.'
73
+ );
74
+ }
75
+ return raw;
76
+ }
65
77
  function resolveMetrics(raw) {
78
+ const authToken = resolveMetricsAuthToken(raw?.authToken);
66
79
  return {
67
80
  enabled: raw?.enabled ?? false,
68
81
  path: raw?.path ?? DEFAULT_METRICS_PATH,
69
82
  collectDefaultMetrics: raw?.collectDefaultMetrics ?? true,
70
- defaultLabels: { ...raw?.defaultLabels ?? {} }
83
+ defaultLabels: { ...raw?.defaultLabels ?? {} },
84
+ ...authToken !== void 0 ? { authToken } : {}
71
85
  };
72
86
  }
73
87
  function cloneServers(raw) {
@@ -310,6 +324,23 @@ function extractExplicitDetails(carrier) {
310
324
  const details = carrier.details;
311
325
  return typeof details === "object" && details !== null ? details : void 0;
312
326
  }
327
+ var CLIENT_ERROR_MIN2 = 400;
328
+ var CLIENT_ERROR_MAX2 = 500;
329
+ function resolveExposedClientError(exception) {
330
+ if (typeof exception !== "object" || exception === null) {
331
+ return void 0;
332
+ }
333
+ const candidate = exception;
334
+ if (candidate.expose !== true) {
335
+ return void 0;
336
+ }
337
+ const status = Number.isInteger(candidate.status) ? candidate.status : candidate.statusCode;
338
+ if (typeof status !== "number" || !Number.isInteger(status) || status < CLIENT_ERROR_MIN2 || status >= CLIENT_ERROR_MAX2) {
339
+ return void 0;
340
+ }
341
+ const message = exception instanceof Error ? exception.message : "";
342
+ return { status, message: message === "" ? INTERNAL_ERROR_MESSAGE : message };
343
+ }
313
344
  function isValidationResponse(response) {
314
345
  return typeof response === "object" && response !== null && "message" in response && Array.isArray(response.message);
315
346
  }
@@ -424,6 +455,15 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
424
455
  if (exception instanceof common.HttpException) {
425
456
  return this.mapHttpException(exception, context);
426
457
  }
458
+ const exposed = resolveExposedClientError(exception);
459
+ if (exposed !== void 0) {
460
+ return this.toEnvelope(
461
+ exposed.status,
462
+ codeForStatus(exposed.status),
463
+ exposed.message,
464
+ context
465
+ );
466
+ }
427
467
  try {
428
468
  this.onUnexpectedError(exception, context);
429
469
  } catch {
@@ -1050,6 +1090,18 @@ function assertControllerMatchesOptions2(options, registeredPath) {
1050
1090
  );
1051
1091
  }
1052
1092
  }
1093
+ function bearerMatches(authorization, expected) {
1094
+ if (typeof authorization !== "string") {
1095
+ return false;
1096
+ }
1097
+ const presented = authorization.replace(/^bearer[ \t]+/i, "");
1098
+ if (presented === authorization) {
1099
+ return false;
1100
+ }
1101
+ const presentedDigest = crypto.createHash("sha256").update(presented).digest();
1102
+ const expectedDigest = crypto.createHash("sha256").update(expected).digest();
1103
+ return crypto.timingSafeEqual(presentedDigest, expectedDigest);
1104
+ }
1053
1105
  function createMetricsController(registeredPath) {
1054
1106
  let MetricsController = class {
1055
1107
  /**
@@ -1062,8 +1114,12 @@ function createMetricsController(registeredPath) {
1062
1114
  this.options = options;
1063
1115
  this.adapterHost = adapterHost;
1064
1116
  }
1065
- async scrape(response) {
1117
+ async scrape(response, request) {
1066
1118
  assertControllerMatchesOptions2(this.options, registeredPath);
1119
+ const { authToken } = this.options.metrics;
1120
+ if (authToken !== void 0 && !bearerMatches(request.headers?.["authorization"], authToken)) {
1121
+ throw new common.UnauthorizedException();
1122
+ }
1067
1123
  const body = await this.registry.metrics();
1068
1124
  this.adapterHost.httpAdapter.setHeader(response, "Content-Type", this.registry.contentType);
1069
1125
  this.adapterHost.httpAdapter.reply(response, body, common.HttpStatus.OK);
@@ -1071,7 +1127,8 @@ function createMetricsController(registeredPath) {
1071
1127
  };
1072
1128
  __decorateClass([
1073
1129
  common.Get(),
1074
- __decorateParam(0, common.Res())
1130
+ __decorateParam(0, common.Res()),
1131
+ __decorateParam(1, common.Req())
1075
1132
  ], MetricsController.prototype, "scrape", 1);
1076
1133
  MetricsController = __decorateClass([
1077
1134
  common.Controller(registeredPath),
package/dist/index.d.cts CHANGED
@@ -137,6 +137,18 @@ interface MetricsOptions {
137
137
  defaultLabels?: Record<string, string>;
138
138
  /** Collect `prom-client` default process metrics. Default: `true`. */
139
139
  collectDefaultMetrics?: boolean;
140
+ /**
141
+ * A bearer token the scrape endpoint requires. When set, a request must carry
142
+ * `Authorization: Bearer <token>` matching this value (the scheme is matched
143
+ * case-insensitively; the token is compared in constant time) or it is refused
144
+ * with `401`. When unset (the default) the endpoint is open, so a deployment that
145
+ * exposes `/metrics` beyond a trusted network must either set this or protect the
146
+ * route at its edge — the exposition otherwise publishes the route inventory and
147
+ * `collectDefaultMetrics` process internals to any caller. Configuring this empty
148
+ * or whitespace-only is rejected at boot rather than treated as unset, so a
149
+ * mistyped secret fails loud instead of silently leaving the endpoint open.
150
+ */
151
+ authToken?: string;
140
152
  }
141
153
  /**
142
154
  * Consumer-facing options for `BymaxCoreModule.forRoot` / `forRootAsync`. Every
@@ -179,12 +191,13 @@ interface ResolvedTelemetryOptions {
179
191
  enabled: boolean;
180
192
  exposeTraceId: boolean;
181
193
  }
182
- /** Fully-resolved metrics options. */
194
+ /** Fully-resolved metrics options. `authToken` stays absent when unset. */
183
195
  interface ResolvedMetricsOptions {
184
196
  enabled: boolean;
185
197
  path: string;
186
198
  collectDefaultMetrics: boolean;
187
199
  defaultLabels: Record<string, string>;
200
+ authToken?: string;
188
201
  }
189
202
  /** Fully-resolved OpenAPI options. */
190
203
  interface ResolvedOpenApiOptions {
package/dist/index.d.ts CHANGED
@@ -137,6 +137,18 @@ interface MetricsOptions {
137
137
  defaultLabels?: Record<string, string>;
138
138
  /** Collect `prom-client` default process metrics. Default: `true`. */
139
139
  collectDefaultMetrics?: boolean;
140
+ /**
141
+ * A bearer token the scrape endpoint requires. When set, a request must carry
142
+ * `Authorization: Bearer <token>` matching this value (the scheme is matched
143
+ * case-insensitively; the token is compared in constant time) or it is refused
144
+ * with `401`. When unset (the default) the endpoint is open, so a deployment that
145
+ * exposes `/metrics` beyond a trusted network must either set this or protect the
146
+ * route at its edge — the exposition otherwise publishes the route inventory and
147
+ * `collectDefaultMetrics` process internals to any caller. Configuring this empty
148
+ * or whitespace-only is rejected at boot rather than treated as unset, so a
149
+ * mistyped secret fails loud instead of silently leaving the endpoint open.
150
+ */
151
+ authToken?: string;
140
152
  }
141
153
  /**
142
154
  * Consumer-facing options for `BymaxCoreModule.forRoot` / `forRootAsync`. Every
@@ -179,12 +191,13 @@ interface ResolvedTelemetryOptions {
179
191
  enabled: boolean;
180
192
  exposeTraceId: boolean;
181
193
  }
182
- /** Fully-resolved metrics options. */
194
+ /** Fully-resolved metrics options. `authToken` stays absent when unset. */
183
195
  interface ResolvedMetricsOptions {
184
196
  enabled: boolean;
185
197
  path: string;
186
198
  collectDefaultMetrics: boolean;
187
199
  defaultLabels: Record<string, string>;
200
+ authToken?: string;
188
201
  }
189
202
  /** Fully-resolved OpenAPI options. */
190
203
  interface ResolvedOpenApiOptions {
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
- import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, HttpStatus, NotFoundException } from '@nestjs/common';
1
+ import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, Req, HttpStatus, UnauthorizedException, NotFoundException } from '@nestjs/common';
2
2
  import { HttpAdapterHost, DiscoveryService, Reflector, BaseExceptionFilter, APP_FILTER, APP_INTERCEPTOR, DiscoveryModule } from '@nestjs/core';
3
3
  import { tap, catchError, throwError } from 'rxjs';
4
+ import { createHash, timingSafeEqual } from 'crypto';
4
5
 
5
6
  var __defProp = Object.defineProperty;
6
7
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -60,12 +61,25 @@ function resolveHealth(raw) {
60
61
  autoDiscover: raw?.autoDiscover ?? false
61
62
  };
62
63
  }
64
+ function resolveMetricsAuthToken(raw) {
65
+ if (raw === void 0) {
66
+ return void 0;
67
+ }
68
+ if (raw.trim() === "") {
69
+ throw new Error(
70
+ '[BymaxCoreModule] "metrics.authToken" was configured empty or whitespace-only. Leave it unset to expose an open /metrics endpoint (protected at the edge), or set a non-empty bearer token to require credentialed scrapes.'
71
+ );
72
+ }
73
+ return raw;
74
+ }
63
75
  function resolveMetrics(raw) {
76
+ const authToken = resolveMetricsAuthToken(raw?.authToken);
64
77
  return {
65
78
  enabled: raw?.enabled ?? false,
66
79
  path: raw?.path ?? DEFAULT_METRICS_PATH,
67
80
  collectDefaultMetrics: raw?.collectDefaultMetrics ?? true,
68
- defaultLabels: { ...raw?.defaultLabels ?? {} }
81
+ defaultLabels: { ...raw?.defaultLabels ?? {} },
82
+ ...authToken !== void 0 ? { authToken } : {}
69
83
  };
70
84
  }
71
85
  function cloneServers(raw) {
@@ -308,6 +322,23 @@ function extractExplicitDetails(carrier) {
308
322
  const details = carrier.details;
309
323
  return typeof details === "object" && details !== null ? details : void 0;
310
324
  }
325
+ var CLIENT_ERROR_MIN2 = 400;
326
+ var CLIENT_ERROR_MAX2 = 500;
327
+ function resolveExposedClientError(exception) {
328
+ if (typeof exception !== "object" || exception === null) {
329
+ return void 0;
330
+ }
331
+ const candidate = exception;
332
+ if (candidate.expose !== true) {
333
+ return void 0;
334
+ }
335
+ const status = Number.isInteger(candidate.status) ? candidate.status : candidate.statusCode;
336
+ if (typeof status !== "number" || !Number.isInteger(status) || status < CLIENT_ERROR_MIN2 || status >= CLIENT_ERROR_MAX2) {
337
+ return void 0;
338
+ }
339
+ const message = exception instanceof Error ? exception.message : "";
340
+ return { status, message: message === "" ? INTERNAL_ERROR_MESSAGE : message };
341
+ }
311
342
  function isValidationResponse(response) {
312
343
  return typeof response === "object" && response !== null && "message" in response && Array.isArray(response.message);
313
344
  }
@@ -422,6 +453,15 @@ var BymaxExceptionFilter = class {
422
453
  if (exception instanceof HttpException) {
423
454
  return this.mapHttpException(exception, context);
424
455
  }
456
+ const exposed = resolveExposedClientError(exception);
457
+ if (exposed !== void 0) {
458
+ return this.toEnvelope(
459
+ exposed.status,
460
+ codeForStatus(exposed.status),
461
+ exposed.message,
462
+ context
463
+ );
464
+ }
425
465
  try {
426
466
  this.onUnexpectedError(exception, context);
427
467
  } catch {
@@ -1048,6 +1088,18 @@ function assertControllerMatchesOptions2(options, registeredPath) {
1048
1088
  );
1049
1089
  }
1050
1090
  }
1091
+ function bearerMatches(authorization, expected) {
1092
+ if (typeof authorization !== "string") {
1093
+ return false;
1094
+ }
1095
+ const presented = authorization.replace(/^bearer[ \t]+/i, "");
1096
+ if (presented === authorization) {
1097
+ return false;
1098
+ }
1099
+ const presentedDigest = createHash("sha256").update(presented).digest();
1100
+ const expectedDigest = createHash("sha256").update(expected).digest();
1101
+ return timingSafeEqual(presentedDigest, expectedDigest);
1102
+ }
1051
1103
  function createMetricsController(registeredPath) {
1052
1104
  let MetricsController = class {
1053
1105
  /**
@@ -1060,8 +1112,12 @@ function createMetricsController(registeredPath) {
1060
1112
  this.options = options;
1061
1113
  this.adapterHost = adapterHost;
1062
1114
  }
1063
- async scrape(response) {
1115
+ async scrape(response, request) {
1064
1116
  assertControllerMatchesOptions2(this.options, registeredPath);
1117
+ const { authToken } = this.options.metrics;
1118
+ if (authToken !== void 0 && !bearerMatches(request.headers?.["authorization"], authToken)) {
1119
+ throw new UnauthorizedException();
1120
+ }
1065
1121
  const body = await this.registry.metrics();
1066
1122
  this.adapterHost.httpAdapter.setHeader(response, "Content-Type", this.registry.contentType);
1067
1123
  this.adapterHost.httpAdapter.reply(response, body, HttpStatus.OK);
@@ -1069,7 +1125,8 @@ function createMetricsController(registeredPath) {
1069
1125
  };
1070
1126
  __decorateClass([
1071
1127
  Get(),
1072
- __decorateParam(0, Res())
1128
+ __decorateParam(0, Res()),
1129
+ __decorateParam(1, Req())
1073
1130
  ], MetricsController.prototype, "scrape", 1);
1074
1131
  MetricsController = __decorateClass([
1075
1132
  Controller(registeredPath),
@@ -14,7 +14,10 @@ function coercePositiveInt(value, fallback) {
14
14
  if (!Number.isFinite(coerced) || coerced < MINIMUM) {
15
15
  return fallback;
16
16
  }
17
- return Math.floor(coerced);
17
+ return Math.min(Math.floor(coerced), Number.MAX_SAFE_INTEGER);
18
+ }
19
+ function clampPageToLimit(page, limit) {
20
+ return Math.min(page, Math.floor(Number.MAX_SAFE_INTEGER / limit) + 1);
18
21
  }
19
22
  function clampLimit(rawLimit, options) {
20
23
  const defaultLimit = coercePositiveInt(options?.defaultLimit, DEFAULT_LIMIT);
@@ -24,9 +27,10 @@ function clampLimit(rawLimit, options) {
24
27
 
25
28
  // src/pagination/offset.ts
26
29
  function normalizePageQuery(raw, options) {
30
+ const limit = clampLimit(raw.limit, options);
27
31
  return {
28
- page: coercePositiveInt(raw.page, MINIMUM),
29
- limit: clampLimit(raw.limit, options)
32
+ page: clampPageToLimit(coercePositiveInt(raw.page, MINIMUM), limit),
33
+ limit
30
34
  };
31
35
  }
32
36
  function buildPageResult(items, totalItems, query) {
@@ -44,8 +44,11 @@ interface PageResult<T> {
44
44
  * Clamp raw request input into a safe {@link PageQuery}.
45
45
  *
46
46
  * `page` floors to `1`; `limit` floors to `1` and caps at `maxLimit`. Absent,
47
- * non-numeric, negative, or zero fields fall back to defaults. Options are
48
- * per-call and never retained between calls.
47
+ * non-numeric, negative, or zero fields fall back to defaults. The limit is resolved
48
+ * first so `page` can be capped relative to it: a page is bounded not only to a safe
49
+ * integer but to one whose offset `(page - 1) * limit` also stays a safe integer, so a
50
+ * hostile `page` cannot lose precision before the repository computes its offset.
51
+ * Options are per-call and never retained between calls.
49
52
  *
50
53
  * @param raw - The untrusted page and limit values from the request.
51
54
  * @param options - Per-call `defaultLimit` (default `20`) and `maxLimit`
@@ -44,8 +44,11 @@ interface PageResult<T> {
44
44
  * Clamp raw request input into a safe {@link PageQuery}.
45
45
  *
46
46
  * `page` floors to `1`; `limit` floors to `1` and caps at `maxLimit`. Absent,
47
- * non-numeric, negative, or zero fields fall back to defaults. Options are
48
- * per-call and never retained between calls.
47
+ * non-numeric, negative, or zero fields fall back to defaults. The limit is resolved
48
+ * first so `page` can be capped relative to it: a page is bounded not only to a safe
49
+ * integer but to one whose offset `(page - 1) * limit` also stays a safe integer, so a
50
+ * hostile `page` cannot lose precision before the repository computes its offset.
51
+ * Options are per-call and never retained between calls.
49
52
  *
50
53
  * @param raw - The untrusted page and limit values from the request.
51
54
  * @param options - Per-call `defaultLimit` (default `20`) and `maxLimit`
@@ -12,7 +12,10 @@ function coercePositiveInt(value, fallback) {
12
12
  if (!Number.isFinite(coerced) || coerced < MINIMUM) {
13
13
  return fallback;
14
14
  }
15
- return Math.floor(coerced);
15
+ return Math.min(Math.floor(coerced), Number.MAX_SAFE_INTEGER);
16
+ }
17
+ function clampPageToLimit(page, limit) {
18
+ return Math.min(page, Math.floor(Number.MAX_SAFE_INTEGER / limit) + 1);
16
19
  }
17
20
  function clampLimit(rawLimit, options) {
18
21
  const defaultLimit = coercePositiveInt(options?.defaultLimit, DEFAULT_LIMIT);
@@ -22,9 +25,10 @@ function clampLimit(rawLimit, options) {
22
25
 
23
26
  // src/pagination/offset.ts
24
27
  function normalizePageQuery(raw, options) {
28
+ const limit = clampLimit(raw.limit, options);
25
29
  return {
26
- page: coercePositiveInt(raw.page, MINIMUM),
27
- limit: clampLimit(raw.limit, options)
30
+ page: clampPageToLimit(coercePositiveInt(raw.page, MINIMUM), limit),
31
+ limit
28
32
  };
29
33
  }
30
34
  function buildPageResult(items, totalItems, query) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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",