@blamejs/core 0.4.1

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