@bymax-one/nest-core 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -1
- package/README.md +22 -6
- package/dist/index.cjs +34 -3
- package/dist/index.d.cts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.mjs +35 -4
- package/dist/pagination/index.cjs +7 -3
- package/dist/pagination/index.d.cts +5 -2
- package/dist/pagination/index.d.ts +5 -2
- package/dist/pagination/index.mjs +7 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,44 @@ heading here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.3.0] - 2026-08-11
|
|
15
|
+
|
|
16
|
+
Coordinated ecosystem release aligning every `@bymax-one/*` package after the ioredis 6 /
|
|
17
|
+
bullmq 6 migration. **No source, runtime, or public-API change in this package** — the
|
|
18
|
+
published `dist/` is byte-identical to `1.2.2`; the changes below are development
|
|
19
|
+
and CI tooling only.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Bumped the `dev-dependencies` group with 3 updates. None of these reaches the published bundle.
|
|
24
|
+
- Bumped the pinned `pnpm/action-setup` CI action from 6.0.9 to 6.0.10.
|
|
25
|
+
- Bumped the pinned `github/codeql-action/upload-sarif` CI action from 4.37.4 to 4.37.6 in the
|
|
26
|
+
codeql group.
|
|
27
|
+
|
|
28
|
+
## [1.2.2] - 2026-08-10
|
|
29
|
+
|
|
30
|
+
Remediation of a local audit's metrics-auth and pagination-bound findings (merged in #62). No
|
|
31
|
+
API changed.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Offset-safe page cap.** `normalizePageQuery` resolves the limit first and caps `page` to
|
|
36
|
+
`floor(MAX_SAFE_INTEGER / limit) + 1`, so a hostile `page` can no longer drive
|
|
37
|
+
`(page - 1) * limit` past the safe-integer range and lose precision before a repository computes
|
|
38
|
+
its offset.
|
|
39
|
+
- **The `/metrics` bearer scheme is matched case-insensitively.** An HTTP auth scheme is
|
|
40
|
+
case-insensitive (RFC 7235) and may be separated from the credential by more than one space or a
|
|
41
|
+
tab; the check now accepts `bearer`/`BEARER`/mixed case and that whitespace, and anchors the
|
|
42
|
+
scheme to the start of the header to close a mid-string smuggling path.
|
|
43
|
+
- **A misconfigured scrape token fails closed.** A `metrics.authToken` configured empty or
|
|
44
|
+
whitespace-only is now rejected at boot instead of being silently treated as unset — which left
|
|
45
|
+
`/metrics` open. A real token is kept verbatim.
|
|
46
|
+
|
|
47
|
+
### Documentation
|
|
48
|
+
|
|
49
|
+
- `metrics.authToken` is documented in the README and the technical specification, including a
|
|
50
|
+
protected-scrape example.
|
|
51
|
+
|
|
14
52
|
## [1.2.1] - 2026-08-08
|
|
15
53
|
|
|
16
54
|
A patch: the envelope fix below changes a response status for a class of client errors, without
|
|
@@ -317,6 +355,8 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
317
355
|
[1.0.1]: https://github.com/bymaxone/nest-core/compare/v1.0.0...v1.0.1
|
|
318
356
|
[1.0.0]: https://github.com/bymaxone/nest-core/releases/tag/v1.0.0
|
|
319
357
|
[1.1.1]: https://github.com/bymaxone/nest-core/compare/v1.1.0...v1.1.1
|
|
358
|
+
[1.3.0]: https://github.com/bymaxone/nest-core/compare/v1.2.2...v1.3.0
|
|
359
|
+
[1.2.2]: https://github.com/bymaxone/nest-core/compare/v1.2.1...v1.2.2
|
|
320
360
|
[1.2.1]: https://github.com/bymaxone/nest-core/compare/v1.2.0...v1.2.1
|
|
321
361
|
[1.2.0]: https://github.com/bymaxone/nest-core/compare/v1.1.1...v1.2.0
|
|
322
|
-
[Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.
|
|
362
|
+
[Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.3.0...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) {
|
|
@@ -1076,6 +1090,18 @@ function assertControllerMatchesOptions2(options, registeredPath) {
|
|
|
1076
1090
|
);
|
|
1077
1091
|
}
|
|
1078
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
|
+
}
|
|
1079
1105
|
function createMetricsController(registeredPath) {
|
|
1080
1106
|
let MetricsController = class {
|
|
1081
1107
|
/**
|
|
@@ -1088,8 +1114,12 @@ function createMetricsController(registeredPath) {
|
|
|
1088
1114
|
this.options = options;
|
|
1089
1115
|
this.adapterHost = adapterHost;
|
|
1090
1116
|
}
|
|
1091
|
-
async scrape(response) {
|
|
1117
|
+
async scrape(response, request) {
|
|
1092
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
|
+
}
|
|
1093
1123
|
const body = await this.registry.metrics();
|
|
1094
1124
|
this.adapterHost.httpAdapter.setHeader(response, "Content-Type", this.registry.contentType);
|
|
1095
1125
|
this.adapterHost.httpAdapter.reply(response, body, common.HttpStatus.OK);
|
|
@@ -1097,7 +1127,8 @@ function createMetricsController(registeredPath) {
|
|
|
1097
1127
|
};
|
|
1098
1128
|
__decorateClass([
|
|
1099
1129
|
common.Get(),
|
|
1100
|
-
__decorateParam(0, common.Res())
|
|
1130
|
+
__decorateParam(0, common.Res()),
|
|
1131
|
+
__decorateParam(1, common.Req())
|
|
1101
1132
|
], MetricsController.prototype, "scrape", 1);
|
|
1102
1133
|
MetricsController = __decorateClass([
|
|
1103
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) {
|
|
@@ -1074,6 +1088,18 @@ function assertControllerMatchesOptions2(options, registeredPath) {
|
|
|
1074
1088
|
);
|
|
1075
1089
|
}
|
|
1076
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
|
+
}
|
|
1077
1103
|
function createMetricsController(registeredPath) {
|
|
1078
1104
|
let MetricsController = class {
|
|
1079
1105
|
/**
|
|
@@ -1086,8 +1112,12 @@ function createMetricsController(registeredPath) {
|
|
|
1086
1112
|
this.options = options;
|
|
1087
1113
|
this.adapterHost = adapterHost;
|
|
1088
1114
|
}
|
|
1089
|
-
async scrape(response) {
|
|
1115
|
+
async scrape(response, request) {
|
|
1090
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
|
+
}
|
|
1091
1121
|
const body = await this.registry.metrics();
|
|
1092
1122
|
this.adapterHost.httpAdapter.setHeader(response, "Content-Type", this.registry.contentType);
|
|
1093
1123
|
this.adapterHost.httpAdapter.reply(response, body, HttpStatus.OK);
|
|
@@ -1095,7 +1125,8 @@ function createMetricsController(registeredPath) {
|
|
|
1095
1125
|
};
|
|
1096
1126
|
__decorateClass([
|
|
1097
1127
|
Get(),
|
|
1098
|
-
__decorateParam(0, Res())
|
|
1128
|
+
__decorateParam(0, Res()),
|
|
1129
|
+
__decorateParam(1, Req())
|
|
1099
1130
|
], MetricsController.prototype, "scrape", 1);
|
|
1100
1131
|
MetricsController = __decorateClass([
|
|
1101
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
|
|
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.
|
|
48
|
-
*
|
|
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.
|
|
48
|
-
*
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.3.0",
|
|
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",
|