@bymax-one/nest-core 1.0.0 → 1.0.1

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,41 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.0.1] - 2026-08-04
15
+
16
+ **Behaviour change on the readiness endpoint.** A failing indicator's message no
17
+ longer appears in the HTTP response by default; it goes to the logger instead.
18
+
19
+ ### Security
20
+
21
+ - **The readiness response no longer publishes an indicator's failure message.**
22
+ `GET /health/ready` returned `details.error` carrying the rejecting indicator's
23
+ `Error#message`. That endpoint is typically unauthenticated and reachable by
24
+ whatever probes it — and an indicator rarely authors its own failure text: it
25
+ writes `await this.redis.ping()` and lets the driver's error propagate. Driver
26
+ errors carry hosts, ports and, for a connection string, credentials. An
27
+ indicator failing with `connection refused: postgres://user:PASSWORD@db:5432`
28
+ served that string to anyone who could reach the probe.
29
+
30
+ A failing check is now `{ name, status: 'down' }` and nothing more. The message
31
+ is written to Nest's `Logger` instead, so the diagnostic survives in a channel
32
+ that already has access control rather than being lost.
33
+
34
+ `health.exposeIndicatorErrors` (default `false`) puts it back in the response
35
+ for local debugging — the same shape, and the same warning, as
36
+ `envelope.exposeInternals`. The library made opposite choices about the same
37
+ risk in two places; they now agree.
38
+
39
+ `timedOutAfterMs` is unaffected: that number is one this library chose, not text
40
+ an indicator produced.
41
+
42
+ ### Changed
43
+
44
+ - **The Health and Security Model sections describe the split**, and the
45
+ configuration table documents the new option. The previous text said an
46
+ indicator "cannot leak more than it already chose to put in a message", which
47
+ assigned a choice the indicator's author usually never makes.
48
+
14
49
  ## [1.0.0] - 2026-08-03
15
50
 
16
51
  First published release. Everything below ships in it.
@@ -73,5 +108,6 @@ have regressed from. They are kept because the reasoning is worth having.
73
108
  cleanly and silently. Corrected before the first publish, so no released version
74
109
  ever carried the permissive range. No runtime behaviour changed.
75
110
 
111
+ [1.0.1]: https://github.com/bymaxone/nest-core/compare/v1.0.0...v1.0.1
76
112
  [1.0.0]: https://github.com/bymaxone/nest-core/releases/tag/v1.0.0
77
113
  [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.0.0...HEAD
package/README.md CHANGED
@@ -199,11 +199,12 @@ falls back to the documented default. Pass only what you want to change.
199
199
 
200
200
  ### `health`
201
201
 
202
- | Option | Type | Default | Description |
203
- | -------------------- | --------- | ---------- | ------------------------------------------------------ |
204
- | `enabled` | `boolean` | `true` | Registers the health controller. |
205
- | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
206
- | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
202
+ | Option | Type | Default | Description |
203
+ | ----------------------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
204
+ | `enabled` | `boolean` | `true` | Registers the health controller. |
205
+ | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
206
+ | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
207
+ | `exposeIndicatorErrors` | `boolean` | `false` | Includes the failing indicator's message in the response under `details.error`. Never enable in production — see below. |
207
208
 
208
209
  On `forRoot`, `enabled` and `path` are applied at module-definition time: a
209
210
  disabled feature registers no controller, and a custom `path` mounts the routes.
@@ -391,6 +392,12 @@ Liveness always replies `200` with an empty checks array; readiness runs
391
392
  every registered indicator concurrently and replies `200` only when every
392
393
  indicator reports `up`, `503` otherwise, naming every check either way.
393
394
 
395
+ A failing indicator is named but not quoted: the response says which check is
396
+ down, and the reason goes to the logger. See
397
+ [the security model](#-security-model) for why, and
398
+ `health.exposeIndicatorErrors` if you want the message in the response while
399
+ debugging locally.
400
+
394
401
  ```json
395
402
  { "status": "ok", "checks": [{ "name": "redis", "status": "up" }] }
396
403
  ```
@@ -558,12 +565,28 @@ original message and stack are captured for your logger, not for the response.
558
565
  `envelope.exposeInternals` puts them in the body and exists for local debugging — its own
559
566
  documentation says never to enable it in production, and it defaults to `false`.
560
567
 
561
- ### Health output is bounded by construction
568
+ ### The readiness response names the failure, it does not describe it
569
+
570
+ A failing indicator produces `{ name, status: 'down' }` and nothing else. The reason goes
571
+ to the logger.
572
+
573
+ That split is deliberate. Readiness is usually unauthenticated and reachable by whatever
574
+ probes it, and an indicator rarely authors its own failure text — it writes
575
+ `await this.redis.ping()` and lets the driver's error propagate. Driver errors carry hosts,
576
+ ports, and in the case of a connection string, credentials. Putting that text in the
577
+ response publishes it to everyone who can reach the endpoint; putting it in the log keeps
578
+ it where access is already controlled, without losing the diagnostic.
579
+
580
+ `health.exposeIndicatorErrors` puts the message back in the response for local debugging.
581
+ It defaults to `false`, and its documentation says the same thing `envelope.exposeInternals`
582
+ does: never enable it in production. The two options are the same decision, made the same
583
+ way, about the same risk.
562
584
 
563
- An indicator that rejects is folded into a `down` entry from its top-level `Error#message`
564
- only — never the raw error, its stack, or a nested cause — and the message is truncated. An
565
- indicator cannot leak more than it already chose to put in a message, and a slow one is
566
- converted to `down` by the aggregator rather than hanging the probe.
585
+ What reaches the log is bounded the same way it always was: the top-level `Error#message`
586
+ only — never the raw error, its stack, or a nested cause — truncated at 300 characters.
587
+ A slow indicator is converted to `down` by the aggregator rather than hanging the probe,
588
+ and its `timedOutAfterMs` stays in the response either way, because that number is one this
589
+ library chose rather than text an indicator produced.
567
590
 
568
591
  ### Cursors are opaque, not secret
569
592
 
@@ -580,16 +603,16 @@ guard you would apply to any internal endpoint, or keep it off the public listen
580
603
 
581
604
  ## 🛡️ Security Table
582
605
 
583
- | Layer | Implementation |
584
- | ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
585
- | Error responses | One shape for everything; unknown errors become a generic 500 |
586
- | Internals | Message and stack captured for logging, in the body only under `exposeInternals` (default `false`) |
587
- | Health output | Top-level `Error#message` only, truncated; no raw error, stack or cause |
588
- | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
589
- | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
590
- | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
591
- | Metrics | Opt-in; `prom-client` never imported while it is off |
592
- | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
606
+ | Layer | Implementation |
607
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
608
+ | Error responses | One shape for everything; unknown errors become a generic 500 |
609
+ | Internals | Message and stack captured for logging, in the body only under `exposeInternals` (default `false`) |
610
+ | Health output | The response names which indicator is down and nothing more; the reason goes to the logger. `exposeIndicatorErrors` (default `false`) puts it back in the response for debugging |
611
+ | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
612
+ | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
613
+ | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
614
+ | Metrics | Opt-in; `prom-client` never imported while it is off |
615
+ | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
593
616
 
594
617
  > [!IMPORTANT]
595
618
  > **`exposeInternals` is a debugging switch, not a verbosity setting.** With it on,
package/dist/index.cjs CHANGED
@@ -44,6 +44,7 @@ function resolveHealth(raw) {
44
44
  return {
45
45
  enabled: raw?.enabled ?? true,
46
46
  path: raw?.path ?? DEFAULT_HEALTH_PATH,
47
+ exposeIndicatorErrors: raw?.exposeIndicatorErrors ?? false,
47
48
  indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS
48
49
  };
49
50
  }
@@ -529,18 +530,18 @@ function summarizeRejection(reason) {
529
530
  }
530
531
  return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - TRUNCATION_ELLIPSIS.length)}${TRUNCATION_ELLIPSIS}`;
531
532
  }
532
- async function runIndicator(indicator, timeoutMs) {
533
+ async function runIndicator(indicator, timeoutMs, exposeErrors, logger) {
533
534
  let timer;
534
535
  const timedOut = new Promise((resolve) => {
535
536
  timer = setTimeout(() => {
536
537
  resolve({ name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } });
537
538
  }, timeoutMs);
538
539
  });
539
- const checked = Promise.resolve().then(() => indicator.check()).then((result) => ({ ...result, name: indicator.name })).catch((reason) => ({
540
- name: indicator.name,
541
- status: "down",
542
- details: { error: summarizeRejection(reason) }
543
- }));
540
+ const checked = Promise.resolve().then(() => indicator.check()).then((result) => ({ ...result, name: indicator.name })).catch((reason) => {
541
+ const message = summarizeRejection(reason);
542
+ logger.warn(`Health indicator "${indicator.name}" reported down: ${message}`);
543
+ return exposeErrors ? { name: indicator.name, status: "down", details: { error: message } } : { name: indicator.name, status: "down" };
544
+ });
544
545
  try {
545
546
  return await Promise.race([checked, timedOut]);
546
547
  } finally {
@@ -559,6 +560,12 @@ var HealthService = class {
559
560
  constructor(indicators = [], options) {
560
561
  this.indicators = indicators;
561
562
  this.options = options;
563
+ /**
564
+ * Nest's own logger, scoped to this class. The failure reason of a `down`
565
+ * indicator is written here rather than into the HTTP response, so the
566
+ * diagnostic survives without being served to whoever can reach the probe.
567
+ */
568
+ this.logger = new common.Logger(HealthService.name);
562
569
  }
563
570
  /**
564
571
  * Liveness check: the process is up and able to respond. Runs no
@@ -578,8 +585,11 @@ var HealthService = class {
578
585
  */
579
586
  async checkReadiness() {
580
587
  const timeoutMs = this.options.health.indicatorTimeoutMs;
588
+ const exposeErrors = this.options.health.exposeIndicatorErrors;
581
589
  const checks = await Promise.all(
582
- this.indicators.map((indicator) => runIndicator(indicator, timeoutMs))
590
+ this.indicators.map(
591
+ (indicator) => runIndicator(indicator, timeoutMs, exposeErrors, this.logger)
592
+ )
583
593
  );
584
594
  const status = checks.every((check) => check.status === "up") ? "ok" : "error";
585
595
  return { status, checks };
package/dist/index.d.cts CHANGED
@@ -35,6 +35,18 @@ interface HealthOptions {
35
35
  path?: string;
36
36
  /** Per-indicator timeout before a check is reported as down. Default: `5000`. */
37
37
  indicatorTimeoutMs?: number;
38
+ /**
39
+ * Include the failing indicator's message in the readiness response under
40
+ * `details.error`. Never enable in production. Default: `false`.
41
+ *
42
+ * Readiness is typically unauthenticated and reachable by whatever probes it,
43
+ * and an indicator usually does not author its own failure message — it lets a
44
+ * driver's error propagate, and driver errors carry hosts, ports and sometimes
45
+ * credentials. With this off, the response names which indicator is down and
46
+ * nothing else; the message goes to the logger, where access is already
47
+ * controlled.
48
+ */
49
+ exposeIndicatorErrors?: boolean;
38
50
  }
39
51
  /** Prometheus metrics endpoint configuration. */
40
52
  interface MetricsOptions {
@@ -76,6 +88,7 @@ interface ResolvedHealthOptions {
76
88
  enabled: boolean;
77
89
  path: string;
78
90
  indicatorTimeoutMs: number;
91
+ exposeIndicatorErrors: boolean;
79
92
  }
80
93
  /** Fully-resolved metrics options. */
81
94
  interface ResolvedMetricsOptions {
package/dist/index.d.ts CHANGED
@@ -35,6 +35,18 @@ interface HealthOptions {
35
35
  path?: string;
36
36
  /** Per-indicator timeout before a check is reported as down. Default: `5000`. */
37
37
  indicatorTimeoutMs?: number;
38
+ /**
39
+ * Include the failing indicator's message in the readiness response under
40
+ * `details.error`. Never enable in production. Default: `false`.
41
+ *
42
+ * Readiness is typically unauthenticated and reachable by whatever probes it,
43
+ * and an indicator usually does not author its own failure message — it lets a
44
+ * driver's error propagate, and driver errors carry hosts, ports and sometimes
45
+ * credentials. With this off, the response names which indicator is down and
46
+ * nothing else; the message goes to the logger, where access is already
47
+ * controlled.
48
+ */
49
+ exposeIndicatorErrors?: boolean;
38
50
  }
39
51
  /** Prometheus metrics endpoint configuration. */
40
52
  interface MetricsOptions {
@@ -76,6 +88,7 @@ interface ResolvedHealthOptions {
76
88
  enabled: boolean;
77
89
  path: string;
78
90
  indicatorTimeoutMs: number;
91
+ exposeIndicatorErrors: boolean;
79
92
  }
80
93
  /** Fully-resolved metrics options. */
81
94
  interface ResolvedMetricsOptions {
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Get, Res, Controller, HttpStatus } from '@nestjs/common';
1
+ import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, HttpStatus } from '@nestjs/common';
2
2
  import { HttpAdapterHost, BaseExceptionFilter, APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
3
3
  import { tap, catchError, throwError } from 'rxjs';
4
4
 
@@ -42,6 +42,7 @@ function resolveHealth(raw) {
42
42
  return {
43
43
  enabled: raw?.enabled ?? true,
44
44
  path: raw?.path ?? DEFAULT_HEALTH_PATH,
45
+ exposeIndicatorErrors: raw?.exposeIndicatorErrors ?? false,
45
46
  indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS
46
47
  };
47
48
  }
@@ -527,18 +528,18 @@ function summarizeRejection(reason) {
527
528
  }
528
529
  return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - TRUNCATION_ELLIPSIS.length)}${TRUNCATION_ELLIPSIS}`;
529
530
  }
530
- async function runIndicator(indicator, timeoutMs) {
531
+ async function runIndicator(indicator, timeoutMs, exposeErrors, logger) {
531
532
  let timer;
532
533
  const timedOut = new Promise((resolve) => {
533
534
  timer = setTimeout(() => {
534
535
  resolve({ name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } });
535
536
  }, timeoutMs);
536
537
  });
537
- const checked = Promise.resolve().then(() => indicator.check()).then((result) => ({ ...result, name: indicator.name })).catch((reason) => ({
538
- name: indicator.name,
539
- status: "down",
540
- details: { error: summarizeRejection(reason) }
541
- }));
538
+ const checked = Promise.resolve().then(() => indicator.check()).then((result) => ({ ...result, name: indicator.name })).catch((reason) => {
539
+ const message = summarizeRejection(reason);
540
+ logger.warn(`Health indicator "${indicator.name}" reported down: ${message}`);
541
+ return exposeErrors ? { name: indicator.name, status: "down", details: { error: message } } : { name: indicator.name, status: "down" };
542
+ });
542
543
  try {
543
544
  return await Promise.race([checked, timedOut]);
544
545
  } finally {
@@ -557,6 +558,12 @@ var HealthService = class {
557
558
  constructor(indicators = [], options) {
558
559
  this.indicators = indicators;
559
560
  this.options = options;
561
+ /**
562
+ * Nest's own logger, scoped to this class. The failure reason of a `down`
563
+ * indicator is written here rather than into the HTTP response, so the
564
+ * diagnostic survives without being served to whoever can reach the probe.
565
+ */
566
+ this.logger = new Logger(HealthService.name);
560
567
  }
561
568
  /**
562
569
  * Liveness check: the process is up and able to respond. Runs no
@@ -576,8 +583,11 @@ var HealthService = class {
576
583
  */
577
584
  async checkReadiness() {
578
585
  const timeoutMs = this.options.health.indicatorTimeoutMs;
586
+ const exposeErrors = this.options.health.exposeIndicatorErrors;
579
587
  const checks = await Promise.all(
580
- this.indicators.map((indicator) => runIndicator(indicator, timeoutMs))
588
+ this.indicators.map(
589
+ (indicator) => runIndicator(indicator, timeoutMs, exposeErrors, this.logger)
590
+ )
581
591
  );
582
592
  const status = checks.every((check) => check.status === "up") ? "ok" : "error";
583
593
  return { status, checks };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-core",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Zero-dependency NestJS 11 application foundation kit: error-envelope exception filter, request-timing interceptor, pagination helpers, health endpoints, and an optional Prometheus metrics endpoint.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -55,6 +55,29 @@
55
55
  },
56
56
  "./package.json": "./package.json"
57
57
  },
58
+ "scripts": {
59
+ "build": "pnpm clean && tsup",
60
+ "check:exports": "attw --pack . --profile strict",
61
+ "check:published": "node scripts/check-published-surface.mjs",
62
+ "check:runtime": "node scripts/check-consumer-runtime.mjs",
63
+ "clean": "node -e \"const fs=require('node:fs');for(const d of ['dist','coverage'])fs.rmSync(d,{recursive:true,force:true})\"",
64
+ "dogfood": "node scripts/dogfood-smoke-test.mjs",
65
+ "lint": "eslint --no-error-on-unmatched-pattern src scripts test",
66
+ "lint:fix": "eslint --no-error-on-unmatched-pattern src scripts test --fix",
67
+ "mutation": "stryker run",
68
+ "mutation:dry-run": "stryker run --dryRunOnly",
69
+ "mutation:incremental": "stryker run --incremental",
70
+ "prepare": "husky",
71
+ "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:published",
72
+ "release": "npm publish --provenance --access public",
73
+ "size": "node scripts/check-size.mjs",
74
+ "test": "jest",
75
+ "test:cov": "jest --coverage",
76
+ "test:cov:all": "jest --config jest.coverage.config.ts --coverage",
77
+ "test:e2e": "jest --config jest.e2e.config.ts",
78
+ "test:watch": "jest --watch",
79
+ "typecheck": "tsc --noEmit"
80
+ },
58
81
  "lint-staged": {
59
82
  "*.{ts,tsx,js,mjs,cjs}": [
60
83
  "eslint --fix",
@@ -126,6 +149,7 @@
126
149
  "metrics",
127
150
  "typescript"
128
151
  ],
152
+ "packageManager": "pnpm@10.8.1",
129
153
  "engines": {
130
154
  "node": ">=24.0.0"
131
155
  },
@@ -134,6 +158,16 @@
134
158
  "provenance": true,
135
159
  "registry": "https://registry.npmjs.org/"
136
160
  },
161
+ "pnpm": {
162
+ "overrides": {
163
+ "brace-expansion@1": "^1.1.18",
164
+ "brace-expansion@2": "^2.1.4",
165
+ "brace-expansion@5": "^5.0.9",
166
+ "esbuild": "^0.28.1",
167
+ "fast-uri": "^3.1.5",
168
+ "qs": "^6.15.2"
169
+ }
170
+ },
137
171
  "module": "./dist/index.mjs",
138
172
  "typesVersions": {
139
173
  "*": {
@@ -144,26 +178,5 @@
144
178
  "./dist/health/index.d.cts"
145
179
  ]
146
180
  }
147
- },
148
- "scripts": {
149
- "build": "pnpm clean && tsup",
150
- "check:exports": "attw --pack . --profile strict",
151
- "check:published": "node scripts/check-published-surface.mjs",
152
- "check:runtime": "node scripts/check-consumer-runtime.mjs",
153
- "clean": "node -e \"const fs=require('node:fs');for(const d of ['dist','coverage'])fs.rmSync(d,{recursive:true,force:true})\"",
154
- "dogfood": "node scripts/dogfood-smoke-test.mjs",
155
- "lint": "eslint --no-error-on-unmatched-pattern src scripts test",
156
- "lint:fix": "eslint --no-error-on-unmatched-pattern src scripts test --fix",
157
- "mutation": "stryker run",
158
- "mutation:dry-run": "stryker run --dryRunOnly",
159
- "mutation:incremental": "stryker run --incremental",
160
- "release": "npm publish --provenance --access public",
161
- "size": "node scripts/check-size.mjs",
162
- "test": "jest",
163
- "test:cov": "jest --coverage",
164
- "test:cov:all": "jest --config jest.coverage.config.ts --coverage",
165
- "test:e2e": "jest --config jest.e2e.config.ts",
166
- "test:watch": "jest --watch",
167
- "typecheck": "tsc --noEmit"
168
181
  }
169
- }
182
+ }