@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 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.2.1...HEAD
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: 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.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",