@zudojs/config 1.1.0 → 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
@@ -58,6 +58,16 @@ await `manager.ready()` before reading values.
58
58
  A source provides values plus a priority. Higher priority wins; equal
59
59
  priorities are applied in registration order (last registered wins).
60
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
+
61
71
  - `createDefaultsConfigSource(values, name?)` — low-priority defaults.
62
72
  - `createMemoryConfigSource(values, { name, priority?, optional? })` —
63
73
  in-memory values.
@@ -147,12 +157,21 @@ entry is redacted). Individual values can be validated with
147
157
  including secrets.
148
158
  - `manager.toSafeObject()` / `store.toSafeObject()` replace sensitive
149
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.
150
168
 
151
169
  ## Reloading
152
170
 
153
171
  `manager.reload()` re-runs all sources. Values set at runtime via
154
172
  `manager.set()` and `initialValues` survive loads; sources only
155
- overwrite entries at equal or higher priority. A reload rebuilds every
173
+ overwrite entries at equal or higher priority, and a source that
174
+ declares no priority ranks below both. A reload rebuilds every
156
175
  source-provided value from scratch, so a value a source no longer
157
176
  provides disappears (letting a lower-priority default show through), and
158
177
  the store is only updated once every source has loaded — a failing
@@ -1,6 +1,6 @@
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
4
  import { isSensitiveConfigEntry } from "../configSource/configSource.sensitive.js";
5
5
  import { commitReloadStaging, createReloadStaging, } from "./configLoader.reload.js";
6
6
  import { createConfigStore } from "../configStore/configStore.factory.js";
@@ -23,7 +23,11 @@ export class ConfigLoader {
23
23
  disposed = false;
24
24
  lastResult;
25
25
  constructor(options = {}) {
26
- 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 ?? [])];
27
31
  this.context = options.context ?? {};
28
32
  this.store =
29
33
  options.store ??
@@ -38,15 +38,21 @@ export class ConfigManager {
38
38
  this.store =
39
39
  suppliedStore ??
40
40
  createConfigStore({
41
- initialValues: options.initialValues,
42
41
  freeze: options.freeze ?? true,
43
42
  });
44
43
  // initialValues used to be wired ONLY into a store the manager
45
44
  // created itself: passing `store` or `loader` silently discarded
46
- // them. Seed a supplied store explicitly instead.
47
- 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) {
48
51
  for (const [key, value] of Object.entries(options.initialValues)) {
49
- suppliedStore.set(key, value, { source: "initialValues" });
52
+ this.store.set(key, value, {
53
+ source: "initialValues",
54
+ priority: 0,
55
+ });
50
56
  }
51
57
  }
52
58
  this.name = options.name ?? "config";
@@ -295,7 +301,10 @@ export class ConfigManager {
295
301
  return this.store.set(key, value, {
296
302
  source: options.source ?? "runtime",
297
303
  priority: options.priority ?? Number.MAX_SAFE_INTEGER,
298
- 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,
299
308
  });
300
309
  }
301
310
  /**
@@ -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
  }
@@ -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 &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/config",
3
- "version": "1.1.0",
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,8 +28,8 @@
28
28
  "node": ">=24.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@zudojs/constants": "1.1.0",
32
- "@zudojs/errors": "1.1.0"
31
+ "@zudojs/constants": "1.1.1",
32
+ "@zudojs/errors": "1.2.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "7.0.2",