@bymax-one/nest-config 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,75 @@ as the GitHub Release body, so each released version needs a matching
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.1.2] - 2026-08-14
15
+
16
+ **Documentation only.** `dist/` is byte-identical to `1.1.1`, verified by hashing every file in
17
+ the published tarball against a local build. Inside the package only `README.md`, `CHANGELOG.md`
18
+ and the `version` field of `package.json` differ.
19
+
20
+ ### Documentation
21
+
22
+ - **The `1.1.1` apply-note understated the work for a consumer with a mutation gate.** It said
23
+ a derived backend has nothing to change, which is true for correctness and wrong as an
24
+ estimate. Under Stryker, a message string literal is a mutant: authoring messages for rules
25
+ that had none converts operator-facing prose into tested behavior, and a repo gating at
26
+ `break: 100` fails until an assertion pins each message. Reported by a consumer adopting
27
+ `1.1.1`, which added one assertion per message to keep its gate green.
28
+
29
+ The honest version of the note: **upgrading changes nothing** — a rule that carries no
30
+ message renders exactly as it did before, verified byte-for-byte on a real boot. **Writing
31
+ the messages the release makes worth writing is real work**: wherever mutation testing is a
32
+ gate, every message needs an assertion that covers it — one assertion on the rendered report
33
+ can cover several — and that is the work the release exists to enable.
34
+
35
+ Also worth knowing before writing them: a rule whose variable is _absent_ rendered
36
+ `missing required value` on `1.1.0`, not `invalid value`. For a conditionally-required
37
+ variable that is the more misleading of the two — nothing was forgotten, another variable
38
+ made it required — so the before/after is larger than the `invalid value` case suggests.
39
+
40
+ - The mutation badge read **99.59%**; the score measured as the `v1.1.1` release gate is
41
+ **99.61%**. The denominator grew with the new code — 14 more killed mutants in
42
+ `env-validator.ts`, the only file the release changed executably, and the same single
43
+ counted survivor — so the badge, the README claim and `docs/mutation_testing_results.md`
44
+ now state the measured number, with a dated re-run entry recording it.
45
+
46
+ ## [1.1.1] - 2026-08-14
47
+
48
+ ### Fixed
49
+
50
+ - **A `custom` issue is reported with the message its author wrote.** `.check`, `.refine`
51
+ and `.superRefine` all raise Zod's `custom` code, which carries no structural constraint
52
+ to translate, so every one of them rendered as the literal `invalid value` and the rule's
53
+ own explanation was discarded. Built-in codes were unaffected, which is what made it easy
54
+ to miss: the report looked correct until a rule stopped being trivial. A conditional
55
+ requirement, a cross-field rule or a security floor states itself only through that
56
+ message, and the aggregated report is the one artifact an operator gets for a failure that
57
+ stops the process.
58
+
59
+ The message is used whether the variable is absent or present-but-invalid — "required
60
+ when `X` is enabled" explains an absent variable better than `missing required value`
61
+ does — while `issue.code` still classifies it as `BYMAX_CONFIG_MISSING` or
62
+ `BYMAX_CONFIG_INVALID`, so machine consumers are unaffected. Whitespace runs collapse to
63
+ single spaces to keep the one-line-per-variable layout; the column layout and the
64
+ resolved `variable` name are unchanged.
65
+
66
+ A `custom` issue raised without a message of its own still reads `invalid value`. Zod
67
+ fills a message-less `custom` issue with its locale default before the validator sees it,
68
+ so "no authored message" is recognized by matching that default (`Invalid input`) rather
69
+ than by absence. Two consequences, both deliberate: the string is reserved, so a schema
70
+ that authors it verbatim reports `invalid value`; and under a configured non-English Zod
71
+ locale or a global custom error map the localized default does not match and is reported
72
+ as written.
73
+
74
+ Value-free stays a contract for the text this library generates. An authored message is
75
+ schema text and is printed as written, including a value interpolated into it. The
76
+ exception is now stated everywhere the guarantee is made: the `ConfigIssue`,
77
+ `BymaxConfigValidationError` and `onValidationError` contracts, `env-validator`'s own
78
+ contract, the README (including the security table) and spec 6.1.
79
+
80
+ **Apply to a derived backend:** nothing to change. A schema that already raises `custom`
81
+ issues with messages starts reporting them on the next boot.
82
+
14
83
  ## [1.1.0] - 2026-08-11
15
84
 
16
85
  Coordinated ecosystem release aligning every `@bymax-one/*` package after the ioredis 6 /
@@ -193,4 +262,6 @@ have regressed from. They are kept because the reasoning is worth having.
193
262
  [1.0.1]: https://github.com/bymaxone/nest-config/compare/v1.0.0...v1.0.1
194
263
  [1.0.3]: https://github.com/bymaxone/nest-config/compare/v1.0.2...v1.0.3
195
264
  [1.1.0]: https://github.com/bymaxone/nest-config/compare/v1.0.3...v1.1.0
196
- [Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.1.0...HEAD
265
+ [1.1.1]: https://github.com/bymaxone/nest-config/compare/v1.1.0...v1.1.1
266
+ [1.1.2]: https://github.com/bymaxone/nest-config/compare/v1.1.1...v1.1.2
267
+ [Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.1.2...HEAD
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  <a href="https://www.npmjs.com/package/@bymax-one/nest-config"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
15
15
  <a href="https://github.com/bymaxone/nest-config/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-config/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
16
16
  <a href="https://github.com/bymaxone/nest-config/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
17
- <a href="https://github.com/bymaxone/nest-config/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-99.59%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
17
+ <a href="https://github.com/bymaxone/nest-config/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-99.61%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
18
18
  <a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-config"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-config/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
19
19
  <a href="https://github.com/bymaxone/nest-config/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-config?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
20
20
  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
@@ -50,8 +50,9 @@ minimal.
50
50
  - **One round trip to fix the environment.** Every violation is collected and reported
51
51
  together, not the first one that fails.
52
52
  - **Errors are value-free by contract.** Configuration is where the secrets are, and the
53
- report is the artifact that gets logged and pasted into issues — so messages carry the
54
- expected constraint and the schema's own enum options, never the received value.
53
+ report is the artifact that gets logged and pasted into issues — so generated messages
54
+ carry the expected constraint and the schema's own enum options, never the received
55
+ value. Your own `custom` messages are the documented exception: they print as written.
55
56
  - **The schema is the type.** `get('database.url')` is `string` and `get('server.port')` is
56
57
  `number` because the schema says so; a typo in a path is a build error, not a runtime
57
58
  `undefined`.
@@ -66,8 +67,9 @@ minimal.
66
67
  application serves traffic; there is no lazy path that could discover a problem later
67
68
  - ✅ **Fail fast, fail complete** — every violation reported together in one aggregated
68
69
  error, so an operator fixes the environment in one round trip
69
- - ✅ **Never echo values** — messages carry variable names, paths and constraint
70
- descriptions only, so a secret cannot reach a log or a crash report through this library
70
+ - ✅ **Never echo values** — generated messages carry variable names, paths and constraint
71
+ descriptions only, so a secret cannot reach a log or a crash report through this library;
72
+ a `custom` message you write yourself is printed as written and is yours to keep value-free
71
73
  - ✅ **Zod v4 schemas** — the full type language, including coercion, refinements and
72
74
  defaults, with `defineEnv` shaping namespaces and leaves
73
75
 
@@ -202,8 +204,38 @@ BymaxConfigValidationError: environment validation failed (3 issues)
202
204
  Fix the variables above and restart the process.
203
205
  ```
204
206
 
205
- Raw values are never printed, not truncated, not masked, absent. This is a
206
- hard guarantee of the package, verified by tests.
207
+ In a message this package generates, raw values are never printed — not
208
+ truncated, not masked, absent. That is a hard guarantee, verified by tests. The
209
+ one exception is a message you write yourself, described next.
210
+
211
+ A rule you write yourself — a `.check`, `.refine` or `.superRefine` raising a
212
+ `custom` issue — has no structural constraint to describe, so its own message is
213
+ what the report prints:
214
+
215
+ ```
216
+ BymaxConfigValidationError: environment validation failed (2 issues)
217
+
218
+ STORAGE_ENDPOINT STORAGE_ENDPOINT is required when STORAGE_ENABLED is true.
219
+ LOG_PRETTY LOG_PRETTY must be false when NODE_ENV is production.
220
+
221
+ Fix the variables above and restart the process.
222
+ ```
223
+
224
+ The message is used whether the variable is absent or present-but-invalid — a
225
+ conditional rule explains an absent variable better than "missing required
226
+ value" does — while `issue.code` still classifies it as
227
+ `BYMAX_CONFIG_MISSING` or `BYMAX_CONFIG_INVALID`. Whitespace runs collapse to
228
+ single spaces so a message wrapped across source lines keeps the one-line
229
+ layout.
230
+
231
+ A `custom` issue raised without a message falls back to `invalid value`. Zod
232
+ fills a message-less `custom` issue with its own default text before the
233
+ validator sees it, so "no message" is recognized by matching that default
234
+ (`Invalid input`) rather than by its absence. Two consequences, both deliberate:
235
+ that exact string is reserved — a schema that authors it verbatim reports
236
+ `invalid value` — and under a configured non-English Zod locale (or a global
237
+ custom error map) the default no longer matches, so the localized default is
238
+ reported as written instead.
207
239
 
208
240
  ## 📖 API Reference
209
241
 
@@ -287,7 +319,7 @@ export interface ConfigIssue {
287
319
  readonly path: string // e.g. "database.url"
288
320
  readonly variable: string // e.g. "DATABASE_URL"
289
321
  readonly code: ConfigIssueCode
290
- readonly message: string // value-free constraint description
322
+ readonly message: string // constraint description, or your own `custom` message
291
323
  }
292
324
  ```
293
325
 
@@ -353,7 +385,7 @@ that needs to reference the schema shape directly.
353
385
  │ │
354
386
  ▼ ▼
355
387
  deep-frozen BymaxConfigValidationError
356
- BYMAX_CONFIG one aggregated, value-free report
388
+ BYMAX_CONFIG one aggregated boot-time report
357
389
  │ │
358
390
  ▼ ▼
359
391
  ConfigService the process does not start
@@ -380,15 +412,15 @@ accidentally pass because a variable happened to be set on the machine.
380
412
 
381
413
  ### Design Principles
382
414
 
383
- | Principle | Description |
384
- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
385
- | 💥 **Refuse to start** | A misconfigured process is stopped before it serves a request, rather than discovering the gap at the first call that needs the value |
386
- | 🤐 **Value-free by contract** | `errors.ts`, `env-validator.ts` and `report-formatter.ts` each state it for themselves: the message carries the expected constraint and the schema's own options, never the received value |
387
- | 📋 **Report everything at once** | Validation collects every failure before throwing, so fixing an environment is one cycle instead of one per variable |
388
- | 🧊 **Frozen after validation** | Nothing in the running process rewrites configuration under a component that already read it |
389
- | 🔤 **The schema is the type** | Dot paths are checked against the schema at compile time, so a typo is a build error rather than a runtime `undefined` |
390
- | ⚙️ **Configuration over convention** | Everything goes through `forRoot`/`forRootAsync`. No file discovery, no magic paths, no hidden precedence rules |
391
- | 🧩 **One class identity** | The module and `./testing` import the shared runtime by package specifier, so `ConfigService` is one injection token in ESM and CommonJS alike |
415
+ | Principle | Description |
416
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
417
+ | 💥 **Refuse to start** | A misconfigured process is stopped before it serves a request, rather than discovering the gap at the first call that needs the value |
418
+ | 🤐 **Value-free by contract** | `errors.ts`, `env-validator.ts` and `report-formatter.ts` each state it for themselves: a generated message carries the expected constraint and the schema's own options, never the received value |
419
+ | 📋 **Report everything at once** | Validation collects every failure before throwing, so fixing an environment is one cycle instead of one per variable |
420
+ | 🧊 **Frozen after validation** | Nothing in the running process rewrites configuration under a component that already read it |
421
+ | 🔤 **The schema is the type** | Dot paths are checked against the schema at compile time, so a typo is a build error rather than a runtime `undefined` |
422
+ | ⚙️ **Configuration over convention** | Everything goes through `forRoot`/`forRootAsync`. No file discovery, no magic paths, no hidden precedence rules |
423
+ | 🧩 **One class identity** | The module and `./testing` import the shared runtime by package specifier, so `ConfigService` is one injection token in ESM and CommonJS alike |
392
424
 
393
425
  ---
394
426
 
@@ -399,10 +431,11 @@ guaranteed to be printed, logged, and pasted into an issue. That shapes the whol
399
431
 
400
432
  ### Errors are value-free by contract
401
433
 
402
- Every message states the _expected_ constraint — the type, the allowed enum options taken
403
- from the schema, the bound — and never the received value. `env-validator` and
404
- `report-formatter` each say so in their own contracts, and property-based tests hold them
405
- to it.
434
+ Every message this library generates states the _expected_ constraint — the type, the
435
+ allowed enum options taken from the schema, the bound — and never the received value.
436
+ `env-validator` and `report-formatter` each say so in their own contracts, and
437
+ property-based tests hold them to it. The messages you write yourself are the exception,
438
+ covered below.
406
439
 
407
440
  ### The service does not serialize its configuration
408
441
 
@@ -434,27 +467,31 @@ not choose.
434
467
 
435
468
  ### Your own messages are yours
436
469
 
437
- Value-free applies to this library's reports. If a `.refine` or a custom message in your
438
- schema includes a value, that message is printed as written.
470
+ Value-free applies to this library's reports. A `custom` issue from your own `.check`,
471
+ `.refine` or `.superRefine` is printed as written — it is schema text, and it is the only
472
+ place a conditional or cross-field rule can state itself. That also means the message is
473
+ yours to keep value-free: if it interpolates the received value, that value reaches the
474
+ report.
439
475
 
440
476
  ---
441
477
 
442
478
  ## 🛡️ Security Table
443
479
 
444
- | Layer | Implementation |
445
- | -------------- | -------------------------------------------------------------------------------------------------------------------------- |
446
- | Error messages | Expected constraint only — type, schema-declared enum options, bounds; never the received value |
447
- | Failure mode | Refuse to start; the whole schema is validated before the context finishes building |
448
- | Aggregation | Every failure reported at once, so a fix cycle is one restart rather than one per variable |
449
- | Immutability | The validated result is frozen before it is provided |
450
- | Service state | The root lives in a private field; serializing the injected service yields namespace names only |
451
- | Sources | `process.env` only — no file, no network, no remote provider |
452
- | Type surface | Dot paths checked against the schema at compile time; a typo is a build error, not a runtime `undefined` |
453
- | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
480
+ | Layer | Implementation |
481
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
482
+ | Error messages | Expected constraint only — type, schema-declared enum options, bounds; never the received value. A `custom` message from your own schema is the exception: it prints as written |
483
+ | Failure mode | Refuse to start; the whole schema is validated before the context finishes building |
484
+ | Aggregation | Every failure reported at once, so a fix cycle is one restart rather than one per variable |
485
+ | Immutability | The validated result is frozen before it is provided |
486
+ | Service state | The root lives in a private field; serializing the injected service yields namespace names only |
487
+ | Sources | `process.env` only — no file, no network, no remote provider |
488
+ | Type surface | Dot paths checked against the schema at compile time; a typo is a build error, not a runtime `undefined` |
489
+ | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
454
490
 
455
491
  > [!IMPORTANT]
456
- > **Value-free applies to this library's own reports.** If your schema's `.refine`
457
- > or a custom message includes a value, that message is printed as written.
492
+ > **Value-free applies to this library's own reports.** A `custom` message from your
493
+ > schema's `.check`, `.refine` or `.superRefine` is printed as written — including a
494
+ > value, if you interpolate one into it.
458
495
 
459
496
  ---
460
497
 
@@ -477,14 +514,15 @@ Configuration decides whether a process may start at all, so the suite is held t
477
514
  beyond "the tests pass".
478
515
 
479
516
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
480
- - ✅ **99.59% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
517
+ - ✅ **99.61% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
481
518
  `break: 95`; ten provable equivalents carry their reason inline, and the one survivor left
482
519
  is an equivalent no directive can reach, so it stays counted rather than silenced
483
520
  ([report](./docs/mutation_testing_results.md))
484
521
  - ✅ **The value-free guarantee is tested, not assumed** — a sentinel secret is placed in a
485
522
  source and the assertion is that it appears nowhere in the rendered report or in the thrown
486
523
  error, because a guarantee about what is _absent_ is the one thing an ordinary assertion
487
- about output does not cover
524
+ about output does not cover; the schema under test writes no `custom` message, so what is
525
+ proven is the guarantee this package makes for its own generated text
488
526
  - ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
489
527
  system does, `check:runtime` loads every subpath from the packed tarball in ESM and
490
528
  CommonJS, and `check:published` compiles this README's snippets against `dist/`
@@ -87,7 +87,7 @@ var ConfigErrorCode = Object.freeze({
87
87
  });
88
88
  var BymaxConfigValidationError = class _BymaxConfigValidationError extends Error {
89
89
  /**
90
- * Build the aggregated error from a list of value-free issues.
90
+ * Build the aggregated error from the collected issues.
91
91
  *
92
92
  * @param issues - Every collected violation; copied and frozen so neither the
93
93
  * caller nor consumer code can mutate the reported list.
@@ -146,6 +146,7 @@ function resolveNamespacePrefixes(schema) {
146
146
  // src/env-validator.ts
147
147
  var UNKNOWN_KEY_MESSAGE = "unknown variable not declared in the schema";
148
148
  var MISSING_MESSAGE = "missing required value";
149
+ var ZOD_DEFAULT_CUSTOM_MESSAGE = "Invalid input";
149
150
  function collectPresentLeaves(bindings, source) {
150
151
  const present = [];
151
152
  for (const binding of bindings) {
@@ -189,13 +190,20 @@ function describeConstraint(issue) {
189
190
  return "invalid value";
190
191
  }
191
192
  }
193
+ function authoredMessage(issue) {
194
+ if (issue.code !== "custom") return void 0;
195
+ const collapsed = issue.message.replace(/\s+/g, " ").trim();
196
+ if (collapsed.length === 0 || collapsed === ZOD_DEFAULT_CUSTOM_MESSAGE) return void 0;
197
+ return collapsed;
198
+ }
192
199
  function toConfigIssue(binding, issue, source) {
193
200
  const isMissing = source.get(binding.variable) === void 0;
201
+ const authored = authoredMessage(issue);
194
202
  return {
195
203
  path: binding.path,
196
204
  variable: binding.variable,
197
205
  code: isMissing ? ConfigErrorCode.MISSING : ConfigErrorCode.INVALID,
198
- message: isMissing ? MISSING_MESSAGE : describeConstraint(issue)
206
+ message: authored ?? (isMissing ? MISSING_MESSAGE : describeConstraint(issue))
199
207
  };
200
208
  }
201
209
  function translateIssues(zodIssues, bindings, source) {
@@ -99,11 +99,12 @@ type Path<TConfig> = {
99
99
  type PathValue<TConfig, TPath extends Path<TConfig>> = TPath extends `${infer Namespace}.${infer Leaf}` ? Namespace extends keyof TConfig ? Leaf extends keyof TConfig[Namespace] ? TConfig[Namespace][Leaf] : never : never : never;
100
100
 
101
101
  /**
102
- * @fileoverview Value-free error model for the configuration validation
103
- * pipeline: the frozen issue-code catalog, the ConfigIssue shape, and the
102
+ * @fileoverview Error model for the configuration validation pipeline: the
103
+ * frozen issue-code catalog, the ConfigIssue shape, and the
104
104
  * BymaxConfigValidationError aggregate. The error carries every violation as a
105
105
  * structured, immutable issue list and renders a multi-line report that never
106
- * echoes a raw source value.
106
+ * echoes a raw source value of its own — see {@link ConfigIssue} for the one
107
+ * exception, a message written by the schema author.
107
108
  * @layer Error
108
109
  */
109
110
  /**
@@ -135,11 +136,17 @@ type ConfigValidationCode = typeof ConfigErrorCode.VALIDATION;
135
136
  /** Machine-readable code classifying a single {@link ConfigIssue}. */
136
137
  type ConfigIssueCode = typeof ConfigErrorCode.MISSING | typeof ConfigErrorCode.INVALID | typeof ConfigErrorCode.UNKNOWN_KEY;
137
138
  /**
138
- * A single configuration violation, described entirely without any raw value.
139
+ * A single configuration violation, described for the operator who must fix it.
139
140
  *
140
- * Every field is value-free by contract: `message` states the expected
141
- * constraint, never the received input, so an issue can be logged or serialized
142
- * without leaking a secret.
141
+ * Every description this package generates is value-free by contract: `message`
142
+ * states the expected constraint, never the received input, so an issue can be
143
+ * logged or serialized without leaking a secret.
144
+ *
145
+ * The one exception is the message a schema author attaches to a `custom` issue
146
+ * (`.check`, `.refine`, `.superRefine`), which is reported as written because it
147
+ * is the only statement of a rule that has no structural constraint to describe.
148
+ * A message that interpolates the received value therefore carries that value
149
+ * into the report; keeping an authored message value-free is the author's call.
143
150
  */
144
151
  interface ConfigIssue {
145
152
  /** Nested config path, e.g. `database.url`; the namespace alone (e.g. `database`) for an unknown-key issue. */
@@ -148,16 +155,17 @@ interface ConfigIssue {
148
155
  readonly variable: string;
149
156
  /** Stable machine-readable classification. */
150
157
  readonly code: ConfigIssueCode;
151
- /** Human-readable constraint description, value-free. */
158
+ /** Human-readable constraint description; value-free unless a schema author's own `custom` message says otherwise. */
152
159
  readonly message: string;
153
160
  }
154
161
  /**
155
- * Aggregated, value-free configuration validation error.
162
+ * Aggregated configuration validation error.
156
163
  *
157
164
  * Thrown once at bootstrap when the source fails the schema. It carries every
158
165
  * violation in an immutable, structured {@link ConfigIssue} list and a
159
166
  * human-readable message that lists the offending variables and their expected
160
- * constraints, never their received values.
167
+ * constraints, never their received values — except where a schema author's own
168
+ * `custom` message says otherwise, as described on {@link ConfigIssue}.
161
169
  *
162
170
  * @example
163
171
  * ```typescript
@@ -176,7 +184,7 @@ declare class BymaxConfigValidationError extends Error {
176
184
  /** The immutable, aggregated list of collected violations. */
177
185
  readonly issues: ReadonlyArray<ConfigIssue>;
178
186
  /**
179
- * Build the aggregated error from a list of value-free issues.
187
+ * Build the aggregated error from the collected issues.
180
188
  *
181
189
  * @param issues - Every collected violation; copied and frozen so neither the
182
190
  * caller nor consumer code can mutate the reported list.
@@ -187,7 +195,7 @@ declare class BymaxConfigValidationError extends Error {
187
195
  /**
188
196
  * @fileoverview Public options contract for the BymaxConfigModule dynamic
189
197
  * module: the schema to validate against, an optional raw source (defaulting to
190
- * the process environment inside the provider factory), an optional value-free
198
+ * the process environment inside the provider factory), an optional
191
199
  * observability hook invoked before the fail-fast throw, and an optional strict
192
200
  * unknown-key flag. This file declares only types and carries no runtime code.
193
201
  * @layer Types
@@ -217,10 +225,12 @@ interface BymaxConfigModuleOptions<TSchema extends EnvSchema = EnvSchema> {
217
225
  */
218
226
  readonly source?: Record<string, string | undefined>;
219
227
  /**
220
- * Observability hook invoked once with the structured, value-free issue list
221
- * immediately before the validation error is thrown. It is observability
222
- * only and cannot suppress the failure: the error still propagates even when
223
- * the hook itself throws.
228
+ * Observability hook invoked once with the structured issue list immediately
229
+ * before the validation error is thrown. The issues are value-free on the
230
+ * terms stated by {@link ConfigIssue}: generated descriptions never carry a
231
+ * received value, while a schema author's own `custom` message is passed
232
+ * through as written. It is observability only and cannot suppress the
233
+ * failure: the error still propagates even when the hook itself throws.
224
234
  */
225
235
  readonly onValidationError?: (issues: ReadonlyArray<ConfigIssue>) => void;
226
236
  /**
@@ -514,8 +524,8 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
514
524
  * @fileoverview The BYMAX_CONFIG provider factory: the fail-fast heart of the
515
525
  * module. It resolves the source (defaulting to `process.env`, using a custom
516
526
  * source verbatim), runs the single-pass validator, and on success returns the
517
- * deep-frozen typed config. On failure it invokes the value-free observability
518
- * hook exactly once before rethrowing the aggregated error; the hook is
527
+ * deep-frozen typed config. On failure it invokes the observability hook
528
+ * exactly once before rethrowing the aggregated error; the hook is
519
529
  * observability only and can never suppress or replace the failure, so a
520
530
  * misconfigured process is stopped before any consumer provider is constructed.
521
531
  * @layer Provider
@@ -99,11 +99,12 @@ type Path<TConfig> = {
99
99
  type PathValue<TConfig, TPath extends Path<TConfig>> = TPath extends `${infer Namespace}.${infer Leaf}` ? Namespace extends keyof TConfig ? Leaf extends keyof TConfig[Namespace] ? TConfig[Namespace][Leaf] : never : never : never;
100
100
 
101
101
  /**
102
- * @fileoverview Value-free error model for the configuration validation
103
- * pipeline: the frozen issue-code catalog, the ConfigIssue shape, and the
102
+ * @fileoverview Error model for the configuration validation pipeline: the
103
+ * frozen issue-code catalog, the ConfigIssue shape, and the
104
104
  * BymaxConfigValidationError aggregate. The error carries every violation as a
105
105
  * structured, immutable issue list and renders a multi-line report that never
106
- * echoes a raw source value.
106
+ * echoes a raw source value of its own — see {@link ConfigIssue} for the one
107
+ * exception, a message written by the schema author.
107
108
  * @layer Error
108
109
  */
109
110
  /**
@@ -135,11 +136,17 @@ type ConfigValidationCode = typeof ConfigErrorCode.VALIDATION;
135
136
  /** Machine-readable code classifying a single {@link ConfigIssue}. */
136
137
  type ConfigIssueCode = typeof ConfigErrorCode.MISSING | typeof ConfigErrorCode.INVALID | typeof ConfigErrorCode.UNKNOWN_KEY;
137
138
  /**
138
- * A single configuration violation, described entirely without any raw value.
139
+ * A single configuration violation, described for the operator who must fix it.
139
140
  *
140
- * Every field is value-free by contract: `message` states the expected
141
- * constraint, never the received input, so an issue can be logged or serialized
142
- * without leaking a secret.
141
+ * Every description this package generates is value-free by contract: `message`
142
+ * states the expected constraint, never the received input, so an issue can be
143
+ * logged or serialized without leaking a secret.
144
+ *
145
+ * The one exception is the message a schema author attaches to a `custom` issue
146
+ * (`.check`, `.refine`, `.superRefine`), which is reported as written because it
147
+ * is the only statement of a rule that has no structural constraint to describe.
148
+ * A message that interpolates the received value therefore carries that value
149
+ * into the report; keeping an authored message value-free is the author's call.
143
150
  */
144
151
  interface ConfigIssue {
145
152
  /** Nested config path, e.g. `database.url`; the namespace alone (e.g. `database`) for an unknown-key issue. */
@@ -148,16 +155,17 @@ interface ConfigIssue {
148
155
  readonly variable: string;
149
156
  /** Stable machine-readable classification. */
150
157
  readonly code: ConfigIssueCode;
151
- /** Human-readable constraint description, value-free. */
158
+ /** Human-readable constraint description; value-free unless a schema author's own `custom` message says otherwise. */
152
159
  readonly message: string;
153
160
  }
154
161
  /**
155
- * Aggregated, value-free configuration validation error.
162
+ * Aggregated configuration validation error.
156
163
  *
157
164
  * Thrown once at bootstrap when the source fails the schema. It carries every
158
165
  * violation in an immutable, structured {@link ConfigIssue} list and a
159
166
  * human-readable message that lists the offending variables and their expected
160
- * constraints, never their received values.
167
+ * constraints, never their received values — except where a schema author's own
168
+ * `custom` message says otherwise, as described on {@link ConfigIssue}.
161
169
  *
162
170
  * @example
163
171
  * ```typescript
@@ -176,7 +184,7 @@ declare class BymaxConfigValidationError extends Error {
176
184
  /** The immutable, aggregated list of collected violations. */
177
185
  readonly issues: ReadonlyArray<ConfigIssue>;
178
186
  /**
179
- * Build the aggregated error from a list of value-free issues.
187
+ * Build the aggregated error from the collected issues.
180
188
  *
181
189
  * @param issues - Every collected violation; copied and frozen so neither the
182
190
  * caller nor consumer code can mutate the reported list.
@@ -187,7 +195,7 @@ declare class BymaxConfigValidationError extends Error {
187
195
  /**
188
196
  * @fileoverview Public options contract for the BymaxConfigModule dynamic
189
197
  * module: the schema to validate against, an optional raw source (defaulting to
190
- * the process environment inside the provider factory), an optional value-free
198
+ * the process environment inside the provider factory), an optional
191
199
  * observability hook invoked before the fail-fast throw, and an optional strict
192
200
  * unknown-key flag. This file declares only types and carries no runtime code.
193
201
  * @layer Types
@@ -217,10 +225,12 @@ interface BymaxConfigModuleOptions<TSchema extends EnvSchema = EnvSchema> {
217
225
  */
218
226
  readonly source?: Record<string, string | undefined>;
219
227
  /**
220
- * Observability hook invoked once with the structured, value-free issue list
221
- * immediately before the validation error is thrown. It is observability
222
- * only and cannot suppress the failure: the error still propagates even when
223
- * the hook itself throws.
228
+ * Observability hook invoked once with the structured issue list immediately
229
+ * before the validation error is thrown. The issues are value-free on the
230
+ * terms stated by {@link ConfigIssue}: generated descriptions never carry a
231
+ * received value, while a schema author's own `custom` message is passed
232
+ * through as written. It is observability only and cannot suppress the
233
+ * failure: the error still propagates even when the hook itself throws.
224
234
  */
225
235
  readonly onValidationError?: (issues: ReadonlyArray<ConfigIssue>) => void;
226
236
  /**
@@ -514,8 +524,8 @@ declare function defineEnv<TShape extends EnvShape>(shape: TShape): EnvSchema<TS
514
524
  * @fileoverview The BYMAX_CONFIG provider factory: the fail-fast heart of the
515
525
  * module. It resolves the source (defaulting to `process.env`, using a custom
516
526
  * source verbatim), runs the single-pass validator, and on success returns the
517
- * deep-frozen typed config. On failure it invokes the value-free observability
518
- * hook exactly once before rethrowing the aggregated error; the hook is
527
+ * deep-frozen typed config. On failure it invokes the observability hook
528
+ * exactly once before rethrowing the aggregated error; the hook is
519
529
  * observability only and can never suppress or replace the failure, so a
520
530
  * misconfigured process is stopped before any consumer provider is constructed.
521
531
  * @layer Provider
@@ -85,7 +85,7 @@ var ConfigErrorCode = Object.freeze({
85
85
  });
86
86
  var BymaxConfigValidationError = class _BymaxConfigValidationError extends Error {
87
87
  /**
88
- * Build the aggregated error from a list of value-free issues.
88
+ * Build the aggregated error from the collected issues.
89
89
  *
90
90
  * @param issues - Every collected violation; copied and frozen so neither the
91
91
  * caller nor consumer code can mutate the reported list.
@@ -144,6 +144,7 @@ function resolveNamespacePrefixes(schema) {
144
144
  // src/env-validator.ts
145
145
  var UNKNOWN_KEY_MESSAGE = "unknown variable not declared in the schema";
146
146
  var MISSING_MESSAGE = "missing required value";
147
+ var ZOD_DEFAULT_CUSTOM_MESSAGE = "Invalid input";
147
148
  function collectPresentLeaves(bindings, source) {
148
149
  const present = [];
149
150
  for (const binding of bindings) {
@@ -187,13 +188,20 @@ function describeConstraint(issue) {
187
188
  return "invalid value";
188
189
  }
189
190
  }
191
+ function authoredMessage(issue) {
192
+ if (issue.code !== "custom") return void 0;
193
+ const collapsed = issue.message.replace(/\s+/g, " ").trim();
194
+ if (collapsed.length === 0 || collapsed === ZOD_DEFAULT_CUSTOM_MESSAGE) return void 0;
195
+ return collapsed;
196
+ }
190
197
  function toConfigIssue(binding, issue, source) {
191
198
  const isMissing = source.get(binding.variable) === void 0;
199
+ const authored = authoredMessage(issue);
192
200
  return {
193
201
  path: binding.path,
194
202
  variable: binding.variable,
195
203
  code: isMissing ? ConfigErrorCode.MISSING : ConfigErrorCode.INVALID,
196
- message: isMissing ? MISSING_MESSAGE : describeConstraint(issue)
204
+ message: authored ?? (isMissing ? MISSING_MESSAGE : describeConstraint(issue))
197
205
  };
198
206
  }
199
207
  function translateIssues(zodIssues, bindings, source) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-config",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Typed and validated environment configuration entry point for NestJS 11 applications, built on Zod v4.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",