@zudojs/config 1.0.1 → 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/README.md CHANGED
@@ -3,6 +3,12 @@
3
3
  Layered configuration management with multiple sources, priority-based
4
4
  merging, schema validation, and sensitive-value redaction.
5
5
 
6
+ <!-- zudo-docs:start -->
7
+
8
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-config](https://zudojs.oyinlola.site/docs/packages-config) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-config.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
9
+
10
+ <!-- zudo-docs:end -->
11
+
6
12
  ## Installation
7
13
 
8
14
  ```bash
@@ -52,14 +58,24 @@ await `manager.ready()` before reading values.
52
58
  A source provides values plus a priority. Higher priority wins; equal
53
59
  priorities are applied in registration order (last registered wins).
54
60
 
61
+ A source that declares no priority gets `DEFAULT_CONFIG_SOURCE_PRIORITY`
62
+ (`-1`), which is strictly below the priority `initialValues` is seeded
63
+ at (`0`), so an undeclared source layers UNDER the values a manager was
64
+ seeded with. Declare `priority: 0` (or higher) on the source when it
65
+ should override them.
66
+
67
+ Sources are deduplicated by name — the first occurrence of a name wins,
68
+ both when they are passed to the constructor and through `addSource()`
69
+ (which rejects a duplicate outright).
70
+
55
71
  - `createDefaultsConfigSource(values, name?)` — low-priority defaults.
56
72
  - `createMemoryConfigSource(values, { name, priority?, optional? })` —
57
73
  in-memory values.
58
74
  - `createEnvironmentConfigSource({ prefix, env?, priority?, keyMapper?, isSensitive? })`
59
75
  — reads environment variables. Values stay RAW STRINGS (use the typed
60
76
  accessors to coerce, so `"false"` becomes `false` instead of a truthy
61
- string). Names matching a password/token/secret/API-key pattern are
62
- reported as sensitive and therefore redacted by `toSafeObject()`.
77
+ string). Names matching `isSensitiveConfigKey` are reported as
78
+ sensitive and therefore redacted by `toSafeObject()`.
63
79
  - `createCustomConfigSource(name, loader, options?)` — async loader
64
80
  function returning `{ values, source, type }`. Include
65
81
  `sensitiveKeys: ["some.key"]` in the result to mark values as
@@ -83,6 +99,13 @@ const remote = createCustomConfigSource("vault", async () => ({
83
99
  }));
84
100
  ```
85
101
 
102
+ Whatever the source, the loader also marks an entry sensitive when
103
+ `isSensitiveConfigEntry(key, value)` flags it: a key naming a password,
104
+ secret, token, API/private key, credential, DSN, database URL or `*_key`
105
+ (in any dotted segment, case-insensitive), a nested object containing such
106
+ a key, or a URL with embedded `user:password@` credentials. Returning
107
+ `false` from `isSensitive` or omitting `sensitiveKeys` cannot un-mark these.
108
+
86
109
  ## Typed access
87
110
 
88
111
  `ConfigManager` (and the underlying `ConfigResolver`) offer typed
@@ -95,6 +118,9 @@ const db = manager.scoped("db");
95
118
  db.string("host", "localhost");
96
119
  ```
97
120
 
121
+ `number()` accepts decimal notation only: `"0x1F90"`, `"0b11"` and `"0o17"`
122
+ are rejected rather than silently becoming 8080, 3 and 15.
123
+
98
124
  ## Schema validation
99
125
 
100
126
  Schemas are plain objects validated by this package (no external
@@ -118,7 +144,10 @@ so nested constraints are enforced by `validate()`, `resolve()` and the
118
144
  standalone `validateConfigValue`.
119
145
 
120
146
  `validate()` throws on failure and marks entries whose schema has
121
- `secret: true` as sensitive. Individual values can be validated with
147
+ `secret: true` as sensitive, at any depth: a secret declared inside a
148
+ nested object schema, or as a dotted key such as `"db.password"`, marks
149
+ the store entry that holds it (for a nested `db` object, the whole `db`
150
+ entry is redacted). Individual values can be validated with
122
151
  `manager.resolve(key, schema)` or the standalone
123
152
  `validateConfigValue` / `validateConfigObject` functions.
124
153
 
@@ -128,12 +157,26 @@ standalone `validateConfigValue`.
128
157
  including secrets.
129
158
  - `manager.toSafeObject()` / `store.toSafeObject()` replace sensitive
130
159
  values with `"[REDACTED]"` — use these for logging and diagnostics.
160
+ - An entry is marked sensitive automatically by `ConfigStore.set()`, so
161
+ the same key is redacted however it was written: from a source, from
162
+ `initialValues`, from `set()` / `setMany()` / `replace()`, or from
163
+ `manager.set()`. Detection is `isSensitiveConfigEntry` — the key name
164
+ (`password`, `secret`, `token`, `*_key`, `auth`, `dsn`,
165
+ `database_url`, …) or a value that is a URL with embedded
166
+ `user:password@` credentials. Pass `sensitive: false` explicitly to
167
+ opt a key out.
131
168
 
132
169
  ## Reloading
133
170
 
134
171
  `manager.reload()` re-runs all sources. Values set at runtime via
135
172
  `manager.set()` and `initialValues` survive loads; sources only
136
- overwrite entries at equal or higher priority.
173
+ overwrite entries at equal or higher priority, and a source that
174
+ declares no priority ranks below both. A reload rebuilds every
175
+ source-provided value from scratch, so a value a source no longer
176
+ provides disappears (letting a lower-priority default show through), and
177
+ the store is only updated once every source has loaded — a failing
178
+ source leaves the previous configuration intact. An entry that was
179
+ sensitive before a reload stays sensitive.
137
180
 
138
181
  ## Untrusted input
139
182
 
@@ -141,7 +184,8 @@ Configuration frequently originates from files, environment variables
141
184
  or remote services. Keys such as `__proto__`, `constructor` and
142
185
  `prototype` are copied with `Object.defineProperty`, never plain
143
186
  assignment, so a hostile key becomes an ordinary own property and can
144
- never replace an object's prototype. Schema properties are read as OWN
187
+ never replace an object's prototype (this includes
188
+ `toConfigJsonValue` and `configValueToString`). Schema properties are read as OWN
145
189
  properties, so a schema property named `constructor` is reported as
146
190
  missing rather than matching an inherited function.
147
191
 
@@ -94,7 +94,10 @@ export declare class ConfigLoader {
94
94
  /**
95
95
  * Reloads configuration.
96
96
  *
97
- * The current store is replaced with freshly loaded values.
97
+ * The current store is replaced with freshly loaded values. When the
98
+ * loader layers onto a shared store (`clearStore: false`), values that
99
+ * sources no longer provide are dropped, runtime and initial values are
100
+ * kept, and the store is only updated once every source has loaded.
98
101
  */
99
102
  reload(): Promise<ConfigLoadResult>;
100
103
  /**
@@ -140,7 +143,9 @@ export declare class ConfigLoader {
140
143
  * source overwrites, so among equal priorities the last-applied
141
144
  * (last-registered) source wins.
142
145
  *
143
- * Keys listed in the result's `sensitiveKeys` are marked sensitive.
146
+ * Keys listed in the result's `sensitiveKeys` are marked sensitive, as
147
+ * is any key or value that `isSensitiveConfigEntry` flags, whatever the
148
+ * source type.
144
149
  * Once an entry is sensitive it stays sensitive even when a later
145
150
  * source overwrites its value.
146
151
  */
@@ -1,6 +1,8 @@
1
1
  import { cloneConfigValue } from "../configValue/configValue.core.js";
2
2
  import { createConfigEntry } from "../configEntry/configEntry.type.js";
3
- import { loadConfigSourceStrict, sortConfigSources, } from "../configSource/configSource.core.js";
3
+ import { deduplicateConfigSources, loadConfigSourceStrict, sortConfigSources, } from "../configSource/configSource.core.js";
4
+ import { isSensitiveConfigEntry } from "../configSource/configSource.sensitive.js";
5
+ import { commitReloadStaging, createReloadStaging, } from "./configLoader.reload.js";
4
6
  import { createConfigStore } from "../configStore/configStore.factory.js";
5
7
  /**
6
8
  * Configuration loader.
@@ -21,7 +23,11 @@ export class ConfigLoader {
21
23
  disposed = false;
22
24
  lastResult;
23
25
  constructor(options = {}) {
24
- this.sources = [...(options.sources ?? [])];
26
+ // addSource() rejects a name that is already registered, but the
27
+ // constructor used to accept duplicates silently — and because a
28
+ // later duplicate overwrote the earlier one, the LAST occurrence
29
+ // won, the opposite of the documented "first occurrence wins".
30
+ this.sources = [...deduplicateConfigSources(options.sources ?? [])];
25
31
  this.context = options.context ?? {};
26
32
  this.store =
27
33
  options.store ??
@@ -128,12 +134,47 @@ export class ConfigLoader {
128
134
  /**
129
135
  * Reloads configuration.
130
136
  *
131
- * The current store is replaced with freshly loaded values.
137
+ * The current store is replaced with freshly loaded values. When the
138
+ * loader layers onto a shared store (`clearStore: false`), values that
139
+ * sources no longer provide are dropped, runtime and initial values are
140
+ * kept, and the store is only updated once every source has loaded.
132
141
  */
133
142
  async reload() {
134
143
  this.assertActive();
135
144
  this.loaded = false;
136
- return this.load();
145
+ if (this.clearStore) {
146
+ return this.load();
147
+ }
148
+ // Layered stores (clearStore: false) are rebuilt off to the side and
149
+ // committed only once every source has loaded: see
150
+ // configLoader.reload.ts.
151
+ const staging = createReloadStaging(this.store, new Set(this.sources.map((source) => source.name)), this.freeze);
152
+ const stagingLoader = new ConfigLoader({
153
+ sources: this.sources,
154
+ context: this.context,
155
+ store: staging,
156
+ clearStore: false,
157
+ freeze: this.freeze,
158
+ onSourceLoaded: this.onSourceLoaded,
159
+ onSourceError: this.onSourceError,
160
+ });
161
+ this.loading = true;
162
+ try {
163
+ const staged = await stagingLoader.load();
164
+ commitReloadStaging(this.store, staging);
165
+ const result = {
166
+ ...staged,
167
+ store: this.store,
168
+ entries: this.store.getEntries(),
169
+ };
170
+ this.lastResult = result;
171
+ this.loaded = true;
172
+ return result;
173
+ }
174
+ finally {
175
+ this.loading = false;
176
+ stagingLoader.detach();
177
+ }
137
178
  }
138
179
  /**
139
180
  * Loads only selected sources.
@@ -232,7 +273,9 @@ export class ConfigLoader {
232
273
  * source overwrites, so among equal priorities the last-applied
233
274
  * (last-registered) source wins.
234
275
  *
235
- * Keys listed in the result's `sensitiveKeys` are marked sensitive.
276
+ * Keys listed in the result's `sensitiveKeys` are marked sensitive, as
277
+ * is any key or value that `isSensitiveConfigEntry` flags, whatever the
278
+ * source type.
236
279
  * Once an entry is sensitive it stays sensitive even when a later
237
280
  * source overwrites its value.
238
281
  */
@@ -248,7 +291,9 @@ export class ConfigLoader {
248
291
  source: source.name,
249
292
  sourceType: source.type,
250
293
  priority,
251
- sensitive: sensitiveKeys.has(key) || (existing?.sensitive ?? false),
294
+ sensitive: sensitiveKeys.has(key) ||
295
+ (existing?.sensitive ?? false) ||
296
+ isSensitiveConfigEntry(key, value),
252
297
  resolved: true,
253
298
  });
254
299
  }
@@ -302,7 +347,7 @@ export function sourceResultsToEntries(results, sources) {
302
347
  source: result.source,
303
348
  sourceType: result.type,
304
349
  priority: source?.priority ?? 0,
305
- sensitive: sensitiveKeys.has(key),
350
+ sensitive: sensitiveKeys.has(key) || isSensitiveConfigEntry(key, value),
306
351
  }));
307
352
  }
308
353
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Atomic reload support for the configuration loader.
3
+ *
4
+ * Reloading used to re-apply sources on top of the live store without
5
+ * clearing it. A value that a higher-priority source stopped providing
6
+ * then stayed forever, because its entry still carried the higher
7
+ * priority and no lower source could replace it; and a source failing
8
+ * partway left the store half-applied.
9
+ */
10
+ import type { ConfigStore } from "../configStore/configStore.core.js";
11
+ /**
12
+ * Creates a staging store holding only the entries no source owns
13
+ * (runtime writes, initial values).
14
+ */
15
+ export declare function createReloadStaging(store: ConfigStore, sourceNames: ReadonlySet<string>, freeze: boolean): ConfigStore;
16
+ /**
17
+ * Copies a fully loaded staging store into the live store: keys the
18
+ * reload no longer produces are removed, the rest are written. An entry
19
+ * that was sensitive before the reload stays sensitive.
20
+ */
21
+ export declare function commitReloadStaging(store: ConfigStore, staging: ConfigStore): void;
22
+ //# sourceMappingURL=configLoader.reload.d.ts.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Atomic reload support for the configuration loader.
3
+ *
4
+ * Reloading used to re-apply sources on top of the live store without
5
+ * clearing it. A value that a higher-priority source stopped providing
6
+ * then stayed forever, because its entry still carried the higher
7
+ * priority and no lower source could replace it; and a source failing
8
+ * partway left the store half-applied.
9
+ */
10
+ import { createConfigStore } from "../configStore/configStore.factory.js";
11
+ /**
12
+ * Whether an entry was written by one of the loader's sources (and so must
13
+ * be re-derived on reload) rather than by `set()` or `initialValues`.
14
+ */
15
+ function isSourceEntry(entry, sourceNames) {
16
+ return sourceNames.has(entry.source);
17
+ }
18
+ /**
19
+ * Creates a staging store holding only the entries no source owns
20
+ * (runtime writes, initial values).
21
+ */
22
+ export function createReloadStaging(store, sourceNames, freeze) {
23
+ const staging = createConfigStore({ freeze });
24
+ for (const entry of store.getEntries()) {
25
+ if (!isSourceEntry(entry, sourceNames))
26
+ staging.setEntry(entry);
27
+ }
28
+ return staging;
29
+ }
30
+ /**
31
+ * Copies a fully loaded staging store into the live store: keys the
32
+ * reload no longer produces are removed, the rest are written. An entry
33
+ * that was sensitive before the reload stays sensitive.
34
+ */
35
+ export function commitReloadStaging(store, staging) {
36
+ const wasSensitive = new Set(store
37
+ .getEntries()
38
+ .filter((entry) => entry.sensitive)
39
+ .map((entry) => entry.key));
40
+ for (const key of store.keys()) {
41
+ if (!staging.has(key))
42
+ store.delete(key);
43
+ }
44
+ for (const entry of staging.getEntries()) {
45
+ store.setEntry(wasSensitive.has(entry.key) ? { ...entry, sensitive: true } : entry);
46
+ }
47
+ }
48
+ //# sourceMappingURL=configLoader.reload.js.map
@@ -6,6 +6,7 @@ import { createConfigStore } from "../configStore/configStore.factory.js";
6
6
  import { createConfigResolver } from "../configResolver/core/configResolver.factory.js";
7
7
  import { ConfigManagerState } from "./configManager.type.js";
8
8
  import { ConfigManagerValidationError } from "./configManager.error.js";
9
+ import { collectSecretPaths, markSecretEntries, } from "./configManager.secrets.js";
9
10
  /**
10
11
  * Central configuration lifecycle manager.
11
12
  *
@@ -37,15 +38,21 @@ export class ConfigManager {
37
38
  this.store =
38
39
  suppliedStore ??
39
40
  createConfigStore({
40
- initialValues: options.initialValues,
41
41
  freeze: options.freeze ?? true,
42
42
  });
43
43
  // initialValues used to be wired ONLY into a store the manager
44
44
  // created itself: passing `store` or `loader` silently discarded
45
- // them. Seed a supplied store explicitly instead.
46
- if (suppliedStore && options.initialValues) {
45
+ // them. Both paths are seeded here instead, at the baseline
46
+ // priority 0. A source that declares no priority now ranks below
47
+ // that (DEFAULT_CONFIG_SOURCE_PRIORITY), so it can no longer wipe
48
+ // the seeds; a source that declares a priority of 0 or above
49
+ // still overrides them.
50
+ if (options.initialValues) {
47
51
  for (const [key, value] of Object.entries(options.initialValues)) {
48
- suppliedStore.set(key, value, { source: "initialValues" });
52
+ this.store.set(key, value, {
53
+ source: "initialValues",
54
+ priority: 0,
55
+ });
49
56
  }
50
57
  }
51
58
  this.name = options.name ?? "config";
@@ -211,23 +218,9 @@ export class ConfigManager {
211
218
  if (!result.valid) {
212
219
  throw new ConfigManagerValidationError(result.issues);
213
220
  }
214
- // Property schemas flagged `secret` mark the corresponding store
215
- // entries as sensitive so safe serialization redacts them.
216
- for (const [key, propertySchema] of Object.entries(schema.properties)) {
217
- if (!propertySchema.secret) {
218
- continue;
219
- }
220
- const entry = this.store.getEntry(key);
221
- if (entry && !entry.sensitive) {
222
- this.store.set(key, entry.value, {
223
- source: entry.source,
224
- sourceType: entry.sourceType,
225
- priority: entry.priority,
226
- sensitive: true,
227
- resolved: entry.resolved,
228
- });
229
- }
230
- }
221
+ // Schemas flagged `secret` at any depth mark the store entries that
222
+ // hold them as sensitive, so safe serialization redacts them.
223
+ markSecretEntries(this.store, collectSecretPaths(schema.properties));
231
224
  return cloneConfigValue(result.value);
232
225
  }
233
226
  /**
@@ -308,7 +301,10 @@ export class ConfigManager {
308
301
  return this.store.set(key, value, {
309
302
  source: options.source ?? "runtime",
310
303
  priority: options.priority ?? Number.MAX_SAFE_INTEGER,
311
- sensitive: options.sensitive ?? false,
304
+ // Left undefined so the store's own secret detection runs; an
305
+ // explicit `false` here would have disabled it for every runtime
306
+ // write.
307
+ sensitive: options.sensitive,
312
308
  });
313
309
  }
314
310
  /**
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Secret propagation from a validation schema onto store entries.
3
+ *
4
+ * `validate()` used to look only at the top level of `schema.properties`,
5
+ * so `db.properties.password.secret` — or a `"db.password"` property when
6
+ * the source supplied a nested `db` object — never marked anything
7
+ * sensitive, and `toSafeObject()` printed the password.
8
+ */
9
+ import type { AnyConfigSchema } from "../configSchema/index.js";
10
+ import type { ConfigStore } from "../configStore/configStore.core.js";
11
+ /**
12
+ * Every path (as dotted-key segments) at which a schema declares
13
+ * `secret: true`, including inside nested object and array schemas.
14
+ * Array items contribute the path of the array itself.
15
+ */
16
+ export declare function collectSecretPaths(properties: Readonly<Record<string, AnyConfigSchema>>): string[][];
17
+ /**
18
+ * Marks sensitive every store entry that holds a secret path.
19
+ *
20
+ * A path is owned by the entry whose key is its longest dotted prefix, so
21
+ * both a flat `db.password` entry and a nested `db` object entry are found.
22
+ * When the secret sits inside an entry's value the whole entry is marked,
23
+ * since redaction works per entry. A path with no owning entry holds no
24
+ * value, so there is nothing to redact.
25
+ */
26
+ export declare function markSecretEntries(store: ConfigStore, paths: readonly (readonly string[])[]): void;
27
+ //# sourceMappingURL=configManager.secrets.d.ts.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Secret propagation from a validation schema onto store entries.
3
+ *
4
+ * `validate()` used to look only at the top level of `schema.properties`,
5
+ * so `db.properties.password.secret` — or a `"db.password"` property when
6
+ * the source supplied a nested `db` object — never marked anything
7
+ * sensitive, and `toSafeObject()` printed the password.
8
+ */
9
+ /** Nesting bound for the schema walk. */
10
+ const MAX_SCHEMA_DEPTH = 32;
11
+ /**
12
+ * Every path (as dotted-key segments) at which a schema declares
13
+ * `secret: true`, including inside nested object and array schemas.
14
+ * Array items contribute the path of the array itself.
15
+ */
16
+ export function collectSecretPaths(properties) {
17
+ const paths = [];
18
+ const walk = (schema, path, depth) => {
19
+ if (schema.secret) {
20
+ paths.push(path);
21
+ return;
22
+ }
23
+ if (depth >= MAX_SCHEMA_DEPTH)
24
+ return;
25
+ const nested = schema;
26
+ for (const [key, child] of Object.entries(nested.properties ?? {})) {
27
+ walk(child, [...path, ...key.split(".")], depth + 1);
28
+ }
29
+ if (nested.items)
30
+ walk(nested.items, path, depth + 1);
31
+ if (typeof nested.additionalProperties === "object" &&
32
+ nested.additionalProperties.secret) {
33
+ paths.push(path);
34
+ }
35
+ };
36
+ for (const [key, schema] of Object.entries(properties)) {
37
+ walk(schema, key.split("."), 1);
38
+ }
39
+ return paths;
40
+ }
41
+ /**
42
+ * Marks sensitive every store entry that holds a secret path.
43
+ *
44
+ * A path is owned by the entry whose key is its longest dotted prefix, so
45
+ * both a flat `db.password` entry and a nested `db` object entry are found.
46
+ * When the secret sits inside an entry's value the whole entry is marked,
47
+ * since redaction works per entry. A path with no owning entry holds no
48
+ * value, so there is nothing to redact.
49
+ */
50
+ export function markSecretEntries(store, paths) {
51
+ for (const path of paths) {
52
+ for (let length = path.length; length > 0; length--) {
53
+ const entry = store.getEntry(path.slice(0, length).join("."));
54
+ if (!entry)
55
+ continue;
56
+ if (!entry.sensitive) {
57
+ store.set(entry.key, entry.value, {
58
+ source: entry.source,
59
+ sourceType: entry.sourceType,
60
+ priority: entry.priority,
61
+ sensitive: true,
62
+ resolved: entry.resolved,
63
+ });
64
+ }
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ //# sourceMappingURL=configManager.secrets.js.map
@@ -77,6 +77,16 @@ export interface ConfigSourceOptions {
77
77
  * Checks whether a value is a configuration source.
78
78
  */
79
79
  export declare function isConfigSource(value: unknown): value is ConfigSource;
80
+ /**
81
+ * Priority given to a source that declares none.
82
+ *
83
+ * Strictly below the priority `initialValues` and other baseline store
84
+ * writes use (0). A source used to default to 0 as well, and because
85
+ * `applySource` overwrites on EQUAL priority, any undeclared source
86
+ * silently wiped the values a manager was seeded with. Declaring a
87
+ * priority of 0 or above still overrides them.
88
+ */
89
+ export declare const DEFAULT_CONFIG_SOURCE_PRIORITY = -1;
80
90
  /**
81
91
  * Creates a function-based configuration source.
82
92
  */
@@ -32,12 +32,22 @@ export function isConfigSource(value) {
32
32
  typeof source.optional === "boolean" &&
33
33
  typeof source.load === "function");
34
34
  }
35
+ /**
36
+ * Priority given to a source that declares none.
37
+ *
38
+ * Strictly below the priority `initialValues` and other baseline store
39
+ * writes use (0). A source used to default to 0 as well, and because
40
+ * `applySource` overwrites on EQUAL priority, any undeclared source
41
+ * silently wiped the values a manager was seeded with. Declaring a
42
+ * priority of 0 or above still overrides them.
43
+ */
44
+ export const DEFAULT_CONFIG_SOURCE_PRIORITY = -1;
35
45
  /**
36
46
  * Creates a function-based configuration source.
37
47
  */
38
48
  export function createConfigSource(options, loader) {
39
49
  const type = options.type ?? ConfigSourceType.CUSTOM;
40
- const priority = options.priority ?? 0;
50
+ const priority = options.priority ?? DEFAULT_CONFIG_SOURCE_PRIORITY;
41
51
  if (options.name.trim().length === 0) {
42
52
  throw new TypeError("Configuration source name cannot be empty.");
43
53
  }
@@ -42,11 +42,13 @@ export interface EnvironmentConfigSourceOptions {
42
42
  readonly keyMapper?: (name: string) => string;
43
43
  /**
44
44
  * Decides whether a variable holds a secret. Receives the ORIGINAL
45
- * variable name and the derived configuration key. Defaults to a
46
- * name pattern covering passwords, tokens, API keys and credentials.
45
+ * variable name and the derived configuration key. Defaults to
46
+ * `isSensitiveConfigKey` (passwords, tokens, API/private keys,
47
+ * credentials, DSNs, database URLs, `*_KEY`).
47
48
  *
48
- * Pass `() => false` to opt out — values are then NOT redacted by
49
- * safe serialization.
49
+ * Returning false does not un-redact: the loader still marks keys and
50
+ * values that `isSensitiveConfigEntry` flags (for example a URL with
51
+ * embedded `user:password@`).
50
52
  */
51
53
  readonly isSensitive?: (name: string, key: string) => boolean;
52
54
  }
@@ -7,14 +7,7 @@
7
7
  */
8
8
  import { defineConfigProperty } from "../configValue/configValue.core.js";
9
9
  import { ConfigSourceType, createConfigSource } from "./configSource.core.js";
10
- /**
11
- * Environment variable names whose values are treated as secrets.
12
- *
13
- * Matching entries are reported through `sensitiveKeys`, so
14
- * `toSafeObject()` and safe entry serialization redact them instead of
15
- * printing credentials into logs.
16
- */
17
- const DEFAULT_SECRET_PATTERN = /(pass(word)?|secret|token|api[_.-]?key|private[_.-]?key|credential|auth)/i;
10
+ import { isSensitiveConfigKey } from "./configSource.sensitive.js";
18
11
  /**
19
12
  * Maps an environment variable name to a configuration key.
20
13
  */
@@ -37,7 +30,7 @@ export function createEnvironmentConfigSource(options = {}) {
37
30
  const prefix = options.prefix ?? "";
38
31
  const keyMapper = options.keyMapper ?? defaultKeyMapper;
39
32
  const isSensitive = options.isSensitive ??
40
- ((name) => DEFAULT_SECRET_PATTERN.test(name));
33
+ ((name, key) => isSensitiveConfigKey(name) || isSensitiveConfigKey(key));
41
34
  const name = options.name ?? "environment";
42
35
  return createConfigSource({
43
36
  name,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Name- and value-based secret detection shared by every configuration
3
+ * source.
4
+ *
5
+ * Only the environment source used to redact by name, so a `password` or
6
+ * `api_key` supplied by a memory, defaults or custom source was printed in
7
+ * clear by `toSafeObject()`, and even the environment pattern missed
8
+ * `DATABASE_URL`-style connection strings and `*_KEY` names.
9
+ */
10
+ /**
11
+ * Returns whether a configuration key names a secret (passwords, tokens,
12
+ * API/private keys, credentials, DSNs, database URLs, `*_key`).
13
+ */
14
+ export declare function isSensitiveConfigKey(key: string): boolean;
15
+ /**
16
+ * Returns whether a configuration value holds a secret: a string URL with
17
+ * embedded `user:password@` credentials, or a nested object with a key that
18
+ * {@link isSensitiveConfigKey} flags anywhere inside it.
19
+ */
20
+ export declare function isSensitiveConfigValue(value: unknown): boolean;
21
+ /**
22
+ * Whether an entry loaded under `key` with `value` must be marked
23
+ * sensitive.
24
+ */
25
+ export declare function isSensitiveConfigEntry(key: string, value: unknown): boolean;
26
+ //# sourceMappingURL=configSource.sensitive.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Name- and value-based secret detection shared by every configuration
3
+ * source.
4
+ *
5
+ * Only the environment source used to redact by name, so a `password` or
6
+ * `api_key` supplied by a memory, defaults or custom source was printed in
7
+ * clear by `toSafeObject()`, and even the environment pattern missed
8
+ * `DATABASE_URL`-style connection strings and `*_KEY` names.
9
+ */
10
+ /**
11
+ * Key names treated as secrets. Matched case-insensitively against the
12
+ * whole dotted key, so any path segment can trigger it.
13
+ */
14
+ const SENSITIVE_KEY_PATTERN = /(pass(word|wd)?|secret|token|api[_.-]?key|private[_.-]?key|credential|auth|dsn|database[_.-]?url|connection[_.-]?string|(^|[_.-])key$|(^|[_.-])key[_.-])/i;
15
+ /** `scheme://user:password@host` — a URL with embedded credentials. */
16
+ const CREDENTIAL_URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\/[^\s/@:]*:[^\s/@]*@/i;
17
+ /** Nesting bound for {@link isSensitiveConfigValue}. */
18
+ const MAX_SCAN_DEPTH = 32;
19
+ /**
20
+ * Returns whether a configuration key names a secret (passwords, tokens,
21
+ * API/private keys, credentials, DSNs, database URLs, `*_key`).
22
+ */
23
+ export function isSensitiveConfigKey(key) {
24
+ return SENSITIVE_KEY_PATTERN.test(key);
25
+ }
26
+ /**
27
+ * Returns whether a configuration value holds a secret: a string URL with
28
+ * embedded `user:password@` credentials, or a nested object with a key that
29
+ * {@link isSensitiveConfigKey} flags anywhere inside it.
30
+ */
31
+ export function isSensitiveConfigValue(value) {
32
+ const pending = [
33
+ { value, depth: 0 },
34
+ ];
35
+ while (pending.length > 0) {
36
+ const next = pending.pop();
37
+ const current = next.value;
38
+ if (typeof current === "string") {
39
+ if (CREDENTIAL_URL_PATTERN.test(current))
40
+ return true;
41
+ continue;
42
+ }
43
+ if (typeof current !== "object" || current === null)
44
+ continue;
45
+ if (next.depth >= MAX_SCAN_DEPTH || current instanceof Date)
46
+ continue;
47
+ const entries = Array.isArray(current)
48
+ ? current.map((item, index) => [String(index), item])
49
+ : Object.entries(current);
50
+ for (const [key, child] of entries) {
51
+ if (!Array.isArray(current) && isSensitiveConfigKey(key))
52
+ return true;
53
+ pending.push({ value: child, depth: next.depth + 1 });
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ /**
59
+ * Whether an entry loaded under `key` with `value` must be marked
60
+ * sensitive.
61
+ */
62
+ export function isSensitiveConfigEntry(key, value) {
63
+ return isSensitiveConfigKey(key) || isSensitiveConfigValue(value);
64
+ }
65
+ //# sourceMappingURL=configSource.sensitive.js.map
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export * from "./configSource.core.js";
7
7
  export * from "./configSource.environment.js";
8
+ export * from "./configSource.sensitive.js";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export * from "./configSource.core.js";
7
7
  export * from "./configSource.environment.js";
8
+ export * from "./configSource.sensitive.js";
8
9
  //# sourceMappingURL=index.js.map
@@ -1,5 +1,6 @@
1
1
  import { cloneConfigValue, configValuesEqual, defineConfigProperty, freezeConfigValue, } from "../configValue/configValue.core.js";
2
2
  import { createConfigEntry, toSafeConfigEntry, } from "../configEntry/configEntry.type.js";
3
+ import { isSensitiveConfigEntry } from "../configSource/configSource.sensitive.js";
3
4
  /**
4
5
  * Normalizes a configuration key.
5
6
  */
@@ -97,7 +98,12 @@ export class ConfigStore {
97
98
  source: options.source ?? "runtime",
98
99
  sourceType: options.sourceType,
99
100
  priority: options.priority ?? 0,
100
- sensitive: options.sensitive ?? false,
101
+ // Secret detection belongs HERE, not at a single caller. It used
102
+ // to run only inside ConfigLoader.applySource, so the very same
103
+ // key was redacted when it arrived from a source and printed in
104
+ // clear when it arrived through initialValues, set(), setMany()
105
+ // or replace(). Pass `sensitive: false` explicitly to opt out.
106
+ sensitive: options.sensitive ?? isSensitiveConfigEntry(normalizedKey, value),
101
107
  resolved: options.resolved ?? true,
102
108
  });
103
109
  if (previous &&
@@ -30,6 +30,18 @@ export type ResolvedConfigValue = string | number | boolean | bigint | null | un
30
30
  };
31
31
  /**
32
32
  * Checks whether an object key is unsafe to copy onto plain objects.
33
+ *
34
+ * Assigning these keys (most notably "__proto__") on a plain object
35
+ * mutates its prototype instead of creating an own property, which
36
+ * enables prototype-pollution attacks via untrusted configuration
37
+ * payloads such as JSON.parse output. The key set is
38
+ * `SCHEMA_FORBIDDEN_KEYS` from `@zudojs/constants`, shared with
39
+ * `@zudojs/schema`, `@zudojs/validation` and `@zudojs/cache`.
40
+ *
41
+ * The package itself never needs this check to stay safe: every key
42
+ * write goes through {@link defineConfigProperty}, which keeps such keys
43
+ * as inert own properties. Use it when copying configuration into code
44
+ * that assigns with `target[key] = value` or `Object.assign`.
33
45
  */
34
46
  export declare function isUnsafeConfigKey(key: string): boolean;
35
47
  /**
@@ -72,7 +84,8 @@ export declare function parseConfigString(value: string | undefined): ConfigPrim
72
84
  */
73
85
  export declare function parseConfigBoolean(value: string | boolean | undefined): boolean | undefined;
74
86
  /**
75
- * Parses a numeric configuration value.
87
+ * Parses a numeric configuration value. Only decimal notation is
88
+ * accepted; hex, binary and octal strings return `undefined`.
76
89
  */
77
90
  export declare function parseConfigNumber(value: string | number | undefined): number | undefined;
78
91
  /**
@@ -6,24 +6,24 @@
6
6
  * possible, while allowing common runtime values such as undefined,
7
7
  * Date, and Buffer-like binary data to be handled safely.
8
8
  */
9
+ import { SCHEMA_FORBIDDEN_KEYS } from "@zudojs/constants";
9
10
  /**
10
- * Object keys that must never be copied onto plain objects.
11
+ * Checks whether an object key is unsafe to copy onto plain objects.
11
12
  *
12
13
  * Assigning these keys (most notably "__proto__") on a plain object
13
14
  * mutates its prototype instead of creating an own property, which
14
15
  * enables prototype-pollution attacks via untrusted configuration
15
- * payloads such as JSON.parse output.
16
- */
17
- const UNSAFE_CONFIG_KEYS = new Set([
18
- "__proto__",
19
- "constructor",
20
- "prototype",
21
- ]);
22
- /**
23
- * Checks whether an object key is unsafe to copy onto plain objects.
16
+ * payloads such as JSON.parse output. The key set is
17
+ * `SCHEMA_FORBIDDEN_KEYS` from `@zudojs/constants`, shared with
18
+ * `@zudojs/schema`, `@zudojs/validation` and `@zudojs/cache`.
19
+ *
20
+ * The package itself never needs this check to stay safe: every key
21
+ * write goes through {@link defineConfigProperty}, which keeps such keys
22
+ * as inert own properties. Use it when copying configuration into code
23
+ * that assigns with `target[key] = value` or `Object.assign`.
24
24
  */
25
25
  export function isUnsafeConfigKey(key) {
26
- return UNSAFE_CONFIG_KEYS.has(key);
26
+ return SCHEMA_FORBIDDEN_KEYS.has(key);
27
27
  }
28
28
  /**
29
29
  * Checks whether a value is a configuration primitive.
@@ -103,8 +103,10 @@ export function toConfigJsonValue(value) {
103
103
  const result = {};
104
104
  for (const [key, child] of Object.entries(value)) {
105
105
  const converted = toConfigJsonValue(child);
106
+ // Defined, never assigned: an own "__proto__" key from a JSON or
107
+ // remote source would otherwise replace the result's prototype.
106
108
  if (converted !== undefined) {
107
- result[key] = converted;
109
+ defineConfigProperty(result, key, converted);
108
110
  }
109
111
  }
110
112
  return result;
@@ -184,8 +186,11 @@ export function parseConfigBoolean(value) {
184
186
  return undefined;
185
187
  }
186
188
  }
189
+ /** Optionally signed decimal number, with optional fraction and exponent. */
190
+ const DECIMAL_NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
187
191
  /**
188
- * Parses a numeric configuration value.
192
+ * Parses a numeric configuration value. Only decimal notation is
193
+ * accepted; hex, binary and octal strings return `undefined`.
189
194
  */
190
195
  export function parseConfigNumber(value) {
191
196
  if (value === undefined) {
@@ -198,6 +203,11 @@ export function parseConfigNumber(value) {
198
203
  if (normalized.length === 0) {
199
204
  return undefined;
200
205
  }
206
+ // Decimal only. `Number()` also accepts "0x1F90", "0b11" and "0o17",
207
+ // so a port of "0x1F90" silently became 8080.
208
+ if (!DECIMAL_NUMBER.test(normalized)) {
209
+ return undefined;
210
+ }
201
211
  const parsed = Number(normalized);
202
212
  return Number.isFinite(parsed) ? parsed : undefined;
203
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/config",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Layered configuration management with multiple sources, validation, and environment-specific overrides.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -28,7 +28,8 @@
28
28
  "node": ">=24.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@zudojs/errors": "1.0.1"
31
+ "@zudojs/constants": "1.1.1",
32
+ "@zudojs/errors": "1.2.0"
32
33
  },
33
34
  "devDependencies": {
34
35
  "typescript": "7.0.2",