@bymax-one/nest-config 1.1.2 → 1.2.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,115 @@ as the GitHub Release body, so each released version needs a matching
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.0] - 2026-09-04
15
+
16
+ ### Added
17
+
18
+ - **A namespace can declare itself open, so `strict` is usable next to a library that
19
+ reads its own environment.** `strict` claims a declared namespace's entire variable
20
+ prefix: a namespace named `otel` rejects the OpenTelemetry SDK's own
21
+ `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_TRACES_SAMPLER`, and one named `redis` rejects a
22
+ `REDIS_PORT` only a compose file reads. A prefix matching no declared namespace
23
+ (`POSTGRES_*` with no `postgres` namespace) is never inspected, so the failure lands
24
+ hardest exactly where a schema shares a prefix with another program — which is the common
25
+ case. `OTEL_` is an open standard; a consumer cannot rename around it.
26
+
27
+ `z.object({ ... }).meta({ open: true })` on the namespace waives unknown-key detection
28
+ for the remainder of that prefix. The waiver is narrow by construction: declared leaves
29
+ stay bound and validated (an open `otel` still reads and coerces `OTEL_ENABLED`), it
30
+ applies per namespace rather than per schema, and a more specific closed namespace still
31
+ claims its own variables (`otelExporter` beside an open `otel` still reports
32
+ `OTEL_EXPORTER_TYPO`). Only the literal `true` opens a namespace.
33
+
34
+ **The cost is stated rather than designed away:** an open namespace cannot distinguish a
35
+ foreign variable from a misspelled local one, so a typo of `OTEL_ENABLED` passes and the
36
+ leaf falls back to its default. The two cases are indistinguishable by name. Open the
37
+ namespaces whose prefix genuinely belongs to another program, and leave the rest closed.
38
+
39
+ Filtering the foreign names out of `source` is not an equivalent workaround and is worse
40
+ in a way that is easy to miss: removing `OTEL_*` from the source also unbinds the declared
41
+ `OTEL_ENABLED`, whose `.default()` then manufactures a plausible value while the variable
42
+ silently stops being read.
43
+
44
+ ### Security
45
+
46
+ - **Development-only advisories cleared; the published package was never affected.** Eight
47
+ advisories landed against transitive development dependencies across this cycle:
48
+ `browserslist` (GHSA-73wf-gq98-2v4g, GHSA-c83g-rgw3-j3cx, CVSS 7.5 each), `fast-uri`
49
+ (GHSA-5jgf-p345-68v8, GHSA-jqff-g426-hqxp — host confusion; GHSA-f65p-4m7j-42xc,
50
+ GHSA-fph4-wmhf-6fwf — server-side request forgery; CVSS 7.5 each) and `qs`
51
+ (GHSA-4mjr-xmp4-gh2g, GHSA-x5fp-wj9c-mxmx, CVSS 6.3 each). All three are reached through
52
+ the mutation-testing and commit-tooling chains, none is a runtime dependency, and this
53
+ package ships `dist` alone — so no consumer resolved a vulnerable version through it.
54
+
55
+ Recorded here rather than omitted as internal because a consumer reading an advisory feed
56
+ needs to know whether a library in their tree pulled one in, and "it did not" is an answer
57
+ only the maintainer can give. The floors now sit above every affected range and the
58
+ lockfile resolves `browserslist@4.28.8`, `fast-uri@3.1.6` and `qs@6.16.0`.
59
+
60
+ ### Documentation
61
+
62
+ - **`strict` now documents what it claims before you enable it.** The option read as an
63
+ unambiguous improvement and gave no warning that a declared namespace claims its whole
64
+ prefix — the behaviour was measured by a consumer only after enabling it broke a boot on
65
+ five correct variables. The API reference gains a `Strict mode and shared prefixes`
66
+ section covering the claim, the open-namespace opt-out, and its cost.
67
+
68
+ ## [1.1.3] - 2026-08-18
69
+
70
+ **Documentation.** The runtime bundles are byte-identical to `1.1.2`. Five files differ: the
71
+ two `./internal` declaration files, by the JSDoc noted below; `README.md` and `CHANGELOG.md`;
72
+ and `package.json`, by its `version` field alone. Verified by hashing every file in the
73
+ published `1.1.2` tarball against this build.
74
+
75
+ ### Documentation
76
+
77
+ - **How to report the failure is documented, because the two obvious ways are not
78
+ equivalent.** `onValidationError` said what the hook is and nothing about logging what it
79
+ hands you, which left the choice at the call site with no way to know the cost. `code` and
80
+ `issues` are the only two own enumerable properties of `BymaxConfigValidationError`,
81
+ while `name`, `message` and `stack` are non-enumerable as on any `Error`. That splits the
82
+ outcome three ways by representation: an error object keeps everything when the serializer
83
+ extracts the standard fields and copies the own enumerables, which are two separate steps; `error.stack` keeps the report and drops `code` and `issues`;
84
+ and a plain `JSON.stringify(error)` keeps `code` and `issues` and drops the report.
85
+ Which representation reaches the sink is decided by the logging call, and that call is
86
+ library-specific — Pino serializes an error only as the merging object or under `err`,
87
+ and drops it entirely when it is passed as a trailing argument. The machine-readable half is the easy one to
88
+ lose, precisely because the report keeps arriving — `code` separates a configuration
89
+ failure from any other boot failure, and `issues` is what an alert keys on per variable.
90
+
91
+ Wrapping the error as a `cause` also keeps all of it, wherever the serializer walks the
92
+ chain the same way. Measured against `@bymax-one/nest-logger` 1.2.7 and 1.2.9 rather
93
+ than assumed: a fifteen-issue report crosses the chain with every issue and the full multi-line
94
+ `message` intact.
95
+
96
+ Versions 1.2.6 through 1.2.9 carry a defect the README states in full: handed this error
97
+ **directly**, the redactor dropped the whole `err` field, leaving `_redactionFailed: true`
98
+ and no report at all. The trigger was `issues` rather than `code`, and the property
99
+ definition rather than freezing: this error is not frozen — it defines `code` and `issues`
100
+ as non-writable, non-configurable own properties and freezes the issue list, while the
101
+ instance stays extensible. Only the locked object property failed, because the redactor's
102
+ clone inherited the locked descriptors and redefining a non-configurable property fails
103
+ exactly when the value differs, which a structurally copied object does and an identical
104
+ string does not.
105
+
106
+ **Fixed upstream in `1.3.0`**, measured against the published package rather than taken on
107
+ report: a real failure logged directly now arrives with `type, message, stack, code,
108
+ issues`, the full issue array and the multi-line report intact, and this error is left
109
+ untouched. On an affected version the workaround stands, and it is something a consumer
110
+ writes: a `catch` receives this error unchanged, since the module rethrows the instance it
111
+ caught, so wrapping is an explicit `new Error(message, { cause: error })`.
112
+
113
+ The rule also covers the call site where this failure usually lands: a `catch` in
114
+ `main.ts` runs before any logging module is registered, so it reports through
115
+ `console.error`. Node's inspector appends an error's own enumerables after the stack,
116
+ so `console.error(message, error)` prints the report, the `code` and the expanded `issues`, while
117
+ `console.error(message, error.stack)` prints the report alone — the same split, one layer
118
+ earlier.
119
+
120
+ The same fact is now stated on `BymaxConfigValidationError` itself, so it reaches a
121
+ consumer through the published types and not only through the README.
122
+
14
123
  ## [1.1.2] - 2026-08-14
15
124
 
16
125
  **Documentation only.** `dist/` is byte-identical to `1.1.1`, verified by hashing every file in
@@ -264,4 +373,6 @@ have regressed from. They are kept because the reasoning is worth having.
264
373
  [1.1.0]: https://github.com/bymaxone/nest-config/compare/v1.0.3...v1.1.0
265
374
  [1.1.1]: https://github.com/bymaxone/nest-config/compare/v1.1.0...v1.1.1
266
375
  [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
376
+ [1.1.3]: https://github.com/bymaxone/nest-config/compare/v1.1.2...v1.1.3
377
+ [1.2.0]: https://github.com/bymaxone/nest-config/compare/v1.1.3...v1.2.0
378
+ [Unreleased]: https://github.com/bymaxone/nest-config/compare/v1.2.0...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.61%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.62%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>
@@ -258,6 +258,20 @@ database: z.object({
258
258
  })
259
259
  ```
260
260
 
261
+ A namespace can declare itself **open**, which waives strict unknown-key
262
+ detection for the rest of its prefix. Use it when the prefix is shared with
263
+ another program that reads its own variables — see
264
+ [Strict mode and shared prefixes](#strict-mode-and-shared-prefixes):
265
+
266
+ ```typescript
267
+ // OTEL_ENABLED is read and validated; every other OTEL_* variable belongs to
268
+ // the OpenTelemetry SDK, which reads it natively.
269
+ otel: z.object({ enabled: z.stringbool().default(false) }).meta({ open: true })
270
+ ```
271
+
272
+ Only the literal `true` opens a namespace. Any other value, and any metadata
273
+ without the key, leaves detection in force.
274
+
261
275
  ### `BymaxConfigModule`
262
276
 
263
277
  | Method | Signature |
@@ -267,12 +281,12 @@ database: z.object({
267
281
 
268
282
  `BymaxConfigModuleOptions`:
269
283
 
270
- | Field | Type | Required | Notes |
271
- | ------------------- | ---------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
272
- | `schema` | `EnvSchema` | yes | Produced by `defineEnv`. |
273
- | `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env` in the provider factory. Injectable for tests. |
274
- | `onValidationError` | `(issues: ReadonlyArray<ConfigIssue>) => void` | no | Observability hook invoked before the fail-fast throw. Cannot suppress the failure. |
275
- | `strict` | `boolean` | no | When true, source variables matching a namespace prefix but no declared leaf raise `BYMAX_CONFIG_UNKNOWN_KEY`. Defaults to `false`. |
284
+ | Field | Type | Required | Notes |
285
+ | ------------------- | ---------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
+ | `schema` | `EnvSchema` | yes | Produced by `defineEnv`. |
287
+ | `source` | `Record<string, string \| undefined>` | no | Defaults to `process.env` in the provider factory. Injectable for tests. |
288
+ | `onValidationError` | `(issues: ReadonlyArray<ConfigIssue>) => void` | no | Observability hook invoked before the fail-fast throw. Cannot suppress the failure. |
289
+ | `strict` | `boolean` | no | When true, source variables matching a namespace prefix but no declared leaf raise `BYMAX_CONFIG_UNKNOWN_KEY`. Defaults to `false`. Read [Strict mode and shared prefixes](#strict-mode-and-shared-prefixes) before enabling it. |
276
290
 
277
291
  `forRootAsync` resolves the source through another provider, for example a
278
292
  secrets-manager client composed with `process.env`:
@@ -293,6 +307,53 @@ BymaxConfigModule.forRootAsync({
293
307
  `isGlobal` defaults to `true` and can be set to `false` through the standard
294
308
  extras when an application intentionally scopes configuration to a submodule.
295
309
 
310
+ #### Strict mode and shared prefixes
311
+
312
+ **A declared namespace claims its entire variable prefix.** Under `strict`, any
313
+ source variable starting with that prefix and matching no declared leaf fails
314
+ the boot — including variables this application never reads because _another
315
+ program_ reads them. A namespace named `otel` claims `OTEL_*`, so the
316
+ OpenTelemetry SDK's own `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_TRACES_SAMPLER`
317
+ are rejected; a namespace named `redis` claims `REDIS_*`, so a `REDIS_PORT` that
318
+ only a compose file reads is rejected too. A prefix matching no declared
319
+ namespace at all (`POSTGRES_*` with no `postgres` namespace) is never inspected,
320
+ which is why the failure lands hardest on schemas that share a prefix with a
321
+ library or a tool — the common case, not the edge one.
322
+
323
+ Declare such a namespace **open** to waive the check for the remainder of its
324
+ prefix:
325
+
326
+ ```typescript
327
+ export const envSchema = defineEnv({
328
+ // OTEL_ENABLED is declared, read and validated. Every other OTEL_* variable
329
+ // is the SDK's, and strict leaves it alone.
330
+ otel: z.object({ enabled: z.stringbool().default(false) }).meta({ open: true }),
331
+ database: z.object({ url: z.url() })
332
+ })
333
+ ```
334
+
335
+ What the waiver does and does not do:
336
+
337
+ - **Declared leaves stay bound.** `OTEL_ENABLED` is still read, coerced and
338
+ validated. Opening a namespace waives unknown-key detection only; it never
339
+ weakens the validation the schema does perform.
340
+ - **The waiver is per namespace.** A stray `DATABASE_TYPO` is still reported
341
+ while `otel` is open. One open namespace does not disable strict mode.
342
+ - **The most specific namespace wins.** If a schema declares both `otel` (open)
343
+ and `otelExporter` (closed), `OTEL_EXPORTER_TYPO` belongs to `otelExporter`
344
+ and is still reported.
345
+
346
+ **The cost, stated plainly:** an open namespace cannot tell a foreign variable
347
+ from a misspelled local one. Under an open `otel`, a typo of `OTEL_ENABLED`
348
+ passes silently and the leaf falls back to its default. No design removes this —
349
+ the two cases are indistinguishable by name — so open the namespaces whose
350
+ prefix genuinely belongs to someone else, and leave the rest closed.
351
+
352
+ > Filtering the foreign names out of `source` instead is **not** equivalent, and
353
+ > is worse in a way that is easy to miss: removing `OTEL_*` from the source also
354
+ > unbinds the declared `OTEL_ENABLED`, whose `.default()` then manufactures a
355
+ > plausible value while the variable silently stops being read.
356
+
296
357
  ### `ConfigService<TConfig>`
297
358
 
298
359
  The typed, injectable accessor over the frozen configuration object.
@@ -327,6 +388,83 @@ export interface ConfigIssue {
327
388
  error's own `code`) and `ConfigIssueCode` (each issue's `code`); see the
328
389
  error catalog below for every value.
329
390
 
391
+ ### Reporting the failure
392
+
393
+ `onValidationError` receives `ReadonlyArray<ConfigIssue>` — four string fields per
394
+ issue, JSON-serializable, with no error to unwrap. Every description this package
395
+ generates is value-free, so it can go straight into a log as structured data. The
396
+ exception is the one described above: a `custom` message written by your own schema
397
+ is printed as written, so if one of yours interpolates a value, reviewing it before
398
+ it reaches a log is the schema author's job, not this package's.
399
+
400
+ If you also log the thrown error from a bootstrap `catch`, how you pass it decides
401
+ what survives. `code` and `issues` are the **only two own enumerable properties** of
402
+ `BymaxConfigValidationError`; `name`, `message` and `stack` are non-enumerable, as
403
+ on any `Error`. That splits the outcome three ways — all three measured:
404
+
405
+ | Which representation reaches the sink | The report (`message`) | `code` and `issues` |
406
+ | ------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------- |
407
+ | the error object, read by a serializer that extracts the standard fields **and** copies own enumerables | ✅ | ✅ |
408
+ | `error.stack` | ✅ inside the string | ❌ |
409
+ | `JSON.stringify(error)`, `{ ...error }` | ❌ | ✅ |
410
+
411
+ Recognizing an `Error` is not enough on its own: extracting `name`, `message` and
412
+ `stack` is what preserves the report, and copying the own enumerables is a separate
413
+ step that preserves `code` and `issues`. A serializer that does only the second is
414
+ the third row — `code` and `issues` and no report at all — so if you build the log
415
+ record by hand, add `message` explicitly.
416
+
417
+ **Which representation the sink sees is decided by your logging call, and that call
418
+ is library-specific.** These are measured, not assumed:
419
+
420
+ ```typescript
421
+ console.error('configuration invalid', error) // report, code and issues
422
+ pino.error({ err: error }, 'configuration invalid') // report, code and issues
423
+ pino.error('configuration invalid', error) // the error is dropped entirely
424
+
425
+ // @bymax-one/nest-logger 1.2.6-1.2.9: wrap it, explicitly (see the note below)
426
+ nestLogger.error('BOOT_FAILED', new Error('bootstrap failed', { cause: error }))
427
+ ```
428
+
429
+ Pino only applies error serialization when the error is the merging object or sits
430
+ under `err`; passed as a trailing argument it is treated as an interpolation value,
431
+ and nothing about the failure reaches the entry.
432
+
433
+ > [!NOTE]
434
+ > **`@bymax-one/nest-logger` 1.2.6 through 1.2.9 dropped this error when it was logged
435
+ > directly.** Its redactor left `_redactionFailed: true` and no `err` field at all —
436
+ > no report, no `code`, no `issues`. The trigger was the definition rather than any
437
+ > freezing: this error defines `code` and `issues` as non-writable, non-configurable
438
+ > own properties, and the redactor's clone inherited those descriptors, so rewriting
439
+ > the object-valued one with a walked copy failed. A locked scalar was unaffected,
440
+ > which is why `code` alone never reproduced it.
441
+ >
442
+ > Fixed in **1.3.0**, measured rather than taken on report: a real failure logged
443
+ > directly now arrives with `type, message, stack, code, issues`, the full issue array
444
+ > and the report intact, and this error is left untouched. Measured affected: 1.2.6, 1.2.7
445
+ > and 1.2.9 — 1.2.8 was never published, and on 1.2.2 and earlier this call shape does not
446
+ > attach the error at all, so the defect does not arise there in this form. On an affected
447
+ > version, wrap it — a `catch` receives this error unchanged, since the
448
+ > module rethrows the instance it caught, so wrapping is an explicit
449
+ > `new Error(message, { cause: error })` rather than a side effect of catching. A
450
+ > lockfile pinned below 1.3.0 does not pick the fix up from an ordinary install, so the
451
+ > update has to be deliberate.
452
+
453
+ Wrapping the error as the `cause` of an error you construct keeps all of it wherever the
454
+ serializer walks the chain — measured against `@bymax-one/nest-logger` 1.2.7 and
455
+ 1.2.9, where a fifteen-issue report crosses the chain with `code`, every issue and
456
+ the full multi-line `message` intact.
457
+
458
+ Before any logger exists — which is where this failure usually lands, since a `catch`
459
+ in `main.ts` runs before the logging module is registered — `console.error` is the
460
+ reporter, and Node's inspector prints the stack and then appends the own enumerables.
461
+ So `console.error(message, error)` shows the report, the `code` and the expanded
462
+ `issues` list, while `console.error(message, error.stack)` shows the report alone.
463
+
464
+ Losing the machine-readable half is easy to miss precisely because the report keeps
465
+ arriving: `code` is what separates a configuration failure from any other boot
466
+ failure, and `issues` is what an alert or a dashboard keys on per variable.
467
+
330
468
  ### Injection tokens
331
469
 
332
470
  `BYMAX_CONFIG` and `BYMAX_CONFIG_OPTIONS` are module-local `Symbol` tokens,
@@ -595,6 +733,10 @@ standard is Jest.
595
733
  4. **No multi-source precedence.** One source record per module
596
734
  registration. Precedence between layers (defaults, env, secrets) is the
597
735
  caller's composition (`{ ...a, ...b }`), kept explicit on purpose.
736
+ 5. **An open namespace cannot detect a typo in its own prefix.** A foreign
737
+ variable and a misspelled local one are indistinguishable by name, so
738
+ `meta({ open: true })` accepts both. See
739
+ [Strict mode and shared prefixes](#strict-mode-and-shared-prefixes).
598
740
 
599
741
  ## 🤝 Contributing
600
742
 
@@ -125,6 +125,9 @@ function readEnvOverride(leaf) {
125
125
  const override = leaf.meta()?.env;
126
126
  return typeof override === "string" && override.length > 0 ? override : void 0;
127
127
  }
128
+ function readOpenFlag(namespace) {
129
+ return namespace.meta()?.open === true;
130
+ }
128
131
  function deriveVariable(namespace, leafKey) {
129
132
  return `${toScreamingSnake(namespace)}_${toScreamingSnake(leafKey)}`;
130
133
  }
@@ -137,9 +140,10 @@ function resolveSourceNames(schema) {
137
140
  );
138
141
  }
139
142
  function resolveNamespacePrefixes(schema) {
140
- return Object.keys(schema.shape).map((namespace) => ({
143
+ return Object.entries(schema.shape).map(([namespace, namespaceSchema]) => ({
141
144
  namespace,
142
- prefix: `${toScreamingSnake(namespace)}_`
145
+ prefix: `${toScreamingSnake(namespace)}_`,
146
+ open: readOpenFlag(namespaceSchema)
143
147
  }));
144
148
  }
145
149
 
@@ -240,6 +244,7 @@ function detectUnknownKeys(prefixes, bindings, source) {
240
244
  if (source.get(key) === void 0) continue;
241
245
  const match = longestMatchingPrefix(prefixes, key);
242
246
  if (match === void 0) continue;
247
+ if (match.open) continue;
243
248
  issues.push({
244
249
  path: match.namespace,
245
250
  variable: key,
@@ -167,6 +167,10 @@ interface ConfigIssue {
167
167
  * constraints, never their received values — except where a schema author's own
168
168
  * `custom` message says otherwise, as described on {@link ConfigIssue}.
169
169
  *
170
+ * `code` and `issues` are its only own enumerable properties, so a structured
171
+ * logger that copies own enumerables keeps both; passing `error.stack` instead of
172
+ * the error keeps neither.
173
+ *
170
174
  * @example
171
175
  * ```typescript
172
176
  * try {
@@ -236,6 +240,12 @@ interface BymaxConfigModuleOptions<TSchema extends EnvSchema = EnvSchema> {
236
240
  /**
237
241
  * When true, source variables that match a declared namespace prefix but no
238
242
  * declared leaf produce `BYMAX_CONFIG_UNKNOWN_KEY` issues. Defaults to false.
243
+ *
244
+ * The prefix is claimed whole, so a namespace sharing its prefix with another
245
+ * program (`OTEL_` with an OpenTelemetry SDK, `REDIS_` with a compose file)
246
+ * would reject that program's variables. Declare such a namespace
247
+ * `meta({ open: true })` to waive the check for the rest of its prefix while
248
+ * its own leaves stay bound and validated.
239
249
  */
240
250
  readonly strict?: boolean;
241
251
  }
@@ -558,8 +568,11 @@ declare function createValidatedConfig<TShape extends EnvShape>(options: Omit<By
558
568
  * @fileoverview Deterministic mapping between nested config paths and flat
559
569
  * environment variable names. Each leaf resolves to exactly one variable:
560
570
  * SCREAMING_SNAKE_CASE of the joined path (`database.url` reads `DATABASE_URL`),
561
- * unless the leaf declares a `meta({ env })` override, which wins. This is an
562
- * internal module consumed by the validator; it is not part of the public API.
571
+ * unless the leaf declares a `meta({ env })` override, which wins. It also
572
+ * resolves the shared prefix of each namespace and whether that namespace
573
+ * declared `meta({ open: true })`, the two facts strict unknown-key detection
574
+ * runs on. This is an internal module consumed by the validator; it is not part
575
+ * of the public API.
563
576
  * @layer Utility
564
577
  */
565
578
 
@@ -167,6 +167,10 @@ interface ConfigIssue {
167
167
  * constraints, never their received values — except where a schema author's own
168
168
  * `custom` message says otherwise, as described on {@link ConfigIssue}.
169
169
  *
170
+ * `code` and `issues` are its only own enumerable properties, so a structured
171
+ * logger that copies own enumerables keeps both; passing `error.stack` instead of
172
+ * the error keeps neither.
173
+ *
170
174
  * @example
171
175
  * ```typescript
172
176
  * try {
@@ -236,6 +240,12 @@ interface BymaxConfigModuleOptions<TSchema extends EnvSchema = EnvSchema> {
236
240
  /**
237
241
  * When true, source variables that match a declared namespace prefix but no
238
242
  * declared leaf produce `BYMAX_CONFIG_UNKNOWN_KEY` issues. Defaults to false.
243
+ *
244
+ * The prefix is claimed whole, so a namespace sharing its prefix with another
245
+ * program (`OTEL_` with an OpenTelemetry SDK, `REDIS_` with a compose file)
246
+ * would reject that program's variables. Declare such a namespace
247
+ * `meta({ open: true })` to waive the check for the rest of its prefix while
248
+ * its own leaves stay bound and validated.
239
249
  */
240
250
  readonly strict?: boolean;
241
251
  }
@@ -558,8 +568,11 @@ declare function createValidatedConfig<TShape extends EnvShape>(options: Omit<By
558
568
  * @fileoverview Deterministic mapping between nested config paths and flat
559
569
  * environment variable names. Each leaf resolves to exactly one variable:
560
570
  * SCREAMING_SNAKE_CASE of the joined path (`database.url` reads `DATABASE_URL`),
561
- * unless the leaf declares a `meta({ env })` override, which wins. This is an
562
- * internal module consumed by the validator; it is not part of the public API.
571
+ * unless the leaf declares a `meta({ env })` override, which wins. It also
572
+ * resolves the shared prefix of each namespace and whether that namespace
573
+ * declared `meta({ open: true })`, the two facts strict unknown-key detection
574
+ * runs on. This is an internal module consumed by the validator; it is not part
575
+ * of the public API.
563
576
  * @layer Utility
564
577
  */
565
578
 
@@ -123,6 +123,9 @@ function readEnvOverride(leaf) {
123
123
  const override = leaf.meta()?.env;
124
124
  return typeof override === "string" && override.length > 0 ? override : void 0;
125
125
  }
126
+ function readOpenFlag(namespace) {
127
+ return namespace.meta()?.open === true;
128
+ }
126
129
  function deriveVariable(namespace, leafKey) {
127
130
  return `${toScreamingSnake(namespace)}_${toScreamingSnake(leafKey)}`;
128
131
  }
@@ -135,9 +138,10 @@ function resolveSourceNames(schema) {
135
138
  );
136
139
  }
137
140
  function resolveNamespacePrefixes(schema) {
138
- return Object.keys(schema.shape).map((namespace) => ({
141
+ return Object.entries(schema.shape).map(([namespace, namespaceSchema]) => ({
139
142
  namespace,
140
- prefix: `${toScreamingSnake(namespace)}_`
143
+ prefix: `${toScreamingSnake(namespace)}_`,
144
+ open: readOpenFlag(namespaceSchema)
141
145
  }));
142
146
  }
143
147
 
@@ -238,6 +242,7 @@ function detectUnknownKeys(prefixes, bindings, source) {
238
242
  if (source.get(key) === void 0) continue;
239
243
  const match = longestMatchingPrefix(prefixes, key);
240
244
  if (match === void 0) continue;
245
+ if (match.open) continue;
241
246
  issues.push({
242
247
  path: match.namespace,
243
248
  variable: key,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-config",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
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",
@@ -56,16 +56,17 @@
56
56
  "scripts": {
57
57
  "build": "pnpm clean && tsup",
58
58
  "check:exports": "attw --pack . --profile strict",
59
+ "check:lint-graph": "node scripts/check-lint-graph-rules.mjs",
59
60
  "check:mutants": "node scripts/check-mutation-directives.mjs",
60
61
  "check:published": "node scripts/check-published-surface.mjs",
61
62
  "check:runtime": "node scripts/check-consumer-runtime.mjs",
62
63
  "clean": "node -e \"const fs=require('node:fs');for(const d of ['dist','coverage'])fs.rmSync(d,{recursive:true,force:true})\"",
63
64
  "dogfood": "node scripts/dogfood-smoke-test.mjs",
64
- "lint": "eslint src",
65
- "lint:fix": "eslint src --fix",
65
+ "lint": "eslint src scripts",
66
+ "lint:fix": "eslint src scripts --fix",
66
67
  "mutation": "stryker run",
67
- "mutation:full": "node -e \"require('node:fs').rmSync('reports/stryker-incremental.json',{force:true,recursive:true})\" && stryker run",
68
68
  "mutation:dry-run": "stryker run --dryRunOnly",
69
+ "mutation:full": "node -e \"require('node:fs').rmSync('reports/stryker-incremental.json',{force:true,recursive:true})\" && stryker run",
69
70
  "prepare": "husky",
70
71
  "prepublishOnly": "pnpm typecheck && pnpm lint && pnpm check:mutants && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:published",
71
72
  "release": "npm publish --provenance --access public",
@@ -86,7 +87,6 @@
86
87
  "prettier --write"
87
88
  ]
88
89
  },
89
- "dependencies": {},
90
90
  "peerDependencies": {
91
91
  "@nestjs/common": "^11.0.16",
92
92
  "@nestjs/core": "^11.1.18",
@@ -97,7 +97,7 @@
97
97
  "@arethetypeswrong/cli": "^0.18.2",
98
98
  "@commitlint/cli": "^21.0.2",
99
99
  "@commitlint/config-conventional": "^21.0.2",
100
- "@eslint/js": "^9.39.4",
100
+ "@eslint/js": "^10.0.1",
101
101
  "@nestjs/common": "^11.1.20",
102
102
  "@nestjs/core": "^11.1.20",
103
103
  "@nestjs/platform-express": "^11.1.20",
@@ -110,10 +110,10 @@
110
110
  "@types/node": "^25",
111
111
  "@typescript-eslint/eslint-plugin": "^8.59.3",
112
112
  "@typescript-eslint/parser": "^8.59.3",
113
- "eslint": "^9.39.4",
113
+ "eslint": "^10.9.1",
114
114
  "eslint-config-prettier": "^10.1.8",
115
- "eslint-import-resolver-typescript": "^4.4.4",
116
- "eslint-plugin-import": "^2.32.0",
115
+ "eslint-import-resolver-typescript": "^4.4.5",
116
+ "eslint-plugin-import-x": "^4.17.1",
117
117
  "eslint-plugin-prettier": "^5.5.5",
118
118
  "eslint-plugin-security": "^4.0.0",
119
119
  "globals": "^17.6.0",