@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 +43 -1
- package/README.md +22 -6
- package/dist/index.cjs +60 -3
- package/dist/index.d.cts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.mjs +61 -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,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.
|
|
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
|
|
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.2.
|
|
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",
|