@blamejs/core 0.7.18 → 0.7.19

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.
Files changed (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. package/sbom.cyclonedx.json +7 -7
@@ -1,642 +1,642 @@
1
- "use strict";
2
- /**
3
- * Security-focused .env loader.
4
- *
5
- * Parses dotenv-style files with the framework's standard guards plus
6
- * change tracking and typo detection — env-var changes are
7
- * operationally significant and the framework records them through the
8
- * audit chain.
9
- *
10
- * The dotenv ecosystem has a long history of subtle footguns: implicit
11
- * `$VAR` expansion that surprises consumers reading process.env later,
12
- * silent overwrite of operator-set values, ambiguous quote handling
13
- * across libraries, comment-vs-value confusion. This loader rejects or
14
- * makes explicit each of those.
15
- *
16
- * Public API:
17
- * env.parse(input, opts?) object | throws SafeEnvError
18
- * env.load(filepath, opts?) { values, diff } | throws
19
- * env.SafeEnvError error class
20
- *
21
- * env.parse returns the bare key/value object — pure, no I/O, no
22
- * audit, no schema. Use it when you have the file content already in
23
- * memory or when you want to compose `.env` parsing into a larger flow.
24
- *
25
- * env.load reads the file via atomicFile.readSync, applies schema
26
- * validation, computes a diff against the previous snapshot, optionally
27
- * applies values to process.env, and writes a system.config.changed
28
- * audit row when the diff is non-empty. Followers in cluster mode see
29
- * their own config; the audit write is leader-gated and is silently
30
- * skipped on followers (their own audit row would have nothing to
31
- * anchor to anyway since they don't write the chain).
32
- *
33
- * What is REJECTED:
34
- * - Tabs at start of value (real spaces are fine; tabs in unquoted
35
- * values are almost always a copy-paste accident)
36
- * - $VAR / ${VAR} expansion (consumers reading process.env later
37
- * would not know whether the value was literal or expanded —
38
- * consistency over convenience). Operators who want expansion
39
- * can do it themselves before passing values to the framework.
40
- * - Multiline unquoted values (you must double-quote a value
41
- * containing newlines). Common dotenv libraries auto-join lines;
42
- * the heuristic regularly produces wrong results.
43
- * - Keys outside `^[A-Z_][A-Z0-9_]*$` by default (POSIX env-var
44
- * shape). Operators can override via `{ keyShape: /…/ }`.
45
- * - Empty key (`=value` with no key)
46
- *
47
- * Defaults:
48
- * maxBytes: 64 KiB (.env files are config; cap is small)
49
- * maxKeys: 1000
50
- * keyShape: /^[A-Z_][A-Z0-9_]*$/
51
- * applyToProcess: false (return values; do not mutate process.env)
52
- * allowOverwrite: false (only relevant when applyToProcess: true)
53
- * rejectUnknown: false (warn on unregistered keys; do not refuse)
54
- * audit: true (record diff in audit chain)
55
- */
56
-
57
- var C = require("../constants");
58
- var atomicFile = require("../atomic-file");
59
- var lazyRequire = require("../lazy-require");
60
- var numericBounds = require("../numeric-bounds");
61
- var safeBuffer = require("../safe-buffer");
62
- var safeJson = require("../safe-json");
63
- var { FrameworkError } = require("../framework-error");
64
- var { boot } = require("../log");
65
-
66
- // Lazy require for audit — eager-loading audit at top of file forms a
67
- // load cycle: vault.init → passphrase-source → safe-env → audit →
68
- // (transitively) vault, which leaves safe-env's module.exports
69
- // half-built when vault first reaches readVar. Defer audit resolution
70
- // until the first emit-driven call.
71
- var auditModule = lazyRequire(function () { return require("../audit"); });
72
-
73
- var log = boot("env");
74
-
75
- // parseInt radix — named so the call site doesn't carry a bare 16
76
- // integer literal that reads as a byte count.
77
- var RADIX_HEX = 0x10;
78
-
79
- class SafeEnvError extends FrameworkError {
80
- constructor(message, code, line) {
81
- super(line != null ? message + " at line " + line : message);
82
- this.name = "SafeEnvError";
83
- this.code = code || "env/invalid";
84
- this.line = line == null ? null : line;
85
- this.isSafeEnvError = true;
86
- }
87
- }
88
-
89
- var DEFAULTS = {
90
- maxBytes: C.BYTES.kib(64),
91
- maxKeys: 1_000,
92
- keyShape: /^[A-Z_][A-Z0-9_]*$/,
93
- applyToProcess: false,
94
- allowOverwrite: false,
95
- rejectUnknown: false,
96
- audit: true,
97
- };
98
-
99
- var POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
100
-
101
- // ---- parse ----
102
-
103
- function parse(input, opts) {
104
- opts = opts || {};
105
- // Validate operator-supplied numeric opts via lib/numeric-bounds —
106
- // Infinity / NaN / negative / non-integer all bypass the `> 0` shape
107
- // and silently lift the DoS cap they were meant to enforce.
108
- if (opts.maxBytes !== undefined && !numericBounds.isPositiveFiniteInt(opts.maxBytes)) {
109
- throw new SafeEnvError("env.parse: maxBytes must be a positive finite integer; got " +
110
- numericBounds.shape(opts.maxBytes), "env/bad-opt");
111
- }
112
- if (opts.maxKeys !== undefined && !numericBounds.isPositiveFiniteInt(opts.maxKeys)) {
113
- throw new SafeEnvError("env.parse: maxKeys must be a positive finite integer; got " +
114
- numericBounds.shape(opts.maxKeys), "env/bad-opt");
115
- }
116
- var maxBytes = opts.maxBytes !== undefined
117
- ? Math.min(opts.maxBytes, C.BYTES.mib(1)) : DEFAULTS.maxBytes;
118
- var maxKeys = opts.maxKeys !== undefined
119
- ? Math.min(opts.maxKeys, 100_000) : DEFAULTS.maxKeys;
120
- var keyShape = opts.keyShape instanceof RegExp ? opts.keyShape : DEFAULTS.keyShape;
121
-
122
- input = safeBuffer.normalizeText(input, {
123
- maxBytes: maxBytes,
124
- errorClass: SafeEnvError,
125
- typeCode: "env/wrong-input-type",
126
- sizeCode: "env/too-large",
127
- });
128
-
129
- var rawLines = input.split(/\r\n|\r|\n/);
130
- var values = Object.create(null);
131
- var seen = new Set();
132
-
133
- for (var i = 0; i < rawLines.length; i++) {
134
- var line = rawLines[i];
135
- var lineNumber = i + 1;
136
- // Trim leading whitespace (operators sometimes indent for readability)
137
- var trimmed = line.replace(/^[ \t]+/, "");
138
- if (trimmed.length === 0) continue;
139
- if (trimmed.charAt(0) === "#") continue;
140
-
141
- // Optional `export ` prefix (POSIX shell convention)
142
- if (/^export\s+/.test(trimmed)) {
143
- trimmed = trimmed.replace(/^export\s+/, "");
144
- }
145
-
146
- var eqIdx = trimmed.indexOf("=");
147
- if (eqIdx < 0) {
148
- throw new SafeEnvError("missing '=' separator", "env/bad-line", lineNumber);
149
- }
150
- var key = trimmed.substring(0, eqIdx).replace(safeBuffer.TRAILING_HSPACE_RE, "");
151
- var rest = trimmed.substring(eqIdx + 1);
152
-
153
- if (key.length === 0) {
154
- throw new SafeEnvError("empty key", "env/empty-key", lineNumber);
155
- }
156
- if (POISONED_KEYS.has(key)) {
157
- throw new SafeEnvError("forbidden key '" + key + "'", "env/poisoned-key", lineNumber);
158
- }
159
- if (!keyShape.test(key)) {
160
- throw new SafeEnvError(
161
- "key '" + key + "' does not match keyShape " + keyShape,
162
- "env/bad-key-shape", lineNumber
163
- );
164
- }
165
- if (seen.has(key)) {
166
- throw new SafeEnvError("duplicate key '" + key + "'", "env/duplicate-key", lineNumber);
167
- }
168
-
169
- // Tab at start of value is almost always a paste accident
170
- if (rest.charAt(0) === "\t") {
171
- throw new SafeEnvError(
172
- "tab at start of value (use spaces or quote the value)",
173
- "env/tab-in-value", lineNumber
174
- );
175
- }
176
- // Strip ONE leading space (operator-friendly: `KEY = value` → "value")
177
- if (rest.charAt(0) === " ") rest = rest.substring(1);
178
-
179
- var value;
180
- if (rest.charAt(0) === '"') {
181
- value = _decodeDoubleQuoted(rest, lineNumber);
182
- } else if (rest.charAt(0) === "'") {
183
- value = _decodeSingleQuoted(rest, lineNumber);
184
- } else {
185
- // Unquoted value: strip trailing whitespace + inline `# comment`.
186
- // The comment marker MUST be preceded by whitespace to count
187
- // (so a value like `KEY=color#red` keeps the literal `#`).
188
- var commentMatch = rest.match(/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/);
189
- if (commentMatch) {
190
- value = commentMatch[1].replace(safeBuffer.TRAILING_HSPACE_RE, "");
191
- } else {
192
- value = rest.replace(safeBuffer.TRAILING_HSPACE_RE, "");
193
- }
194
- // Reject `$VAR` style references — explicit error so operators
195
- // see the policy rather than silently getting unexpanded text.
196
- if (/\$(\{[A-Za-z_]|[A-Za-z_])/.test(value)) {
197
- throw new SafeEnvError(
198
- "$VAR / ${VAR} expansion not supported (escape with \\$ if literal, or quote and expand yourself)",
199
- "env/expansion-banned", lineNumber
200
- );
201
- }
202
- }
203
-
204
- seen.add(key);
205
- values[key] = value;
206
- if (seen.size > maxKeys) {
207
- throw new SafeEnvError("input exceeds maxKeys", "env/too-many-keys", lineNumber);
208
- }
209
- }
210
-
211
- // Convert null-prototype to plain {} so JSON.stringify and for-in work.
212
- var out = {};
213
- for (var k in values) {
214
- if (Object.prototype.hasOwnProperty.call(values, k)) out[k] = values[k];
215
- }
216
- return out;
217
- }
218
-
219
- function _decodeDoubleQuoted(rest, lineNumber) {
220
- // Find closing quote, handling \\ + \"
221
- var i = 1;
222
- var out = "";
223
- while (i < rest.length) {
224
- var ch = rest.charAt(i);
225
- if (ch === "\\") {
226
- var esc = rest.charAt(i + 1);
227
- switch (esc) {
228
- case '"': out += '"'; i += 2; continue;
229
- case "\\": out += "\\"; i += 2; continue;
230
- case "n": out += "\n"; i += 2; continue;
231
- case "r": out += "\r"; i += 2; continue;
232
- case "t": out += "\t"; i += 2; continue;
233
- case "$": out += "$"; i += 2; continue; // literal $ escape
234
- case "u": {
235
- var hex = rest.substring(i + 2, i + 6);
236
- if (!safeBuffer.isHex(hex, 4)) {
237
- throw new SafeEnvError("bad \\u escape", "env/bad-escape", lineNumber);
238
- }
239
- out += String.fromCharCode(parseInt(hex, RADIX_HEX));
240
- i += 6;
241
- continue;
242
- }
243
- default:
244
- throw new SafeEnvError("unknown escape '\\" + esc + "'", "env/bad-escape", lineNumber);
245
- }
246
- }
247
- if (ch === '"') {
248
- // Optional inline comment after closing quote — ignore.
249
- return out;
250
- }
251
- if (ch === "$" && /^[{A-Za-z_]/.test(rest.charAt(i + 1) || "")) {
252
- throw new SafeEnvError(
253
- "$VAR / ${VAR} expansion not supported in double-quoted value (use \\$ for literal $)",
254
- "env/expansion-banned", lineNumber
255
- );
256
- }
257
- out += ch;
258
- i += 1;
259
- }
260
- throw new SafeEnvError("unterminated double-quoted value", "env/unterminated-string", lineNumber);
261
- }
262
-
263
- function _decodeSingleQuoted(rest, lineNumber) {
264
- // Single-quoted is fully literal: no escapes, no expansion. Closing
265
- // quote is the first un-escaped `'` — but since there are no escapes,
266
- // it's literally the next `'`.
267
- var end = rest.indexOf("'", 1);
268
- if (end < 0) {
269
- throw new SafeEnvError("unterminated single-quoted value", "env/unterminated-string", lineNumber);
270
- }
271
- return rest.substring(1, end);
272
- }
273
-
274
- // ---- Schema validation + type coercion ----
275
- //
276
- // expected = { KEY: { type, sensitivity, default? } }
277
- // type: "string" (default) | "number" | "boolean" | "json"
278
- // sensitivity: "boot-only" | "runtime" | "breaking" (informational; used by diff)
279
- // default: optional value to use when key is absent
280
-
281
- function _coerceType(rawValue, type, key) {
282
- if (type === "string" || type == null) return rawValue;
283
- if (type === "number") {
284
- if (rawValue === "") return null;
285
- var n = Number(rawValue);
286
- if (Number.isNaN(n)) {
287
- throw new SafeEnvError("'" + rawValue + "' is not a number for key '" + key + "'",
288
- "env/bad-type");
289
- }
290
- return n;
291
- }
292
- if (type === "boolean") {
293
- // Only accept the unambiguous spellings — no yes/no/on/off
294
- // (consistent with our YAML "Norway problem" stance).
295
- if (rawValue === "true") return true;
296
- if (rawValue === "false") return false;
297
- throw new SafeEnvError(
298
- "boolean key '" + key + "' must be 'true' or 'false' (got '" + rawValue + "')",
299
- "env/bad-type"
300
- );
301
- }
302
- if (type === "json") {
303
- try { return safeJson.parse(rawValue); }
304
- catch (e) {
305
- throw new SafeEnvError("invalid JSON for key '" + key + "': " + e.message,
306
- "env/bad-type");
307
- }
308
- }
309
- throw new SafeEnvError("unknown type '" + type + "' for key '" + key + "'",
310
- "env/bad-schema");
311
- }
312
-
313
- // ---- Levenshtein distance for typo detection ----
314
-
315
- function _levenshtein(a, b) {
316
- var m = a.length, n = b.length;
317
- if (Math.abs(m - n) > 3) return 4; // early-exit: anything > 2 we don't care about
318
- var prev = new Array(n + 1);
319
- var curr = new Array(n + 1);
320
- for (var j = 0; j <= n; j++) prev[j] = j;
321
- for (var i = 1; i <= m; i++) {
322
- curr[0] = i;
323
- for (var k = 1; k <= n; k++) {
324
- var cost = a.charAt(i - 1) === b.charAt(k - 1) ? 0 : 1;
325
- curr[k] = Math.min(
326
- prev[k] + 1, // deletion
327
- curr[k - 1] + 1, // insertion
328
- prev[k - 1] + cost // substitution
329
- );
330
- }
331
- var tmp = prev; prev = curr; curr = tmp;
332
- }
333
- return prev[n];
334
- }
335
-
336
- function _detectSuspicious(values, expected) {
337
- if (!expected) return [];
338
- var expectedKeys = Object.keys(expected);
339
- var expectedUpper = expectedKeys.map(function (k) { return k.toUpperCase(); });
340
- var suspicious = [];
341
- for (var key in values) {
342
- if (Object.prototype.hasOwnProperty.call(expected, key)) continue; // registered
343
-
344
- // Case-mismatch: an exact match exists if you upper-case
345
- var upper = key.toUpperCase();
346
- var caseIdx = expectedUpper.indexOf(upper);
347
- if (caseIdx !== -1 && expectedKeys[caseIdx] !== key) {
348
- suspicious.push({
349
- key: key,
350
- suggestion: expectedKeys[caseIdx],
351
- reason: "case-mismatch",
352
- });
353
- continue;
354
- }
355
-
356
- // Levenshtein distance ≤ 2
357
- var bestDist = Infinity;
358
- var bestKey = null;
359
- for (var j = 0; j < expectedKeys.length; j++) {
360
- var d = _levenshtein(key, expectedKeys[j]);
361
- if (d < bestDist) { bestDist = d; bestKey = expectedKeys[j]; }
362
- }
363
- if (bestDist <= 2 && bestKey) {
364
- suspicious.push({
365
- key: key,
366
- suggestion: bestKey,
367
- reason: "single-char-typo",
368
- });
369
- } else {
370
- suspicious.push({ key: key, suggestion: null, reason: "unknown" });
371
- }
372
- }
373
- return suspicious;
374
- }
375
-
376
- // ---- Diff computation ----
377
-
378
- function _diff(prevValues, nextValues, expected) {
379
- var added = [];
380
- var removed = [];
381
- var changed = [];
382
- function _sensitivityOf(k) {
383
- if (expected && expected[k] && expected[k].sensitivity) return expected[k].sensitivity;
384
- return null;
385
- }
386
- for (var k in nextValues) {
387
- if (!Object.prototype.hasOwnProperty.call(prevValues, k)) {
388
- added.push(k);
389
- } else if (prevValues[k] !== nextValues[k]) {
390
- changed.push({ key: k, sensitivity: _sensitivityOf(k) });
391
- }
392
- }
393
- for (var k2 in prevValues) {
394
- if (!Object.prototype.hasOwnProperty.call(nextValues, k2)) {
395
- removed.push(k2);
396
- }
397
- }
398
- return { added: added, removed: removed, changed: changed };
399
- }
400
-
401
- // ---- load() ----
402
-
403
- function load(filepath, opts) {
404
- if (typeof filepath !== "string") {
405
- throw new SafeEnvError("load requires a file path", "env/bad-arg");
406
- }
407
- opts = opts || {};
408
- var applyToProcess = opts.applyToProcess === true;
409
- var allowOverwrite = opts.allowOverwrite === true;
410
- var rejectUnknown = opts.rejectUnknown === true;
411
- var auditEnabled = opts.audit !== false;
412
- var expected = opts.expected || null;
413
- var allowChange = new Set((opts.allow || []).map(String));
414
-
415
- var bytes = atomicFile.readSync(filepath, {
416
- maxBytes: opts.maxBytes != null ? opts.maxBytes : DEFAULTS.maxBytes,
417
- });
418
- var rawValues = parse(bytes.toString("utf8"), {
419
- maxBytes: opts.maxBytes,
420
- maxKeys: opts.maxKeys,
421
- keyShape: opts.keyShape,
422
- });
423
-
424
- // Schema-driven coercion + missing-required detection
425
- var values = {};
426
- if (expected) {
427
- for (var k in expected) {
428
- if (Object.prototype.hasOwnProperty.call(rawValues, k)) {
429
- values[k] = _coerceType(rawValues[k], expected[k].type, k);
430
- } else if ("default" in expected[k]) {
431
- values[k] = expected[k].default;
432
- } else if (expected[k].required === true) {
433
- throw new SafeEnvError("required key '" + k + "' missing from " + filepath,
434
- "env/missing-required");
435
- }
436
- }
437
- // Also include unregistered keys in values so the operator's app can
438
- // still read them; they just don't get coercion.
439
- for (var k2 in rawValues) {
440
- if (!Object.prototype.hasOwnProperty.call(values, k2)) {
441
- values[k2] = rawValues[k2];
442
- }
443
- }
444
- } else {
445
- values = rawValues;
446
- }
447
-
448
- // Suspicious-key detection
449
- var suspicious = _detectSuspicious(rawValues, expected);
450
- if (rejectUnknown && suspicious.length > 0) {
451
- var keys = suspicious.map(function (s) { return s.key; }).join(", ");
452
- throw new SafeEnvError(
453
- "rejectUnknown: unregistered keys present: " + keys,
454
- "env/unknown-keys"
455
- );
456
- }
457
-
458
- // Snapshot diff
459
- var snapshotPath = opts.snapshotPath || null;
460
- var prevValues = {};
461
- if (snapshotPath && atomicFile.exists(snapshotPath)) {
462
- try {
463
- var snapBuf = atomicFile.readSync(snapshotPath);
464
- prevValues = safeJson.parse(snapBuf) || {};
465
- } catch (_e) { /* missing/corrupt snapshot → treat as empty */ }
466
- }
467
- var diff = _diff(prevValues, rawValues, expected);
468
- diff.suspicious = suspicious;
469
-
470
- // Refuse breaking changes unless explicitly allowed
471
- if (expected) {
472
- for (var i = 0; i < diff.changed.length; i++) {
473
- var entry = diff.changed[i];
474
- if (entry.sensitivity === "breaking" && !allowChange.has(entry.key)) {
475
- throw new SafeEnvError(
476
- "key '" + entry.key + "' is sensitivity:'breaking' — pass " +
477
- "{ allow: ['" + entry.key + "'] } to acknowledge the change",
478
- "env/breaking-change"
479
- );
480
- }
481
- }
482
- }
483
-
484
- // Optionally apply to process.env
485
- if (applyToProcess) {
486
- for (var k3 in rawValues) {
487
- if (Object.prototype.hasOwnProperty.call(process.env, k3) && !allowOverwrite) {
488
- // Existing env var — leave untouched
489
- continue;
490
- }
491
- process.env[k3] = rawValues[k3];
492
- }
493
- }
494
-
495
- // Persist new snapshot (best-effort; failure to write doesn't crash
496
- // the load — operators just lose change-tracking until next successful
497
- // write).
498
- if (snapshotPath) {
499
- try {
500
- atomicFile.writeSync(snapshotPath, JSON.stringify(rawValues), { fileMode: 0o600 });
501
- } catch (_e) { /* best-effort */ }
502
- }
503
-
504
- // Audit hooks
505
- if (auditEnabled && _hasNonEmptyDiff(diff)) {
506
- _writeAuditRows(filepath, diff);
507
- }
508
-
509
- return { values: values, diff: diff };
510
- }
511
-
512
- function _hasNonEmptyDiff(d) {
513
- return d.added.length > 0 || d.removed.length > 0 ||
514
- d.changed.length > 0 || d.suspicious.length > 0;
515
- }
516
-
517
- function _writeAuditRows(filepath, diff) {
518
- // Both audit.record and cluster.requireLeader sit downstream — if
519
- // we're a follower, audit.record will throw NotLeaderError. Catch
520
- // explicitly: a follower's local config-load shouldn't crash because
521
- // the cluster's audit chain belongs to the leader.
522
- var audit = auditModule(); // resolve the lazy-required audit module
523
-
524
- function _safeRecord(action, metadata) {
525
- try {
526
- audit.emit({
527
- actor: { kind: "system", id: "config-loader" },
528
- action: action,
529
- outcome: "success",
530
- target: { kind: "config-file", id: filepath },
531
- metadata: metadata,
532
- });
533
- } catch (e) {
534
- if (e && e.code === "NOT_LEADER") return; // follower; expected
535
- // Unexpected — but config-loading should not fail on audit failure
536
- // (the audit chain is observability, not the loaded values).
537
- // Surface via stderr so operators notice without crashing.
538
- log.error("audit.record failed: " + e.message);
539
- }
540
- }
541
-
542
- if (diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0) {
543
- _safeRecord("system.config.changed", {
544
- file: filepath,
545
- added: diff.added,
546
- removed: diff.removed,
547
- changed: diff.changed,
548
- });
549
- }
550
- if (diff.suspicious.length > 0) {
551
- _safeRecord("system.config.suspicious", {
552
- file: filepath,
553
- suspicious: diff.suspicious,
554
- });
555
- }
556
- }
557
-
558
- // ---- readVar() — single process.env reader with schema validation ----
559
- //
560
- // Centralizes "read one process.env var, with size cap + type coercion +
561
- // optional default + optional strip-after-read for secrets". Replaces
562
- // scattered `process.env.BLAMEJS_*` reads across lib/* — every call site
563
- // that previously did its own missing/empty/coerce handling now goes
564
- // through this primitive.
565
- //
566
- // schema:
567
- // type: "string" (default) | "number" | "boolean" | "json" | "buffer"
568
- // required: bool — throw if missing/empty (default: false)
569
- // default: fallback value when var is missing/empty (overrides required)
570
- // maxBytes: cap on byte length (default 64 KiB; pass 4096 for secrets)
571
- // strip: bool — `delete process.env[name]` after read (for secrets)
572
- // enum: optional array of allowed raw string values (string type only)
573
-
574
- var READVAR_DEFAULT_MAX_BYTES = C.BYTES.kib(64);
575
-
576
- function readVar(name, schema) {
577
- if (typeof name !== "string" || name.length === 0) {
578
- throw new SafeEnvError("readVar requires a non-empty name", "env/bad-arg");
579
- }
580
- schema = schema || {};
581
- var type = schema.type || "string";
582
- var required = schema.required === true;
583
- var hasDefault = "default" in schema;
584
- if (schema.maxBytes !== undefined && !numericBounds.isPositiveFiniteInt(schema.maxBytes)) {
585
- throw new SafeEnvError(
586
- "readVar: maxBytes must be a positive finite integer; got " +
587
- numericBounds.shape(schema.maxBytes),
588
- "env/bad-opt"
589
- );
590
- }
591
- var maxBytes = (schema.maxBytes !== undefined)
592
- ? schema.maxBytes : READVAR_DEFAULT_MAX_BYTES;
593
- var strip = schema.strip === true;
594
-
595
- var raw = process.env[name];
596
- var present = raw !== undefined && raw !== null && raw !== "";
597
-
598
- if (!present) {
599
- if (hasDefault) return schema.default;
600
- if (required) {
601
- throw new SafeEnvError(name + " env var is not set or is empty", "env/missing-required");
602
- }
603
- return undefined;
604
- }
605
-
606
- // Size cap. Apply BEFORE strip so an oversized value still triggers
607
- // the diagnostic (operator gets a clear error, not a silent truncation).
608
- if (Buffer.byteLength(raw, "utf8") > maxBytes) {
609
- throw new SafeEnvError(
610
- name + " exceeds " + maxBytes + " byte limit",
611
- "env/too-large"
612
- );
613
- }
614
-
615
- // enum constraint (string type only, applied to RAW value before coercion)
616
- if (Array.isArray(schema.enum) && schema.enum.indexOf(raw) === -1) {
617
- throw new SafeEnvError(
618
- name + "='" + raw + "' is not one of: " + schema.enum.join(", "),
619
- "env/bad-value"
620
- );
621
- }
622
-
623
- var value;
624
- if (type === "buffer") {
625
- value = Buffer.from(raw, "utf8");
626
- } else {
627
- value = _coerceType(raw, type, name);
628
- }
629
-
630
- if (strip) {
631
- delete process.env[name];
632
- }
633
-
634
- return value;
635
- }
636
-
637
- module.exports = {
638
- parse: parse,
639
- load: load,
640
- readVar: readVar,
641
- SafeEnvError: SafeEnvError,
642
- };
1
+ "use strict";
2
+ /**
3
+ * Security-focused .env loader.
4
+ *
5
+ * Parses dotenv-style files with the framework's standard guards plus
6
+ * change tracking and typo detection — env-var changes are
7
+ * operationally significant and the framework records them through the
8
+ * audit chain.
9
+ *
10
+ * The dotenv ecosystem has a long history of subtle footguns: implicit
11
+ * `$VAR` expansion that surprises consumers reading process.env later,
12
+ * silent overwrite of operator-set values, ambiguous quote handling
13
+ * across libraries, comment-vs-value confusion. This loader rejects or
14
+ * makes explicit each of those.
15
+ *
16
+ * Public API:
17
+ * env.parse(input, opts?) object | throws SafeEnvError
18
+ * env.load(filepath, opts?) { values, diff } | throws
19
+ * env.SafeEnvError error class
20
+ *
21
+ * env.parse returns the bare key/value object — pure, no I/O, no
22
+ * audit, no schema. Use it when you have the file content already in
23
+ * memory or when you want to compose `.env` parsing into a larger flow.
24
+ *
25
+ * env.load reads the file via atomicFile.readSync, applies schema
26
+ * validation, computes a diff against the previous snapshot, optionally
27
+ * applies values to process.env, and writes a system.config.changed
28
+ * audit row when the diff is non-empty. Followers in cluster mode see
29
+ * their own config; the audit write is leader-gated and is silently
30
+ * skipped on followers (their own audit row would have nothing to
31
+ * anchor to anyway since they don't write the chain).
32
+ *
33
+ * What is REJECTED:
34
+ * - Tabs at start of value (real spaces are fine; tabs in unquoted
35
+ * values are almost always a copy-paste accident)
36
+ * - $VAR / ${VAR} expansion (consumers reading process.env later
37
+ * would not know whether the value was literal or expanded —
38
+ * consistency over convenience). Operators who want expansion
39
+ * can do it themselves before passing values to the framework.
40
+ * - Multiline unquoted values (you must double-quote a value
41
+ * containing newlines). Common dotenv libraries auto-join lines;
42
+ * the heuristic regularly produces wrong results.
43
+ * - Keys outside `^[A-Z_][A-Z0-9_]*$` by default (POSIX env-var
44
+ * shape). Operators can override via `{ keyShape: /…/ }`.
45
+ * - Empty key (`=value` with no key)
46
+ *
47
+ * Defaults:
48
+ * maxBytes: 64 KiB (.env files are config; cap is small)
49
+ * maxKeys: 1000
50
+ * keyShape: /^[A-Z_][A-Z0-9_]*$/
51
+ * applyToProcess: false (return values; do not mutate process.env)
52
+ * allowOverwrite: false (only relevant when applyToProcess: true)
53
+ * rejectUnknown: false (warn on unregistered keys; do not refuse)
54
+ * audit: true (record diff in audit chain)
55
+ */
56
+
57
+ var C = require("../constants");
58
+ var atomicFile = require("../atomic-file");
59
+ var lazyRequire = require("../lazy-require");
60
+ var numericBounds = require("../numeric-bounds");
61
+ var safeBuffer = require("../safe-buffer");
62
+ var safeJson = require("../safe-json");
63
+ var { FrameworkError } = require("../framework-error");
64
+ var { boot } = require("../log");
65
+
66
+ // Lazy require for audit — eager-loading audit at top of file forms a
67
+ // load cycle: vault.init → passphrase-source → safe-env → audit →
68
+ // (transitively) vault, which leaves safe-env's module.exports
69
+ // half-built when vault first reaches readVar. Defer audit resolution
70
+ // until the first emit-driven call.
71
+ var auditModule = lazyRequire(function () { return require("../audit"); });
72
+
73
+ var log = boot("env");
74
+
75
+ // parseInt radix — named so the call site doesn't carry a bare 16
76
+ // integer literal that reads as a byte count.
77
+ var RADIX_HEX = 0x10;
78
+
79
+ class SafeEnvError extends FrameworkError {
80
+ constructor(message, code, line) {
81
+ super(line != null ? message + " at line " + line : message);
82
+ this.name = "SafeEnvError";
83
+ this.code = code || "env/invalid";
84
+ this.line = line == null ? null : line;
85
+ this.isSafeEnvError = true;
86
+ }
87
+ }
88
+
89
+ var DEFAULTS = {
90
+ maxBytes: C.BYTES.kib(64),
91
+ maxKeys: 1_000,
92
+ keyShape: /^[A-Z_][A-Z0-9_]*$/,
93
+ applyToProcess: false,
94
+ allowOverwrite: false,
95
+ rejectUnknown: false,
96
+ audit: true,
97
+ };
98
+
99
+ var POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
100
+
101
+ // ---- parse ----
102
+
103
+ function parse(input, opts) {
104
+ opts = opts || {};
105
+ // Validate operator-supplied numeric opts via lib/numeric-bounds —
106
+ // Infinity / NaN / negative / non-integer all bypass the `> 0` shape
107
+ // and silently lift the DoS cap they were meant to enforce.
108
+ if (opts.maxBytes !== undefined && !numericBounds.isPositiveFiniteInt(opts.maxBytes)) {
109
+ throw new SafeEnvError("env.parse: maxBytes must be a positive finite integer; got " +
110
+ numericBounds.shape(opts.maxBytes), "env/bad-opt");
111
+ }
112
+ if (opts.maxKeys !== undefined && !numericBounds.isPositiveFiniteInt(opts.maxKeys)) {
113
+ throw new SafeEnvError("env.parse: maxKeys must be a positive finite integer; got " +
114
+ numericBounds.shape(opts.maxKeys), "env/bad-opt");
115
+ }
116
+ var maxBytes = opts.maxBytes !== undefined
117
+ ? Math.min(opts.maxBytes, C.BYTES.mib(1)) : DEFAULTS.maxBytes;
118
+ var maxKeys = opts.maxKeys !== undefined
119
+ ? Math.min(opts.maxKeys, 100_000) : DEFAULTS.maxKeys;
120
+ var keyShape = opts.keyShape instanceof RegExp ? opts.keyShape : DEFAULTS.keyShape;
121
+
122
+ input = safeBuffer.normalizeText(input, {
123
+ maxBytes: maxBytes,
124
+ errorClass: SafeEnvError,
125
+ typeCode: "env/wrong-input-type",
126
+ sizeCode: "env/too-large",
127
+ });
128
+
129
+ var rawLines = input.split(/\r\n|\r|\n/);
130
+ var values = Object.create(null);
131
+ var seen = new Set();
132
+
133
+ for (var i = 0; i < rawLines.length; i++) {
134
+ var line = rawLines[i];
135
+ var lineNumber = i + 1;
136
+ // Trim leading whitespace (operators sometimes indent for readability)
137
+ var trimmed = line.replace(/^[ \t]+/, "");
138
+ if (trimmed.length === 0) continue;
139
+ if (trimmed.charAt(0) === "#") continue;
140
+
141
+ // Optional `export ` prefix (POSIX shell convention)
142
+ if (/^export\s+/.test(trimmed)) {
143
+ trimmed = trimmed.replace(/^export\s+/, "");
144
+ }
145
+
146
+ var eqIdx = trimmed.indexOf("=");
147
+ if (eqIdx < 0) {
148
+ throw new SafeEnvError("missing '=' separator", "env/bad-line", lineNumber);
149
+ }
150
+ var key = trimmed.substring(0, eqIdx).replace(safeBuffer.TRAILING_HSPACE_RE, "");
151
+ var rest = trimmed.substring(eqIdx + 1);
152
+
153
+ if (key.length === 0) {
154
+ throw new SafeEnvError("empty key", "env/empty-key", lineNumber);
155
+ }
156
+ if (POISONED_KEYS.has(key)) {
157
+ throw new SafeEnvError("forbidden key '" + key + "'", "env/poisoned-key", lineNumber);
158
+ }
159
+ if (!keyShape.test(key)) {
160
+ throw new SafeEnvError(
161
+ "key '" + key + "' does not match keyShape " + keyShape,
162
+ "env/bad-key-shape", lineNumber
163
+ );
164
+ }
165
+ if (seen.has(key)) {
166
+ throw new SafeEnvError("duplicate key '" + key + "'", "env/duplicate-key", lineNumber);
167
+ }
168
+
169
+ // Tab at start of value is almost always a paste accident
170
+ if (rest.charAt(0) === "\t") {
171
+ throw new SafeEnvError(
172
+ "tab at start of value (use spaces or quote the value)",
173
+ "env/tab-in-value", lineNumber
174
+ );
175
+ }
176
+ // Strip ONE leading space (operator-friendly: `KEY = value` → "value")
177
+ if (rest.charAt(0) === " ") rest = rest.substring(1);
178
+
179
+ var value;
180
+ if (rest.charAt(0) === '"') {
181
+ value = _decodeDoubleQuoted(rest, lineNumber);
182
+ } else if (rest.charAt(0) === "'") {
183
+ value = _decodeSingleQuoted(rest, lineNumber);
184
+ } else {
185
+ // Unquoted value: strip trailing whitespace + inline `# comment`.
186
+ // The comment marker MUST be preceded by whitespace to count
187
+ // (so a value like `KEY=color#red` keeps the literal `#`).
188
+ var commentMatch = rest.match(/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/);
189
+ if (commentMatch) {
190
+ value = commentMatch[1].replace(safeBuffer.TRAILING_HSPACE_RE, "");
191
+ } else {
192
+ value = rest.replace(safeBuffer.TRAILING_HSPACE_RE, "");
193
+ }
194
+ // Reject `$VAR` style references — explicit error so operators
195
+ // see the policy rather than silently getting unexpanded text.
196
+ if (/\$(\{[A-Za-z_]|[A-Za-z_])/.test(value)) {
197
+ throw new SafeEnvError(
198
+ "$VAR / ${VAR} expansion not supported (escape with \\$ if literal, or quote and expand yourself)",
199
+ "env/expansion-banned", lineNumber
200
+ );
201
+ }
202
+ }
203
+
204
+ seen.add(key);
205
+ values[key] = value;
206
+ if (seen.size > maxKeys) {
207
+ throw new SafeEnvError("input exceeds maxKeys", "env/too-many-keys", lineNumber);
208
+ }
209
+ }
210
+
211
+ // Convert null-prototype to plain {} so JSON.stringify and for-in work.
212
+ var out = {};
213
+ for (var k in values) {
214
+ if (Object.prototype.hasOwnProperty.call(values, k)) out[k] = values[k];
215
+ }
216
+ return out;
217
+ }
218
+
219
+ function _decodeDoubleQuoted(rest, lineNumber) {
220
+ // Find closing quote, handling \\ + \"
221
+ var i = 1;
222
+ var out = "";
223
+ while (i < rest.length) {
224
+ var ch = rest.charAt(i);
225
+ if (ch === "\\") {
226
+ var esc = rest.charAt(i + 1);
227
+ switch (esc) {
228
+ case '"': out += '"'; i += 2; continue;
229
+ case "\\": out += "\\"; i += 2; continue;
230
+ case "n": out += "\n"; i += 2; continue;
231
+ case "r": out += "\r"; i += 2; continue;
232
+ case "t": out += "\t"; i += 2; continue;
233
+ case "$": out += "$"; i += 2; continue; // literal $ escape
234
+ case "u": {
235
+ var hex = rest.substring(i + 2, i + 6);
236
+ if (!safeBuffer.isHex(hex, 4)) {
237
+ throw new SafeEnvError("bad \\u escape", "env/bad-escape", lineNumber);
238
+ }
239
+ out += String.fromCharCode(parseInt(hex, RADIX_HEX));
240
+ i += 6;
241
+ continue;
242
+ }
243
+ default:
244
+ throw new SafeEnvError("unknown escape '\\" + esc + "'", "env/bad-escape", lineNumber);
245
+ }
246
+ }
247
+ if (ch === '"') {
248
+ // Optional inline comment after closing quote — ignore.
249
+ return out;
250
+ }
251
+ if (ch === "$" && /^[{A-Za-z_]/.test(rest.charAt(i + 1) || "")) {
252
+ throw new SafeEnvError(
253
+ "$VAR / ${VAR} expansion not supported in double-quoted value (use \\$ for literal $)",
254
+ "env/expansion-banned", lineNumber
255
+ );
256
+ }
257
+ out += ch;
258
+ i += 1;
259
+ }
260
+ throw new SafeEnvError("unterminated double-quoted value", "env/unterminated-string", lineNumber);
261
+ }
262
+
263
+ function _decodeSingleQuoted(rest, lineNumber) {
264
+ // Single-quoted is fully literal: no escapes, no expansion. Closing
265
+ // quote is the first un-escaped `'` — but since there are no escapes,
266
+ // it's literally the next `'`.
267
+ var end = rest.indexOf("'", 1);
268
+ if (end < 0) {
269
+ throw new SafeEnvError("unterminated single-quoted value", "env/unterminated-string", lineNumber);
270
+ }
271
+ return rest.substring(1, end);
272
+ }
273
+
274
+ // ---- Schema validation + type coercion ----
275
+ //
276
+ // expected = { KEY: { type, sensitivity, default? } }
277
+ // type: "string" (default) | "number" | "boolean" | "json"
278
+ // sensitivity: "boot-only" | "runtime" | "breaking" (informational; used by diff)
279
+ // default: optional value to use when key is absent
280
+
281
+ function _coerceType(rawValue, type, key) {
282
+ if (type === "string" || type == null) return rawValue;
283
+ if (type === "number") {
284
+ if (rawValue === "") return null;
285
+ var n = Number(rawValue);
286
+ if (Number.isNaN(n)) {
287
+ throw new SafeEnvError("'" + rawValue + "' is not a number for key '" + key + "'",
288
+ "env/bad-type");
289
+ }
290
+ return n;
291
+ }
292
+ if (type === "boolean") {
293
+ // Only accept the unambiguous spellings — no yes/no/on/off
294
+ // (consistent with our YAML "Norway problem" stance).
295
+ if (rawValue === "true") return true;
296
+ if (rawValue === "false") return false;
297
+ throw new SafeEnvError(
298
+ "boolean key '" + key + "' must be 'true' or 'false' (got '" + rawValue + "')",
299
+ "env/bad-type"
300
+ );
301
+ }
302
+ if (type === "json") {
303
+ try { return safeJson.parse(rawValue); }
304
+ catch (e) {
305
+ throw new SafeEnvError("invalid JSON for key '" + key + "': " + e.message,
306
+ "env/bad-type");
307
+ }
308
+ }
309
+ throw new SafeEnvError("unknown type '" + type + "' for key '" + key + "'",
310
+ "env/bad-schema");
311
+ }
312
+
313
+ // ---- Levenshtein distance for typo detection ----
314
+
315
+ function _levenshtein(a, b) {
316
+ var m = a.length, n = b.length;
317
+ if (Math.abs(m - n) > 3) return 4; // early-exit: anything > 2 we don't care about
318
+ var prev = new Array(n + 1);
319
+ var curr = new Array(n + 1);
320
+ for (var j = 0; j <= n; j++) prev[j] = j;
321
+ for (var i = 1; i <= m; i++) {
322
+ curr[0] = i;
323
+ for (var k = 1; k <= n; k++) {
324
+ var cost = a.charAt(i - 1) === b.charAt(k - 1) ? 0 : 1;
325
+ curr[k] = Math.min(
326
+ prev[k] + 1, // deletion
327
+ curr[k - 1] + 1, // insertion
328
+ prev[k - 1] + cost // substitution
329
+ );
330
+ }
331
+ var tmp = prev; prev = curr; curr = tmp;
332
+ }
333
+ return prev[n];
334
+ }
335
+
336
+ function _detectSuspicious(values, expected) {
337
+ if (!expected) return [];
338
+ var expectedKeys = Object.keys(expected);
339
+ var expectedUpper = expectedKeys.map(function (k) { return k.toUpperCase(); });
340
+ var suspicious = [];
341
+ for (var key in values) {
342
+ if (Object.prototype.hasOwnProperty.call(expected, key)) continue; // registered
343
+
344
+ // Case-mismatch: an exact match exists if you upper-case
345
+ var upper = key.toUpperCase();
346
+ var caseIdx = expectedUpper.indexOf(upper);
347
+ if (caseIdx !== -1 && expectedKeys[caseIdx] !== key) {
348
+ suspicious.push({
349
+ key: key,
350
+ suggestion: expectedKeys[caseIdx],
351
+ reason: "case-mismatch",
352
+ });
353
+ continue;
354
+ }
355
+
356
+ // Levenshtein distance ≤ 2
357
+ var bestDist = Infinity;
358
+ var bestKey = null;
359
+ for (var j = 0; j < expectedKeys.length; j++) {
360
+ var d = _levenshtein(key, expectedKeys[j]);
361
+ if (d < bestDist) { bestDist = d; bestKey = expectedKeys[j]; }
362
+ }
363
+ if (bestDist <= 2 && bestKey) {
364
+ suspicious.push({
365
+ key: key,
366
+ suggestion: bestKey,
367
+ reason: "single-char-typo",
368
+ });
369
+ } else {
370
+ suspicious.push({ key: key, suggestion: null, reason: "unknown" });
371
+ }
372
+ }
373
+ return suspicious;
374
+ }
375
+
376
+ // ---- Diff computation ----
377
+
378
+ function _diff(prevValues, nextValues, expected) {
379
+ var added = [];
380
+ var removed = [];
381
+ var changed = [];
382
+ function _sensitivityOf(k) {
383
+ if (expected && expected[k] && expected[k].sensitivity) return expected[k].sensitivity;
384
+ return null;
385
+ }
386
+ for (var k in nextValues) {
387
+ if (!Object.prototype.hasOwnProperty.call(prevValues, k)) {
388
+ added.push(k);
389
+ } else if (prevValues[k] !== nextValues[k]) {
390
+ changed.push({ key: k, sensitivity: _sensitivityOf(k) });
391
+ }
392
+ }
393
+ for (var k2 in prevValues) {
394
+ if (!Object.prototype.hasOwnProperty.call(nextValues, k2)) {
395
+ removed.push(k2);
396
+ }
397
+ }
398
+ return { added: added, removed: removed, changed: changed };
399
+ }
400
+
401
+ // ---- load() ----
402
+
403
+ function load(filepath, opts) {
404
+ if (typeof filepath !== "string") {
405
+ throw new SafeEnvError("load requires a file path", "env/bad-arg");
406
+ }
407
+ opts = opts || {};
408
+ var applyToProcess = opts.applyToProcess === true;
409
+ var allowOverwrite = opts.allowOverwrite === true;
410
+ var rejectUnknown = opts.rejectUnknown === true;
411
+ var auditEnabled = opts.audit !== false;
412
+ var expected = opts.expected || null;
413
+ var allowChange = new Set((opts.allow || []).map(String));
414
+
415
+ var bytes = atomicFile.readSync(filepath, {
416
+ maxBytes: opts.maxBytes != null ? opts.maxBytes : DEFAULTS.maxBytes,
417
+ });
418
+ var rawValues = parse(bytes.toString("utf8"), {
419
+ maxBytes: opts.maxBytes,
420
+ maxKeys: opts.maxKeys,
421
+ keyShape: opts.keyShape,
422
+ });
423
+
424
+ // Schema-driven coercion + missing-required detection
425
+ var values = {};
426
+ if (expected) {
427
+ for (var k in expected) {
428
+ if (Object.prototype.hasOwnProperty.call(rawValues, k)) {
429
+ values[k] = _coerceType(rawValues[k], expected[k].type, k);
430
+ } else if ("default" in expected[k]) {
431
+ values[k] = expected[k].default;
432
+ } else if (expected[k].required === true) {
433
+ throw new SafeEnvError("required key '" + k + "' missing from " + filepath,
434
+ "env/missing-required");
435
+ }
436
+ }
437
+ // Also include unregistered keys in values so the operator's app can
438
+ // still read them; they just don't get coercion.
439
+ for (var k2 in rawValues) {
440
+ if (!Object.prototype.hasOwnProperty.call(values, k2)) {
441
+ values[k2] = rawValues[k2];
442
+ }
443
+ }
444
+ } else {
445
+ values = rawValues;
446
+ }
447
+
448
+ // Suspicious-key detection
449
+ var suspicious = _detectSuspicious(rawValues, expected);
450
+ if (rejectUnknown && suspicious.length > 0) {
451
+ var keys = suspicious.map(function (s) { return s.key; }).join(", ");
452
+ throw new SafeEnvError(
453
+ "rejectUnknown: unregistered keys present: " + keys,
454
+ "env/unknown-keys"
455
+ );
456
+ }
457
+
458
+ // Snapshot diff
459
+ var snapshotPath = opts.snapshotPath || null;
460
+ var prevValues = {};
461
+ if (snapshotPath && atomicFile.exists(snapshotPath)) {
462
+ try {
463
+ var snapBuf = atomicFile.readSync(snapshotPath);
464
+ prevValues = safeJson.parse(snapBuf) || {};
465
+ } catch (_e) { /* missing/corrupt snapshot → treat as empty */ }
466
+ }
467
+ var diff = _diff(prevValues, rawValues, expected);
468
+ diff.suspicious = suspicious;
469
+
470
+ // Refuse breaking changes unless explicitly allowed
471
+ if (expected) {
472
+ for (var i = 0; i < diff.changed.length; i++) {
473
+ var entry = diff.changed[i];
474
+ if (entry.sensitivity === "breaking" && !allowChange.has(entry.key)) {
475
+ throw new SafeEnvError(
476
+ "key '" + entry.key + "' is sensitivity:'breaking' — pass " +
477
+ "{ allow: ['" + entry.key + "'] } to acknowledge the change",
478
+ "env/breaking-change"
479
+ );
480
+ }
481
+ }
482
+ }
483
+
484
+ // Optionally apply to process.env
485
+ if (applyToProcess) {
486
+ for (var k3 in rawValues) {
487
+ if (Object.prototype.hasOwnProperty.call(process.env, k3) && !allowOverwrite) {
488
+ // Existing env var — leave untouched
489
+ continue;
490
+ }
491
+ process.env[k3] = rawValues[k3];
492
+ }
493
+ }
494
+
495
+ // Persist new snapshot (best-effort; failure to write doesn't crash
496
+ // the load — operators just lose change-tracking until next successful
497
+ // write).
498
+ if (snapshotPath) {
499
+ try {
500
+ atomicFile.writeSync(snapshotPath, JSON.stringify(rawValues), { fileMode: 0o600 });
501
+ } catch (_e) { /* best-effort */ }
502
+ }
503
+
504
+ // Audit hooks
505
+ if (auditEnabled && _hasNonEmptyDiff(diff)) {
506
+ _writeAuditRows(filepath, diff);
507
+ }
508
+
509
+ return { values: values, diff: diff };
510
+ }
511
+
512
+ function _hasNonEmptyDiff(d) {
513
+ return d.added.length > 0 || d.removed.length > 0 ||
514
+ d.changed.length > 0 || d.suspicious.length > 0;
515
+ }
516
+
517
+ function _writeAuditRows(filepath, diff) {
518
+ // Both audit.record and cluster.requireLeader sit downstream — if
519
+ // we're a follower, audit.record will throw NotLeaderError. Catch
520
+ // explicitly: a follower's local config-load shouldn't crash because
521
+ // the cluster's audit chain belongs to the leader.
522
+ var audit = auditModule(); // resolve the lazy-required audit module
523
+
524
+ function _safeRecord(action, metadata) {
525
+ try {
526
+ audit.emit({
527
+ actor: { kind: "system", id: "config-loader" },
528
+ action: action,
529
+ outcome: "success",
530
+ target: { kind: "config-file", id: filepath },
531
+ metadata: metadata,
532
+ });
533
+ } catch (e) {
534
+ if (e && e.code === "NOT_LEADER") return; // follower; expected
535
+ // Unexpected — but config-loading should not fail on audit failure
536
+ // (the audit chain is observability, not the loaded values).
537
+ // Surface via stderr so operators notice without crashing.
538
+ log.error("audit.record failed: " + e.message);
539
+ }
540
+ }
541
+
542
+ if (diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0) {
543
+ _safeRecord("system.config.changed", {
544
+ file: filepath,
545
+ added: diff.added,
546
+ removed: diff.removed,
547
+ changed: diff.changed,
548
+ });
549
+ }
550
+ if (diff.suspicious.length > 0) {
551
+ _safeRecord("system.config.suspicious", {
552
+ file: filepath,
553
+ suspicious: diff.suspicious,
554
+ });
555
+ }
556
+ }
557
+
558
+ // ---- readVar() — single process.env reader with schema validation ----
559
+ //
560
+ // Centralizes "read one process.env var, with size cap + type coercion +
561
+ // optional default + optional strip-after-read for secrets". Replaces
562
+ // scattered `process.env.BLAMEJS_*` reads across lib/* — every call site
563
+ // that previously did its own missing/empty/coerce handling now goes
564
+ // through this primitive.
565
+ //
566
+ // schema:
567
+ // type: "string" (default) | "number" | "boolean" | "json" | "buffer"
568
+ // required: bool — throw if missing/empty (default: false)
569
+ // default: fallback value when var is missing/empty (overrides required)
570
+ // maxBytes: cap on byte length (default 64 KiB; pass 4096 for secrets)
571
+ // strip: bool — `delete process.env[name]` after read (for secrets)
572
+ // enum: optional array of allowed raw string values (string type only)
573
+
574
+ var READVAR_DEFAULT_MAX_BYTES = C.BYTES.kib(64);
575
+
576
+ function readVar(name, schema) {
577
+ if (typeof name !== "string" || name.length === 0) {
578
+ throw new SafeEnvError("readVar requires a non-empty name", "env/bad-arg");
579
+ }
580
+ schema = schema || {};
581
+ var type = schema.type || "string";
582
+ var required = schema.required === true;
583
+ var hasDefault = "default" in schema;
584
+ if (schema.maxBytes !== undefined && !numericBounds.isPositiveFiniteInt(schema.maxBytes)) {
585
+ throw new SafeEnvError(
586
+ "readVar: maxBytes must be a positive finite integer; got " +
587
+ numericBounds.shape(schema.maxBytes),
588
+ "env/bad-opt"
589
+ );
590
+ }
591
+ var maxBytes = (schema.maxBytes !== undefined)
592
+ ? schema.maxBytes : READVAR_DEFAULT_MAX_BYTES;
593
+ var strip = schema.strip === true;
594
+
595
+ var raw = process.env[name];
596
+ var present = raw !== undefined && raw !== null && raw !== "";
597
+
598
+ if (!present) {
599
+ if (hasDefault) return schema.default;
600
+ if (required) {
601
+ throw new SafeEnvError(name + " env var is not set or is empty", "env/missing-required");
602
+ }
603
+ return undefined;
604
+ }
605
+
606
+ // Size cap. Apply BEFORE strip so an oversized value still triggers
607
+ // the diagnostic (operator gets a clear error, not a silent truncation).
608
+ if (Buffer.byteLength(raw, "utf8") > maxBytes) {
609
+ throw new SafeEnvError(
610
+ name + " exceeds " + maxBytes + " byte limit",
611
+ "env/too-large"
612
+ );
613
+ }
614
+
615
+ // enum constraint (string type only, applied to RAW value before coercion)
616
+ if (Array.isArray(schema.enum) && schema.enum.indexOf(raw) === -1) {
617
+ throw new SafeEnvError(
618
+ name + "='" + raw + "' is not one of: " + schema.enum.join(", "),
619
+ "env/bad-value"
620
+ );
621
+ }
622
+
623
+ var value;
624
+ if (type === "buffer") {
625
+ value = Buffer.from(raw, "utf8");
626
+ } else {
627
+ value = _coerceType(raw, type, name);
628
+ }
629
+
630
+ if (strip) {
631
+ delete process.env[name];
632
+ }
633
+
634
+ return value;
635
+ }
636
+
637
+ module.exports = {
638
+ parse: parse,
639
+ load: load,
640
+ readVar: readVar,
641
+ SafeEnvError: SafeEnvError,
642
+ };