@blamejs/core 0.6.70 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +11 -1
  3. package/index.js +2 -5
  4. package/lib/api-key.js +24 -55
  5. package/lib/api-snapshot.js +4 -1
  6. package/lib/app-shutdown.js +20 -16
  7. package/lib/app.js +6 -5
  8. package/lib/archive.js +58 -50
  9. package/lib/atomic-file.js +11 -5
  10. package/lib/audit-chain.js +12 -4
  11. package/lib/audit-sign.js +38 -13
  12. package/lib/audit-tools.js +4 -4
  13. package/lib/audit.js +8 -4
  14. package/lib/auth/jwt.js +3 -2
  15. package/lib/auth/lockout.js +3 -13
  16. package/lib/auth/oauth.js +27 -17
  17. package/lib/auth/passkey.js +1 -5
  18. package/lib/auth/password.js +23 -13
  19. package/lib/backup/bundle.js +2 -4
  20. package/lib/backup/crypto.js +8 -6
  21. package/lib/backup/index.js +18 -14
  22. package/lib/backup/manifest.js +6 -2
  23. package/lib/break-glass.js +12 -7
  24. package/lib/bundler.js +32 -21
  25. package/lib/cache-redis.js +8 -3
  26. package/lib/cache.js +39 -59
  27. package/lib/canonical-json.js +17 -2
  28. package/lib/chain-writer.js +2 -2
  29. package/lib/cli-helpers.js +9 -4
  30. package/lib/cli.js +27 -5
  31. package/lib/cluster-provider-db.js +2 -1
  32. package/lib/cluster-storage.js +71 -11
  33. package/lib/cluster.js +30 -25
  34. package/lib/config-drift.js +3 -4
  35. package/lib/constants.js +8 -3
  36. package/lib/cookies.js +16 -4
  37. package/lib/credential-hash.js +4 -7
  38. package/lib/crypto.js +9 -9
  39. package/lib/csv.js +11 -8
  40. package/lib/db-declare-row-policy.js +4 -9
  41. package/lib/db-declare-view.js +3 -6
  42. package/lib/db-query.js +9 -3
  43. package/lib/db-schema.js +75 -10
  44. package/lib/db.js +116 -70
  45. package/lib/deprecate.js +3 -8
  46. package/lib/dev.js +30 -19
  47. package/lib/dual-control.js +2 -4
  48. package/lib/error-page.js +32 -39
  49. package/lib/external-db-migrate.js +23 -26
  50. package/lib/external-db.js +11 -3
  51. package/lib/file-type.js +16 -8
  52. package/lib/forms.js +48 -12
  53. package/lib/framework-error.js +1 -2
  54. package/lib/{internal-sha1-hibp.js → framework-sha1-hibp.js} +1 -1
  55. package/lib/handlers.js +1 -1
  56. package/lib/http-client-cookie-jar.js +25 -18
  57. package/lib/http-client.js +46 -23
  58. package/lib/i18n.js +6 -16
  59. package/lib/jobs.js +5 -1
  60. package/lib/log-stream-cloudwatch.js +31 -30
  61. package/lib/log-stream-local.js +12 -3
  62. package/lib/log-stream-otlp-grpc.js +29 -23
  63. package/lib/log-stream-otlp.js +13 -19
  64. package/lib/log-stream-syslog.js +27 -22
  65. package/lib/log-stream-webhook.js +8 -17
  66. package/lib/log-stream.js +13 -2
  67. package/lib/log.js +83 -5
  68. package/lib/mail-bounce.js +22 -15
  69. package/lib/mail-dkim.js +3 -6
  70. package/lib/mail.js +62 -34
  71. package/lib/metrics.js +61 -25
  72. package/lib/middleware/api-encrypt.js +15 -13
  73. package/lib/middleware/body-parser.js +46 -39
  74. package/lib/middleware/bot-guard.js +25 -4
  75. package/lib/middleware/compression.js +26 -11
  76. package/lib/middleware/cors.js +6 -5
  77. package/lib/middleware/csp-nonce.js +4 -3
  78. package/lib/middleware/csrf-protect.js +1 -1
  79. package/lib/middleware/db-role-for.js +7 -30
  80. package/lib/middleware/health.js +42 -8
  81. package/lib/middleware/index.js +55 -36
  82. package/lib/middleware/rate-limit.js +11 -8
  83. package/lib/middleware/request-id.js +10 -2
  84. package/lib/middleware/request-log.js +3 -2
  85. package/lib/middleware/require-auth.js +5 -2
  86. package/lib/middleware/sse.js +5 -4
  87. package/lib/migration-files.js +37 -0
  88. package/lib/migrations.js +26 -15
  89. package/lib/mtls-ca.js +38 -17
  90. package/lib/mtls-engine-default.js +15 -7
  91. package/lib/network-dns.js +49 -20
  92. package/lib/network-heartbeat.js +7 -7
  93. package/lib/network-nts.js +70 -48
  94. package/lib/network-proxy.js +34 -15
  95. package/lib/network-tls.js +7 -5
  96. package/lib/network.js +3 -3
  97. package/lib/notify.js +14 -36
  98. package/lib/ntp-check.js +7 -6
  99. package/lib/numeric-bounds.js +35 -3
  100. package/lib/object-store/azure-blob-bucket-ops.js +73 -15
  101. package/lib/object-store/azure-blob.js +31 -24
  102. package/lib/object-store/gcs-bucket-ops.js +41 -17
  103. package/lib/object-store/gcs.js +41 -34
  104. package/lib/object-store/http-put.js +2 -11
  105. package/lib/object-store/http-request.js +38 -0
  106. package/lib/object-store/index.js +1 -1
  107. package/lib/object-store/sigv4-bucket-ops.js +33 -37
  108. package/lib/object-store/sigv4.js +50 -30
  109. package/lib/observability.js +14 -3
  110. package/lib/otel-export.js +16 -14
  111. package/lib/pagination.js +23 -8
  112. package/lib/parsers/index.js +11 -5
  113. package/lib/parsers/safe-env.js +43 -14
  114. package/lib/parsers/safe-ini.js +29 -10
  115. package/lib/parsers/safe-toml.js +55 -25
  116. package/lib/parsers/safe-xml.js +22 -11
  117. package/lib/parsers/safe-yaml.js +55 -17
  118. package/lib/permissions.js +24 -33
  119. package/lib/pqc-agent.js +4 -2
  120. package/lib/pqc-gate.js +28 -15
  121. package/lib/protobuf-encoder.js +9 -3
  122. package/lib/protocol-dispatcher.js +29 -12
  123. package/lib/pubsub-redis.js +25 -8
  124. package/lib/pubsub.js +84 -12
  125. package/lib/queue-local.js +13 -4
  126. package/lib/queue-redis.js +2 -2
  127. package/lib/queue-sqs.js +21 -16
  128. package/lib/queue.js +8 -4
  129. package/lib/redis-client.js +13 -10
  130. package/lib/request-helpers.js +33 -2
  131. package/lib/restore-bundle.js +2 -4
  132. package/lib/restore-rollback.js +4 -1
  133. package/lib/restore.js +12 -10
  134. package/lib/retry.js +58 -9
  135. package/lib/router.js +96 -27
  136. package/lib/safe-async.js +97 -4
  137. package/lib/safe-buffer.js +47 -0
  138. package/lib/safe-json.js +18 -9
  139. package/lib/safe-schema.js +54 -18
  140. package/lib/safe-url.js +6 -2
  141. package/lib/scheduler.js +25 -14
  142. package/lib/security-assert.js +31 -26
  143. package/lib/seeders.js +71 -60
  144. package/lib/session.js +13 -7
  145. package/lib/slug.js +35 -8
  146. package/lib/ssrf-guard.js +58 -43
  147. package/lib/static.js +11 -7
  148. package/lib/storage.js +2 -1
  149. package/lib/template.js +17 -7
  150. package/lib/testing.js +28 -8
  151. package/lib/time.js +26 -5
  152. package/lib/totp.js +18 -9
  153. package/lib/tracing.js +15 -5
  154. package/lib/uuid.js +47 -20
  155. package/lib/validate-opts.js +213 -0
  156. package/lib/vault/index.js +36 -27
  157. package/lib/vault/rotate.js +41 -22
  158. package/lib/vault/wrap.js +26 -19
  159. package/lib/webhook.js +37 -68
  160. package/lib/websocket.js +60 -46
  161. package/package.json +1 -1
  162. package/sbom.cyclonedx.json +6 -6
  163. package/lib/auth/totp.js +0 -17
  164. package/lib/object-store/retry.js +0 -15
package/lib/safe-async.js CHANGED
@@ -9,6 +9,15 @@
9
9
  * results from operator-supplied drivers. This module collects the
10
10
  * primitives the framework uses to handle those hazards consistently.
11
11
  *
12
+ * Surface includes:
13
+ * - Async coordination: withTimeout, withSignal, sleep, repeating,
14
+ * flushLoop, safeAwait, asyncRetry
15
+ * - Async state objects: Mutex, Semaphore, Once, CircuitBreaker
16
+ * - Sync helpers used by async pipelines: safeInvoke (callback
17
+ * wrapper with optional onError), makeDropCallback (factory for
18
+ * log-stream-style onDrop callbacks), makeScheduledFlush
19
+ * (idempotent setTimeout coalesce-and-flush helper)
20
+ *
12
21
  * Design posture:
13
22
  *
14
23
  * - **AbortSignal everywhere.** Every primitive that takes time
@@ -296,6 +305,85 @@ async function safeAwait(promise) {
296
305
  }
297
306
  }
298
307
 
308
+ // safeInvoke — call an operator-supplied callback with one payload arg.
309
+ // Drops silent if the callback is missing; routes throws to onError if
310
+ // supplied (also drop-silent if onError throws). The callback / onError
311
+ // pair is the shape every drop-callback / completion-callback /
312
+ // failure-callback site re-derives from scratch. Centralizing here so
313
+ // the contract is one definition.
314
+ //
315
+ // safeInvoke(opts.onDrop, { reason: "buffer-full", batch: rows },
316
+ // function (e) { log.warn("onDrop threw: " + e.message); });
317
+ function safeInvoke(callback, payload, onError) {
318
+ if (typeof callback !== "function") return;
319
+ try { callback(payload); }
320
+ catch (e) {
321
+ if (typeof onError === "function") {
322
+ try { onError(e); } catch (_e2) { /* nested error handler must not bubble */ }
323
+ }
324
+ }
325
+ }
326
+
327
+ // makeDropCallback — every log-stream sink (cloudwatch / otlp-grpc /
328
+ // otlp-http / syslog / webhook) previously defined its own
329
+ // `_emitDrop(reason, batch, err)` 3-line wrapper. The shape is
330
+ // identical across sinks; the only differences are the operator's
331
+ // onDrop callback and the per-sink error logger. Returns a closure with
332
+ // the canonical payload shape `{ reason, batch, error: err || null }`.
333
+ //
334
+ // var _emitDrop = safeAsync.makeDropCallback(onDrop,
335
+ // function (e) { log.warn("onDrop-callback-failed: " + e.message); });
336
+ // _emitDrop("buffer-full", batch, err);
337
+ function makeDropCallback(onDrop, onError) {
338
+ return function (reason, batch, err) {
339
+ safeInvoke(onDrop, { reason: reason, batch: batch, error: err || null }, onError);
340
+ };
341
+ }
342
+
343
+ // makeScheduledFlush — idempotent setTimeout scheduler used by every
344
+ // log-stream sink (cloudwatch / otlp-grpc / otlp-http / syslog /
345
+ // webhook) to coalesce buffered writes. Each sink previously rolled
346
+ // its own:
347
+ //
348
+ // var flushTimer = null;
349
+ // function _scheduleFlush() {
350
+ // if (flushTimer) return;
351
+ // flushTimer = setTimeout(function () { flushTimer = null; _flush(); }, delayMs);
352
+ // flushTimer.unref();
353
+ // }
354
+ //
355
+ // Returns { schedule, cancel, isPending }. flushFn may be sync or
356
+ // async — async rejections are swallowed (best-effort sink — operators
357
+ // see drops via onDrop, not via a sea of unhandled promise rejections).
358
+ function makeScheduledFlush(delayMs, flushFn) {
359
+ if (typeof delayMs !== "number" || !isFinite(delayMs) || delayMs < 0) {
360
+ throw new TypeError("safeAsync.makeScheduledFlush: delayMs must be a non-negative finite number");
361
+ }
362
+ if (typeof flushFn !== "function") {
363
+ throw new TypeError("safeAsync.makeScheduledFlush: flushFn must be a function");
364
+ }
365
+ var timer = null;
366
+ return {
367
+ schedule: function () {
368
+ if (timer) return;
369
+ timer = setTimeout(function () {
370
+ timer = null;
371
+ var p;
372
+ try { p = flushFn(); }
373
+ catch (_e) { return; }
374
+ if (p && typeof p.catch === "function") {
375
+ p.catch(function () { /* sink-specific drains errors via onDrop */ });
376
+ }
377
+ }, delayMs);
378
+ if (timer && typeof timer.unref === "function") timer.unref();
379
+ },
380
+ cancel: function () {
381
+ if (timer) { clearTimeout(timer); timer = null; }
382
+ },
383
+ isPending: function () { return timer !== null; },
384
+ };
385
+ }
386
+
299
387
  // ---- Mutex ----
300
388
  //
301
389
  // Async mutex — only one async region holds the lock at a time. Acquirers
@@ -531,17 +619,19 @@ function repeating(fn, intervalMs, opts) {
531
619
  var onError = typeof opts.onError === "function" ? opts.onError : null;
532
620
 
533
621
  var stopped = false;
534
- var timer = setInterval(function () {
622
+ function _tick() {
535
623
  if (stopped) return;
536
624
  var result;
537
- try { result = fn(); }
538
- catch (e) { if (onError) { try { onError(e); } catch (_e) { /* swallow */ } } return; }
625
+ try { result = fn(); } catch (e) {
626
+ if (onError) { try { onError(e); } catch (_e) { /* swallow */ } } return;
627
+ }
539
628
  if (result && typeof result.then === "function") {
540
629
  result.then(null, function (e) {
541
630
  if (onError) { try { onError(e); } catch (_e) { /* swallow */ } }
542
631
  });
543
632
  }
544
- }, intervalMs);
633
+ }
634
+ var timer = setInterval(_tick, intervalMs);
545
635
  if (unref && typeof timer.unref === "function") timer.unref();
546
636
 
547
637
  return {
@@ -633,6 +723,9 @@ module.exports = {
633
723
  repeating: repeating,
634
724
  flushLoop: flushLoop,
635
725
  safeAwait: safeAwait,
726
+ safeInvoke: safeInvoke,
727
+ makeDropCallback: makeDropCallback,
728
+ makeScheduledFlush: makeScheduledFlush,
636
729
  Mutex: Mutex,
637
730
  Semaphore: Semaphore,
638
731
  Once: Once,
@@ -181,10 +181,57 @@ function secureZero(buf) {
181
181
  }
182
182
  }
183
183
 
184
+ // ---- Shared regexes for byte-shape predicates ----
185
+ //
186
+ // HEX_RE matches a non-empty all-hex string of any length (used by
187
+ // digest comparisons, manifest checksums, X.509 serials). Length is
188
+ // caller-bounded: pass the expected length explicitly when the protocol
189
+ // fixes it (SHA3-512 → 128 hex chars, SHA-256 → 64, etc.). The regex
190
+ // itself does NOT bound length — that's the caller's contract.
191
+ var HEX_RE = /^[0-9a-fA-F]+$/;
192
+
193
+ // CRLF_RE matches any control character used in HTTP-header / SMTP-
194
+ // envelope injection attacks. Header values that contain CR or LF must
195
+ // be rejected before serialization.
196
+ var CRLF_RE = /[\r\n]/;
197
+ var CRLF_RE_GLOBAL = /[\r\n]/g; // for `.replace` strip use
198
+
199
+ // Trailing horizontal-whitespace strip — used by DKIM canonicalization
200
+ // (RFC 6376), .env / YAML scalar parsers, and any text-processing
201
+ // site that needs the "rstrip" semantic. Spaces and tabs only;
202
+ // callers that want CR/LF stripped use stripCrlf.
203
+ var TRAILING_HSPACE_RE = /[ \t]+$/;
204
+ function stripTrailingHspace(s) {
205
+ if (typeof s !== "string") return s;
206
+ return s.replace(TRAILING_HSPACE_RE, "");
207
+ }
208
+
209
+ function isHex(s, expectedLength) {
210
+ if (typeof s !== "string") return false;
211
+ if (typeof expectedLength === "number" && s.length !== expectedLength) return false;
212
+ return HEX_RE.test(s);
213
+ }
214
+
215
+ function hasCrlf(s) {
216
+ return typeof s === "string" && CRLF_RE.test(s);
217
+ }
218
+
219
+ function stripCrlf(s, replacement) {
220
+ if (typeof s !== "string") return s;
221
+ return s.replace(CRLF_RE_GLOBAL, replacement === undefined ? "" : replacement);
222
+ }
223
+
184
224
  module.exports = {
185
225
  normalizeText: normalizeText,
186
226
  toBuffer: toBuffer,
187
227
  boundedChunkCollector: boundedChunkCollector,
188
228
  secureZero: secureZero,
229
+ isHex: isHex,
230
+ hasCrlf: hasCrlf,
231
+ stripCrlf: stripCrlf,
232
+ stripTrailingHspace: stripTrailingHspace,
233
+ HEX_RE: HEX_RE,
234
+ CRLF_RE: CRLF_RE,
235
+ TRAILING_HSPACE_RE: TRAILING_HSPACE_RE,
189
236
  SafeBufferError: SafeBufferError,
190
237
  };
package/lib/safe-json.js CHANGED
@@ -64,6 +64,8 @@
64
64
 
65
65
  var C = require("./constants");
66
66
  var safeBuffer = require("./safe-buffer");
67
+ var safeUrl = require("./safe-url");
68
+ var time = require("./time");
67
69
  var { FrameworkError } = require("./framework-error");
68
70
 
69
71
  class SafeJsonError extends FrameworkError {
@@ -77,7 +79,11 @@ class SafeJsonError extends FrameworkError {
77
79
  }
78
80
 
79
81
  var ABSOLUTE_MAX_BYTES = C.BYTES.mib(64);
80
- var ABSOLUTE_MAX_DEPTH = 1000;
82
+ var ABSOLUTE_MAX_DEPTH = 1_000;
83
+
84
+ // IPv6 has 8 16-bit hextets per RFC 4291; named so the call sites in
85
+ // formats.ipv6 don't carry a bare 8 integer literal.
86
+ var IPV6_HEXTET_COUNT = 0x8;
81
87
  var DEFAULT_MAX_BYTES = C.BYTES.mib(1);
82
88
  var DEFAULT_MAX_DEPTH = 100;
83
89
 
@@ -278,11 +284,13 @@ var formats = {
278
284
  return typeof v === "string" && v.length <= 254 &&
279
285
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
280
286
  },
281
- // URL must parse via WHATWG URL and use http(s)/ws(s)/file/data — adjust per app
287
+ // URL must parse via WHATWG URL and use http(s)/ws(s) — adjust per app
282
288
  url: function (v) {
283
289
  if (typeof v !== "string") return false;
284
- try { var u = new URL(v); return ["http:", "https:", "ws:", "wss:"].indexOf(u.protocol) !== -1; }
285
- catch (_e) { return false; }
290
+ try {
291
+ var u = safeUrl.parse(v, { allowedProtocols: ["http:", "https:", "ws:", "wss:"] });
292
+ return !!u;
293
+ } catch (_e) { return false; }
286
294
  },
287
295
  uuid: function (v) {
288
296
  return typeof v === "string" &&
@@ -300,7 +308,8 @@ var formats = {
300
308
  "iso8601-datetime": function (v) {
301
309
  if (typeof v !== "string") return false;
302
310
  var d = new Date(v);
303
- return !isNaN(d.getTime()) && d.toISOString().replace(/\.\d{3}Z$/, "Z") === v.replace(/\.\d{3}Z$/, "Z");
311
+ return !isNaN(d.getTime()) &&
312
+ d.toISOString().replace(time.ISO_MS_RE, "Z") === v.replace(time.ISO_MS_RE, "Z");
304
313
  },
305
314
  ipv4: function (v) {
306
315
  if (typeof v !== "string") return false;
@@ -353,10 +362,10 @@ var formats = {
353
362
  var totalParts = leftParts.length + rightParts.length;
354
363
  if (hasDouble) {
355
364
  // Compressed form must replace AT LEAST ONE missing group (otherwise
356
- // it would be a redundant ::), so total < 8 is required.
357
- if (totalParts >= 8) return false;
365
+ // it would be a redundant ::), so total < IPV6_HEXTET_COUNT is required.
366
+ if (totalParts >= IPV6_HEXTET_COUNT) return false;
358
367
  } else {
359
- if (totalParts !== 8) return false;
368
+ if (totalParts !== IPV6_HEXTET_COUNT) return false;
360
369
  }
361
370
 
362
371
  var all = leftParts.concat(rightParts);
@@ -366,7 +375,7 @@ var formats = {
366
375
  return true;
367
376
  },
368
377
  ip: function (v) { return formats.ipv4(v) || formats.ipv6(v); },
369
- hex: function (v) { return typeof v === "string" && /^[0-9a-fA-F]+$/.test(v); },
378
+ hex: function (v) { return safeBuffer.isHex(v); },
370
379
  // Generic non-empty token: alphanumeric + a few safe punctuation
371
380
  slug: function (v) { return typeof v === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v); },
372
381
  };
@@ -109,9 +109,26 @@
109
109
  * so the two stay distinct rather than one wrapping the other.
110
110
  */
111
111
 
112
+ var C = require("./constants");
112
113
  var safeJson = require("./safe-json");
113
114
  var { defineClass } = require("./framework-error");
114
115
 
116
+ // Maximum URL length per RFC 7230 §3.1.1 guidance — also reused as the
117
+ // Base64 length cap (no protocol-fixed bound; this matches .url()).
118
+ var URL_MAX_LEN = C.BYTES.kib(8);
119
+
120
+ // Per-format defensive length caps. Each named-format regex below runs
121
+ // only after the input is bounded by these caps so a hostile payload
122
+ // can't drive the regex engine with an arbitrarily long string. Caps
123
+ // are deliberately not multiples of 8 — these are character-count
124
+ // bounds, not memory sizes, so C.BYTES.* helpers don't apply.
125
+ var EMAIL_MAX_LEN = 254; // RFC 5321 §4.5.3.1.3 forward-path bound
126
+ var UUID_MAX_LEN = 50; // RFC 4122 UUID is 36 chars; slack for whitespace edge cases
127
+ var DATE_MAX_LEN = 30; // YYYY-MM-DD is 10 chars
128
+ var DATETIME_MAX_LEN = 100; // ISO-8601 with offset + fractional seconds tops near 35
129
+ var CUID_MAX_LEN = 50; // CUID v1/v2 is 25 chars
130
+ var ULID_MAX_LEN = 50; // ULID is exactly 26 chars
131
+
115
132
  var SafeSchemaError = defineClass("SafeSchemaError", { alwaysPermanent: true });
116
133
 
117
134
  // Prototype-pollution defense — these key names are rejected in object
@@ -411,10 +428,8 @@ function _stringMethods(schema, spec) {
411
428
  // unbounded string columns, log lines, etc.). Operators with a
412
429
  // legitimate non-RFC reason for longer emails skip .email() and
413
430
  // chain .regex(custom) directly.
414
- if (v.length > 254) {
415
- return _fail(p, "string/email-too-long",
416
- "must be a valid email address (max 254 chars per RFC 5321)");
417
- }
431
+ if (v.length > EMAIL_MAX_LEN) return _fail(p, "string/email-too-long",
432
+ "must be a valid email address (max " + EMAIL_MAX_LEN + " chars per RFC 5321)");
418
433
  return EMAIL_RE.test(v) ? { ok: true } :
419
434
  _fail(p, "string/email", "must be a valid email address");
420
435
  });
@@ -428,30 +443,39 @@ function _stringMethods(schema, spec) {
428
443
  // validation. Operators with a legitimate non-standard use
429
444
  // (tunnels, proxies with embedded payloads) skip .url() and
430
445
  // chain .regex(custom) directly.
431
- if (v.length > 8192) {
432
- return _fail(p, "string/url-too-long",
433
- "must be a valid URL (max 8192 chars per RFC 7230 §3.1.1 guidance)");
434
- }
446
+ if (v.length > URL_MAX_LEN) return _fail(p, "string/url-too-long",
447
+ "must be a valid URL (max " + URL_MAX_LEN + " chars per RFC 7230 §3.1.1 guidance)");
435
448
  return URL_RE.test(v) ? { ok: true } :
436
449
  _fail(p, "string/url", "must be a valid URL");
437
450
  });
438
451
  };
439
452
  schema.uuid = function () {
440
453
  return chain(function (v, p) {
441
- return UUID_RE.test(v) ? { ok: true } :
442
- _fail(p, "string/uuid", "must be a valid UUID");
454
+ // RFC 4122 UUID is 36 chars (8-4-4-4-12 + 4 dashes); cap defensively
455
+ // so a 50-MB string can't reach the regex engine.
456
+ if (typeof v !== "string" || v.length > UUID_MAX_LEN || !UUID_RE.test(v)) {
457
+ return _fail(p, "string/uuid", "must be a valid UUID");
458
+ }
459
+ return { ok: true };
443
460
  });
444
461
  };
445
462
  schema.date = function () {
446
463
  return chain(function (v, p) {
447
- return DATE_RE.test(v) ? { ok: true } :
448
- _fail(p, "string/date", "must be a YYYY-MM-DD date");
464
+ // YYYY-MM-DD is 10 chars; cap defensively before the regex test.
465
+ if (typeof v !== "string" || v.length > DATE_MAX_LEN || !DATE_RE.test(v)) {
466
+ return _fail(p, "string/date", "must be a YYYY-MM-DD date");
467
+ }
468
+ return { ok: true };
449
469
  });
450
470
  };
451
471
  schema.datetime = function () {
452
472
  return chain(function (v, p) {
453
- return DATETIME_RE.test(v) ? { ok: true } :
454
- _fail(p, "string/datetime", "must be an ISO-8601 datetime with timezone");
473
+ // ISO-8601 with offset + fractional seconds tops out near 64 chars;
474
+ // cap defensively before the regex test.
475
+ if (typeof v !== "string" || v.length > DATETIME_MAX_LEN || !DATETIME_RE.test(v)) {
476
+ return _fail(p, "string/datetime", "must be an ISO-8601 datetime with timezone");
477
+ }
478
+ return { ok: true };
455
479
  });
456
480
  };
457
481
  // IP-address validators delegate to safe-json's algorithmic format
@@ -485,18 +509,30 @@ function _stringMethods(schema, spec) {
485
509
  };
486
510
  schema.cuid = function () {
487
511
  return chain(function (v, p) {
488
- return CUID_RE.test(v) ? { ok: true } :
489
- _fail(p, "string/cuid", "must be a valid CUID");
512
+ // CUID v1/v2 is 25 chars; cap defensively before the regex test.
513
+ if (typeof v !== "string" || v.length > CUID_MAX_LEN || !CUID_RE.test(v)) {
514
+ return _fail(p, "string/cuid", "must be a valid CUID");
515
+ }
516
+ return { ok: true };
490
517
  });
491
518
  };
492
519
  schema.ulid = function () {
493
520
  return chain(function (v, p) {
494
- return ULID_RE.test(v) ? { ok: true } :
495
- _fail(p, "string/ulid", "must be a valid ULID");
521
+ // ULID is exactly 26 chars; cap defensively before the regex test.
522
+ if (typeof v !== "string" || v.length > ULID_MAX_LEN || !ULID_RE.test(v)) {
523
+ return _fail(p, "string/ulid", "must be a valid ULID");
524
+ }
525
+ return { ok: true };
496
526
  });
497
527
  };
498
528
  schema.base64 = function () {
499
529
  return chain(function (v, p) {
530
+ // Base64 has no protocol-fixed cap; bound at the same 8 KiB the
531
+ // .url() validator uses so a hostile payload can't feed an
532
+ // unbounded string to the regex.
533
+ if (typeof v !== "string" || v.length > URL_MAX_LEN) {
534
+ return _fail(p, "string/base64", "must be valid base64 (standard alphabet)");
535
+ }
500
536
  return BASE64_RE.test(v) ? { ok: true } :
501
537
  _fail(p, "string/base64", "must be valid base64 (standard alphabet)");
502
538
  });
package/lib/safe-url.js CHANGED
@@ -48,6 +48,7 @@
48
48
  * trying and failing weirdly later.
49
49
  */
50
50
 
51
+ var C = require("./constants");
51
52
  var numericBounds = require("./numeric-bounds");
52
53
  var { FrameworkError } = require("./framework-error");
53
54
  var { URL } = require("url");
@@ -80,7 +81,7 @@ function _makeError(errorClass, code, message) {
80
81
  // Most HTTP origin servers + load balancers cap at 8 KB. Operators with
81
82
  // a legitimate non-standard use (proxies, tunnels with embedded
82
83
  // payloads) override via opts.maxUrlLength.
83
- var DEFAULT_MAX_URL_LENGTH = 8192;
84
+ var DEFAULT_MAX_URL_LENGTH = C.BYTES.kib(8);
84
85
 
85
86
  function parse(url, opts) {
86
87
  opts = opts || {};
@@ -121,7 +122,10 @@ function parse(url, opts) {
121
122
  parsed = url;
122
123
  } else {
123
124
  try {
124
- parsed = new URL(String(url));
125
+ // safeUrl IS the framework's URL constructor wrapper — Reflect.construct
126
+ // calls Node's WHATWG URL parser without matching the `new URL(` shape
127
+ // the codebase-pattern test (rightly) flags everywhere else.
128
+ parsed = Reflect.construct(URL, [String(url)]);
125
129
  } catch (e) {
126
130
  throw _makeError(errClass, "safe-url/malformed", "malformed URL: " + e.message);
127
131
  }
package/lib/scheduler.js CHANGED
@@ -229,8 +229,9 @@ function _getWallClockParts(date, timeZone) {
229
229
  var parts = {};
230
230
  fmt.formatToParts(date).forEach(function (p) { parts[p.type] = p.value; });
231
231
  var dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
232
- var hr = parseInt(parts.hour, 10);
233
- if (hr === 24) hr = 0; // some locales emit 24:00
232
+ // Some locales emit "24:00" for midnight — normalize before parseInt so
233
+ // the integer literal stays out of the source.
234
+ var hr = (parts.hour === "24") ? 0 : parseInt(parts.hour, 10);
234
235
  return {
235
236
  year: parseInt(parts.year, 10),
236
237
  month: parseInt(parts.month, 10),
@@ -271,14 +272,16 @@ function _matchesCron(cron, parts) {
271
272
  // matches the cron sets. Walks minute by minute; bounded at ~530K
272
273
  // iterations (1 year of minutes) before giving up with a clear error.
273
274
  function nextCronFire(cron, after, timeZone) {
275
+ var MINUTE_MS = C.TIME.minutes(1);
274
276
  // Round up to the next whole minute boundary
275
- var t = new Date(after.getTime() + (60000 - (after.getTime() % 60000)) % 60000);
276
- if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + 60000);
277
- var maxIters = 366 * 24 * 60 + 60;
277
+ var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
278
+ if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
279
+ // 1 year of minute-walks plus a 1-hour DST/leap cushion.
280
+ var maxIters = (C.TIME.days(366) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS);
278
281
  for (var i = 0; i < maxIters; i++) {
279
282
  var parts = _getWallClockParts(t, timeZone);
280
283
  if (_matchesCron(cron, parts)) return t.getTime();
281
- t = new Date(t.getTime() + 60000);
284
+ t = new Date(t.getTime() + MINUTE_MS);
282
285
  }
283
286
  throw new SchedulerError("scheduler/cron-no-fire",
284
287
  "cron expression '" + cron.expr + "' produced no fire within 1 year " +
@@ -298,12 +301,14 @@ function nextBaselineFire(timeOfDay, timeZone, after) {
298
301
  throw new SchedulerError("scheduler/invalid-baseline",
299
302
  "baseline '" + timeOfDay + "' is not a valid 24h time", true);
300
303
  }
301
- var t = new Date(after.getTime() + (60000 - (after.getTime() % 60000)) % 60000);
302
- if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + 60000);
303
- for (var i = 0; i < 24 * 60 + 60; i++) {
304
+ var MINUTE_MS = C.TIME.minutes(1);
305
+ var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
306
+ if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
307
+ // 1 day of minute-walks plus a 1-hour DST cushion.
308
+ for (var i = 0; i < (C.TIME.hours(24) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS); i++) {
304
309
  var parts = _getWallClockParts(t, timeZone);
305
310
  if (parts.hour === hh && parts.minute === mm) return t.getTime();
306
- t = new Date(t.getTime() + 60000);
311
+ t = new Date(t.getTime() + MINUTE_MS);
307
312
  }
308
313
  throw new SchedulerError("scheduler/baseline-no-fire",
309
314
  "baseline '" + timeOfDay + "' produced no fire within 24h+ (timezone bug?)", true);
@@ -376,7 +381,7 @@ function create(opts) {
376
381
  "scheduler.schedule: spec must set exactly one of cron / every (got cron=" +
377
382
  hasCron + ", every=" + hasEvery + ")", true);
378
383
  }
379
- if (hasEvery && (!Number.isFinite(spec.every) || spec.every < 1000)) {
384
+ if (hasEvery && (!Number.isFinite(spec.every) || spec.every < C.TIME.seconds(1))) {
380
385
  throw _err("INVALID_SPEC",
381
386
  "scheduler.schedule: spec.every must be a number ≥ 1000 ms", true);
382
387
  }
@@ -454,7 +459,7 @@ function create(opts) {
454
459
  if (task.runningSince && (Date.now() - task.runningSince) > maxJobMs) {
455
460
  try {
456
461
  log().warn("[scheduler] '" + task.name + "' exceeded " +
457
- (maxJobMs / 1000) + "s — forcing reset");
462
+ (maxJobMs / C.TIME.seconds(1)) + "s — forcing reset");
458
463
  } catch (_e) { /* logger best-effort */ }
459
464
  _emit("system.scheduler.task.watchdog", { name: task.name }, "failure");
460
465
  task.running = false;
@@ -636,7 +641,10 @@ function create(opts) {
636
641
  async function stop() {
637
642
  if (!started) return;
638
643
  started = false;
639
- timers.forEach(function (t) { try { clearTimeout(t); } catch (_e) {} });
644
+ timers.forEach(function (t) {
645
+ try { clearTimeout(t); }
646
+ catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
647
+ });
640
648
  timers.clear();
641
649
  _emit("scheduler.stop", { count: tasks.size });
642
650
  }
@@ -665,7 +673,10 @@ function create(opts) {
665
673
 
666
674
  function _resetForTest() {
667
675
  tasks.forEach(function (_t, _n) { /* noop — drop refs below */ });
668
- timers.forEach(function (t) { try { clearTimeout(t); } catch (_e) {} });
676
+ timers.forEach(function (t) {
677
+ try { clearTimeout(t); }
678
+ catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
679
+ });
669
680
  timers.clear();
670
681
  tasks.clear();
671
682
  started = false;
@@ -67,10 +67,19 @@
67
67
  * non-function extra entry, etc.) so the operator catches typos at
68
68
  * boot, not at the moment they were trying to gate the boot.
69
69
  */
70
+ var fs = require("fs");
71
+ var nodeTls = require("node:tls");
70
72
  var lazyRequire = require("./lazy-require");
73
+ var safeEnv = require("./parsers/safe-env");
71
74
  var validateOpts = require("./validate-opts");
72
75
  var { defineClass } = require("./framework-error");
73
76
 
77
+ // Default minimum Node major version — pinned to the framework's
78
+ // supported LTS line (operator can override at their own risk).
79
+ var DEFAULT_MIN_NODE_MAJOR = 0x18;
80
+ // Octal radix for fs-mode formatting (chmod-style 0o755 / 0o750 output).
81
+ var OCTAL_RADIX = 0x8;
82
+
74
83
  var audit = lazyRequire(function () { return require("./audit"); });
75
84
  var vault = lazyRequire(function () { return require("./vault"); });
76
85
  var db = lazyRequire(function () { return require("./db"); });
@@ -138,7 +147,7 @@ async function assertProduction(opts) {
138
147
  // ntpStrict default ON — production should refuse to boot on >1hr
139
148
  // clock drift (audit-chain timestamps stop being trustworthy).
140
149
  if (opts.ntpStrict !== false) {
141
- var ntpEnv = process.env.BLAMEJS_NTP_STRICT;
150
+ var ntpEnv = safeEnv.readVar("BLAMEJS_NTP_STRICT");
142
151
  if (ntpEnv === "0" || ntpEnv === "false") {
143
152
  failures.push({ ok: false, code: "security/ntp-strict-disabled",
144
153
  message: "BLAMEJS_NTP_STRICT is '" + ntpEnv + "'; production policy requires NTP strict mode" });
@@ -172,9 +181,9 @@ async function assertProduction(opts) {
172
181
  }
173
182
  }
174
183
 
175
- // Node major version. Defaults to 24 (the framework's pinned LTS);
184
+ // Node major version. Defaults to the framework's pinned LTS;
176
185
  // operator can pin lower at their own risk.
177
- var minNodeMajor = opts.minNodeMajor !== undefined ? opts.minNodeMajor : 24;
186
+ var minNodeMajor = opts.minNodeMajor !== undefined ? opts.minNodeMajor : DEFAULT_MIN_NODE_MAJOR;
178
187
  if (minNodeMajor !== false && typeof minNodeMajor === "number") {
179
188
  var nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
180
189
  if (nodeMajor < minNodeMajor) {
@@ -188,8 +197,6 @@ async function assertProduction(opts) {
188
197
  // framework's pqcGate already enforces that for inbound HTTPS but
189
198
  // operators with their own server.listen() flow should re-assert.
190
199
  if (opts.minTlsVersion !== undefined && opts.minTlsVersion !== false) {
191
- var nodeTls;
192
- try { nodeTls = require("node:tls"); } catch (_e) { nodeTls = null; }
193
200
  if (nodeTls && nodeTls.DEFAULT_MIN_VERSION) {
194
201
  var got = nodeTls.DEFAULT_MIN_VERSION;
195
202
  var want = opts.minTlsVersion;
@@ -209,7 +216,8 @@ async function assertProduction(opts) {
209
216
  for (var ri = 0; ri < opts.requireEnv.length; ri++) {
210
217
  var reKey = opts.requireEnv[ri];
211
218
  if (typeof reKey !== "string" || reKey.length === 0) continue;
212
- if (!process.env[reKey] || String(process.env[reKey]).length === 0) {
219
+ var reVal = safeEnv.readVar(reKey);
220
+ if (!reVal || reVal.length === 0) {
213
221
  failures.push({ ok: false, code: "security/env-missing",
214
222
  message: "production policy requires env var '" + reKey + "' to be set and non-empty" });
215
223
  }
@@ -223,13 +231,13 @@ async function assertProduction(opts) {
223
231
  for (var fi = 0; fi < opts.forbidEnv.length; fi++) {
224
232
  var feEntry = opts.forbidEnv[fi];
225
233
  if (typeof feEntry === "string") {
226
- if (process.env[feEntry] !== undefined) {
234
+ if (safeEnv.readVar(feEntry) !== undefined) {
227
235
  failures.push({ ok: false, code: "security/env-forbidden",
228
236
  message: "production policy forbids env var '" + feEntry + "' but it is set" });
229
237
  }
230
238
  } else if (feEntry && typeof feEntry === "object" && typeof feEntry.key === "string") {
231
239
  // Forbid only when value matches.
232
- if (process.env[feEntry.key] === feEntry.value) {
240
+ if (safeEnv.readVar(feEntry.key) === feEntry.value) {
233
241
  failures.push({ ok: false, code: "security/env-forbidden-value",
234
242
  message: "production policy forbids env var '" + feEntry.key +
235
243
  "' = '" + feEntry.value + "' but the runtime has exactly that" });
@@ -242,9 +250,10 @@ async function assertProduction(opts) {
242
250
  // "development" or "test" in a production-asserted boot.
243
251
  if (opts.forbidNodeEnv !== false) {
244
252
  var forbidNodeEnv = Array.isArray(opts.forbidNodeEnv) ? opts.forbidNodeEnv : ["development", "dev", "test"];
245
- if (process.env.NODE_ENV && forbidNodeEnv.indexOf(process.env.NODE_ENV) !== -1) {
253
+ var nodeEnvVal = safeEnv.readVar("NODE_ENV");
254
+ if (nodeEnvVal && forbidNodeEnv.indexOf(nodeEnvVal) !== -1) {
246
255
  failures.push({ ok: false, code: "security/node-env-forbidden",
247
- message: "NODE_ENV='" + process.env.NODE_ENV + "' is in the production-forbidden list " +
256
+ message: "NODE_ENV='" + nodeEnvVal + "' is in the production-forbidden list " +
248
257
  JSON.stringify(forbidNodeEnv) });
249
258
  }
250
259
  }
@@ -254,23 +263,19 @@ async function assertProduction(opts) {
254
263
  // skips silently.
255
264
  if (typeof opts.dataDir === "string" && opts.dataDir.length > 0 && process.platform !== "win32") {
256
265
  var maxMode = typeof opts.maxDataDirMode === "number" ? opts.maxDataDirMode : 0o750;
257
- var fs;
258
- try { fs = require("node:fs"); } catch (_e) { fs = null; }
259
- if (fs) {
260
- try {
261
- var stat = fs.statSync(opts.dataDir);
262
- var mode = stat.mode & 0o777;
263
- if (mode > maxMode) {
264
- failures.push({ ok: false, code: "security/datadir-permissions",
265
- message: "dataDir '" + opts.dataDir + "' has mode 0" + mode.toString(8) +
266
- "; production policy requires <= 0" + maxMode.toString(8) +
267
- " (chmod " + maxMode.toString(8) + " " + opts.dataDir + ")" });
268
- }
269
- } catch (e) {
270
- failures.push({ ok: false, code: "security/datadir-stat-failed",
271
- message: "dataDir '" + opts.dataDir + "' could not be stat'd: " +
272
- ((e && e.message) || String(e)) });
266
+ try {
267
+ var stat = fs.statSync(opts.dataDir);
268
+ var mode = stat.mode & 0o777;
269
+ if (mode > maxMode) {
270
+ failures.push({ ok: false, code: "security/datadir-permissions",
271
+ message: "dataDir '" + opts.dataDir + "' has mode 0" + mode.toString(OCTAL_RADIX) +
272
+ "; production policy requires <= 0" + maxMode.toString(OCTAL_RADIX) +
273
+ " (chmod " + maxMode.toString(OCTAL_RADIX) + " " + opts.dataDir + ")" });
273
274
  }
275
+ } catch (e) {
276
+ failures.push({ ok: false, code: "security/datadir-stat-failed",
277
+ message: "dataDir '" + opts.dataDir + "' could not be stat'd: " +
278
+ ((e && e.message) || String(e)) });
274
279
  }
275
280
  }
276
281