@blamejs/core 0.7.4 → 0.7.18

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 (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  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.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  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 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  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/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. 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
+ };