@zudojs/config 1.0.0 → 1.1.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
@@ -58,8 +64,8 @@ priorities are applied in registration order (last registered wins).
58
64
  - `createEnvironmentConfigSource({ prefix, env?, priority?, keyMapper?, isSensitive? })`
59
65
  — reads environment variables. Values stay RAW STRINGS (use the typed
60
66
  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()`.
67
+ string). Names matching `isSensitiveConfigKey` are reported as
68
+ sensitive and therefore redacted by `toSafeObject()`.
63
69
  - `createCustomConfigSource(name, loader, options?)` — async loader
64
70
  function returning `{ values, source, type }`. Include
65
71
  `sensitiveKeys: ["some.key"]` in the result to mark values as
@@ -83,6 +89,13 @@ const remote = createCustomConfigSource("vault", async () => ({
83
89
  }));
84
90
  ```
85
91
 
92
+ Whatever the source, the loader also marks an entry sensitive when
93
+ `isSensitiveConfigEntry(key, value)` flags it: a key naming a password,
94
+ secret, token, API/private key, credential, DSN, database URL or `*_key`
95
+ (in any dotted segment, case-insensitive), a nested object containing such
96
+ a key, or a URL with embedded `user:password@` credentials. Returning
97
+ `false` from `isSensitive` or omitting `sensitiveKeys` cannot un-mark these.
98
+
86
99
  ## Typed access
87
100
 
88
101
  `ConfigManager` (and the underlying `ConfigResolver`) offer typed
@@ -95,6 +108,9 @@ const db = manager.scoped("db");
95
108
  db.string("host", "localhost");
96
109
  ```
97
110
 
111
+ `number()` accepts decimal notation only: `"0x1F90"`, `"0b11"` and `"0o17"`
112
+ are rejected rather than silently becoming 8080, 3 and 15.
113
+
98
114
  ## Schema validation
99
115
 
100
116
  Schemas are plain objects validated by this package (no external
@@ -118,7 +134,10 @@ so nested constraints are enforced by `validate()`, `resolve()` and the
118
134
  standalone `validateConfigValue`.
119
135
 
120
136
  `validate()` throws on failure and marks entries whose schema has
121
- `secret: true` as sensitive. Individual values can be validated with
137
+ `secret: true` as sensitive, at any depth: a secret declared inside a
138
+ nested object schema, or as a dotted key such as `"db.password"`, marks
139
+ the store entry that holds it (for a nested `db` object, the whole `db`
140
+ entry is redacted). Individual values can be validated with
122
141
  `manager.resolve(key, schema)` or the standalone
123
142
  `validateConfigValue` / `validateConfigObject` functions.
124
143
 
@@ -133,7 +152,12 @@ standalone `validateConfigValue`.
133
152
 
134
153
  `manager.reload()` re-runs all sources. Values set at runtime via
135
154
  `manager.set()` and `initialValues` survive loads; sources only
136
- overwrite entries at equal or higher priority.
155
+ overwrite entries at equal or higher priority. A reload rebuilds every
156
+ source-provided value from scratch, so a value a source no longer
157
+ provides disappears (letting a lower-priority default show through), and
158
+ the store is only updated once every source has loaded — a failing
159
+ source leaves the previous configuration intact. An entry that was
160
+ sensitive before a reload stays sensitive.
137
161
 
138
162
  ## Untrusted input
139
163
 
@@ -141,7 +165,8 @@ Configuration frequently originates from files, environment variables
141
165
  or remote services. Keys such as `__proto__`, `constructor` and
142
166
  `prototype` are copied with `Object.defineProperty`, never plain
143
167
  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
168
+ never replace an object's prototype (this includes
169
+ `toConfigJsonValue` and `configValueToString`). Schema properties are read as OWN
145
170
  properties, so a schema property named `constructor` is reported as
146
171
  missing rather than matching an inherited function.
147
172
 
@@ -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
3
  import { 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.
@@ -128,12 +130,47 @@ export class ConfigLoader {
128
130
  /**
129
131
  * Reloads configuration.
130
132
  *
131
- * The current store is replaced with freshly loaded values.
133
+ * The current store is replaced with freshly loaded values. When the
134
+ * loader layers onto a shared store (`clearStore: false`), values that
135
+ * sources no longer provide are dropped, runtime and initial values are
136
+ * kept, and the store is only updated once every source has loaded.
132
137
  */
133
138
  async reload() {
134
139
  this.assertActive();
135
140
  this.loaded = false;
136
- return this.load();
141
+ if (this.clearStore) {
142
+ return this.load();
143
+ }
144
+ // Layered stores (clearStore: false) are rebuilt off to the side and
145
+ // committed only once every source has loaded: see
146
+ // configLoader.reload.ts.
147
+ const staging = createReloadStaging(this.store, new Set(this.sources.map((source) => source.name)), this.freeze);
148
+ const stagingLoader = new ConfigLoader({
149
+ sources: this.sources,
150
+ context: this.context,
151
+ store: staging,
152
+ clearStore: false,
153
+ freeze: this.freeze,
154
+ onSourceLoaded: this.onSourceLoaded,
155
+ onSourceError: this.onSourceError,
156
+ });
157
+ this.loading = true;
158
+ try {
159
+ const staged = await stagingLoader.load();
160
+ commitReloadStaging(this.store, staging);
161
+ const result = {
162
+ ...staged,
163
+ store: this.store,
164
+ entries: this.store.getEntries(),
165
+ };
166
+ this.lastResult = result;
167
+ this.loaded = true;
168
+ return result;
169
+ }
170
+ finally {
171
+ this.loading = false;
172
+ stagingLoader.detach();
173
+ }
137
174
  }
138
175
  /**
139
176
  * Loads only selected sources.
@@ -232,7 +269,9 @@ export class ConfigLoader {
232
269
  * source overwrites, so among equal priorities the last-applied
233
270
  * (last-registered) source wins.
234
271
  *
235
- * Keys listed in the result's `sensitiveKeys` are marked sensitive.
272
+ * Keys listed in the result's `sensitiveKeys` are marked sensitive, as
273
+ * is any key or value that `isSensitiveConfigEntry` flags, whatever the
274
+ * source type.
236
275
  * Once an entry is sensitive it stays sensitive even when a later
237
276
  * source overwrites its value.
238
277
  */
@@ -248,7 +287,9 @@ export class ConfigLoader {
248
287
  source: source.name,
249
288
  sourceType: source.type,
250
289
  priority,
251
- sensitive: sensitiveKeys.has(key) || (existing?.sensitive ?? false),
290
+ sensitive: sensitiveKeys.has(key) ||
291
+ (existing?.sensitive ?? false) ||
292
+ isSensitiveConfigEntry(key, value),
252
293
  resolved: true,
253
294
  });
254
295
  }
@@ -302,7 +343,7 @@ export function sourceResultsToEntries(results, sources) {
302
343
  source: result.source,
303
344
  sourceType: result.type,
304
345
  priority: source?.priority ?? 0,
305
- sensitive: sensitiveKeys.has(key),
346
+ sensitive: sensitiveKeys.has(key) || isSensitiveConfigEntry(key, value),
306
347
  }));
307
348
  }
308
349
  }
@@ -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
  *
@@ -211,23 +212,9 @@ export class ConfigManager {
211
212
  if (!result.valid) {
212
213
  throw new ConfigManagerValidationError(result.issues);
213
214
  }
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
- }
215
+ // Schemas flagged `secret` at any depth mark the store entries that
216
+ // hold them as sensitive, so safe serialization redacts them.
217
+ markSecretEntries(this.store, collectSecretPaths(schema.properties));
231
218
  return cloneConfigValue(result.value);
232
219
  }
233
220
  /**
@@ -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
@@ -124,8 +124,26 @@ function appendCustomValidationResult(result, path, issues) {
124
124
  }
125
125
  issues.push(result);
126
126
  }
127
+ /**
128
+ * Compiles a string schema pattern for a one-shot `test`.
129
+ *
130
+ * A RegExp carrying the `g` or `y` flag keeps `lastIndex` between calls, so
131
+ * the same schema object alternated between accepting and rejecting an
132
+ * identical value on successive validations. Both flags are dropped; they
133
+ * have no meaning for a whole-value match.
134
+ */
135
+ function compilePattern(pattern) {
136
+ if (typeof pattern === "string") {
137
+ return new RegExp(pattern);
138
+ }
139
+ const flags = pattern.flags.replace(/[gy]/g, "");
140
+ return flags === pattern.flags ? pattern : new RegExp(pattern.source, flags);
141
+ }
127
142
  /**
128
143
  * Built-in schema validation rules.
144
+ *
145
+ * Returns a replacement value when a rule rewrote it (an array whose item
146
+ * schema applied `transform` or `default`), otherwise undefined.
129
147
  */
130
148
  function validateBuiltInRules(value, schema, context, issues) {
131
149
  if (typeof value === "string") {
@@ -139,9 +157,7 @@ function validateBuiltInRules(value, schema, context, issues) {
139
157
  issues.push(createConfigValidationIssue(context.path, `Value must contain at most ${stringSchema.maxLength} characters.`, "MAX_LENGTH"));
140
158
  }
141
159
  if (stringSchema.pattern) {
142
- const pattern = typeof stringSchema.pattern === "string"
143
- ? new RegExp(stringSchema.pattern)
144
- : stringSchema.pattern;
160
+ const pattern = compilePattern(stringSchema.pattern);
145
161
  if (!pattern.test(value)) {
146
162
  issues.push(createConfigValidationIssue(context.path, "Value does not match the required pattern.", "PATTERN"));
147
163
  }
@@ -176,6 +192,11 @@ function validateBuiltInRules(value, schema, context, issues) {
176
192
  issues.push(createConfigValidationIssue(context.path, `Array must contain at most ${arraySchema.maxItems} items.`, "MAX_ITEMS"));
177
193
  }
178
194
  if (arraySchema.items) {
195
+ // Item results used to be consulted for issues only, so an item
196
+ // schema's `transform` (or `default`) ran and its output was thrown
197
+ // away: the caller got the untransformed array back as "valid".
198
+ const rebuilt = [];
199
+ let allValid = true;
179
200
  value.forEach((item, index) => {
180
201
  const result = validateConfigValue(item, arraySchema.items, {
181
202
  path: `${context.path}[${index}]`,
@@ -184,9 +205,15 @@ function validateBuiltInRules(value, schema, context, issues) {
184
205
  key: index,
185
206
  });
186
207
  issues.push(...result.issues);
208
+ if (!result.valid) {
209
+ allValid = false;
210
+ }
211
+ rebuilt.push(result.value === undefined ? item : result.value);
187
212
  });
213
+ return allValid ? rebuilt : undefined;
188
214
  }
189
215
  }
216
+ return undefined;
190
217
  }
191
218
  /**
192
219
  * Validates a value against a schema.
@@ -239,7 +266,7 @@ export function validateConfigValue(value, schema, context) {
239
266
  issues,
240
267
  };
241
268
  }
242
- validateBuiltInRules(value, schema, validationContext, issues);
269
+ const rewritten = validateBuiltInRules(value, schema, validationContext, issues);
243
270
  // An object schema carries `properties` / `additionalProperties`.
244
271
  // Those were declared on ConfigObjectSchema but read nowhere in this
245
272
  // function, so every nested object schema — including the ones used
@@ -247,7 +274,7 @@ export function validateConfigValue(value, schema, context) {
247
274
  // validation unconditionally. Delegate to validateConfigObject so
248
275
  // nested constraints are actually enforced.
249
276
  const objectSchema = schema;
250
- let base = value;
277
+ let base = rewritten ?? value;
251
278
  if (matchesConfigType(value, ConfigValueType.OBJECT) &&
252
279
  (objectSchema.properties !== undefined ||
253
280
  objectSchema.additionalProperties !== undefined)) {
@@ -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
@@ -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,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/config",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Layered configuration management with multiple sources, validation, and environment-specific overrides.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -24,7 +28,8 @@
24
28
  "node": ">=24.0.0"
25
29
  },
26
30
  "dependencies": {
27
- "@zudojs/errors": "1.0.0"
31
+ "@zudojs/constants": "1.1.0",
32
+ "@zudojs/errors": "1.1.0"
28
33
  },
29
34
  "devDependencies": {
30
35
  "typescript": "7.0.2",