@blamejs/core 0.6.69 → 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 +6 -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 +27 -20
  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 +17 -4
  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 +42 -17
  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/router.js CHANGED
@@ -23,14 +23,22 @@ var http = require("http");
23
23
  var http2 = require("http2");
24
24
  var fs = require("fs");
25
25
  var path = require("path");
26
- var { URL } = require("url");
27
26
  var C = require("./constants");
27
+ var requestHelpers = require("./request-helpers");
28
28
  var safeAsync = require("./safe-async");
29
29
  var safeEnv = require("./parsers/safe-env");
30
+ var safeUrl = require("./safe-url");
30
31
  var websocket = require("./websocket");
31
32
  var { boot } = require("./log");
32
33
 
33
34
  var log = boot("router");
35
+ var HTTP_STATUS = requestHelpers.HTTP_STATUS;
36
+
37
+ // Cap on operator-defined route patterns. A route registration that
38
+ // somehow attracts a multi-megabyte template string would stall regex
39
+ // compilation; bound it before new RegExp() so the gate at registration
40
+ // time is bounded.
41
+ var MAX_ROUTE_PATTERN_LEN = C.BYTES.kib(1);
34
42
 
35
43
  // ---- Schema-spec helpers (route-level body/query/params validation) ----
36
44
 
@@ -76,7 +84,7 @@ function _writeValidationError(res, where, errors) {
76
84
  where: where,
77
85
  issues: errors,
78
86
  });
79
- res.writeHead(400, {
87
+ res.writeHead(HTTP_STATUS.BAD_REQUEST, {
80
88
  "Content-Type": "application/json; charset=utf-8",
81
89
  "Content-Length": Buffer.byteLength(body),
82
90
  });
@@ -141,11 +149,67 @@ function _makeResponseValidator(spec) {
141
149
  }
142
150
 
143
151
  function compilePattern(pattern) {
152
+ // pattern is operator-supplied at route registration (router.get(...)).
153
+ // Cap length up-front to bound matcher work even against pathological
154
+ // operator config.
155
+ if (typeof pattern !== "string" || pattern.length === 0) {
156
+ throw new Error("router: pattern must be a non-empty string");
157
+ }
158
+ if (pattern.length > MAX_ROUTE_PATTERN_LEN) {
159
+ throw new Error("router: pattern exceeds " + MAX_ROUTE_PATTERN_LEN +
160
+ " chars (got " + pattern.length + ")");
161
+ }
162
+ // Segment-based matcher — splits on "/" once at registration time and
163
+ // walks token-by-token at match time. Avoids compiling a RegExp from
164
+ // the operator-supplied pattern (which would be a dynamic-regex /
165
+ // ReDoS shape even though pattern is operator-controlled).
166
+ //
167
+ // Each segment is either:
168
+ // - a literal (e.g. "users", "v1")
169
+ // - a named parameter ":id" — captures into params[name]
170
+ //
171
+ // Matching is exact on segment count: "/a/b" does not match "/a/b/c".
172
+ var rawSegments = pattern.split("/");
173
+ var segments = [];
144
174
  var keys = [];
145
- var regexStr = pattern
146
- .replace(/:([^/]+)/g, function (_, key) { keys.push(key); return "([^/]+)"; })
147
- .replace(/\//g, "\\/");
148
- return { pattern: pattern, regex: new RegExp("^" + regexStr + "$"), keys: keys };
175
+ for (var si = 0; si < rawSegments.length; si++) {
176
+ var seg = rawSegments[si];
177
+ if (seg.length > 0 && seg.charAt(0) === ":") {
178
+ var key = seg.slice(1);
179
+ if (key.length === 0) {
180
+ throw new Error("router: pattern '" + pattern +
181
+ "' has an empty parameter name (':' segment)");
182
+ }
183
+ keys.push(key);
184
+ segments.push({ literal: false, key: key });
185
+ } else {
186
+ segments.push({ literal: true, value: seg });
187
+ }
188
+ }
189
+ return { pattern: pattern, segments: segments, keys: keys };
190
+ }
191
+
192
+ // Walk a request path against a compiled pattern. Returns the params
193
+ // object on match, null otherwise. Single non-empty trailing slash
194
+ // difference is treated as a no-match (callers that want trailing-slash
195
+ // tolerance normalize the path before dispatch).
196
+ function _matchCompiled(compiled, pathname) {
197
+ var pathSegments = pathname.split("/");
198
+ var patSegments = compiled.segments;
199
+ if (pathSegments.length !== patSegments.length) return null;
200
+ var params = {};
201
+ for (var i = 0; i < patSegments.length; i++) {
202
+ var seg = patSegments[i];
203
+ if (seg.literal) {
204
+ if (pathSegments[i] !== seg.value) return null;
205
+ } else {
206
+ // Named param: must be non-empty (mirrors the regex `[^/]+`
207
+ // capture from the previous compiled regex).
208
+ if (pathSegments[i].length === 0) return null;
209
+ params[seg.key] = pathSegments[i];
210
+ }
211
+ }
212
+ return params;
149
213
  }
150
214
 
151
215
  var MIME_TYPES = {
@@ -208,7 +272,7 @@ class Router {
208
272
 
209
273
  var closes = conns.map(function (conn) {
210
274
  return new Promise(function (resolve) {
211
- if (conn.readyState === "closed") return resolve();
275
+ if (conn.readyState === "closed") { resolve(); return; }
212
276
  conn.once("close", resolve);
213
277
  try { conn.close(code, reason); }
214
278
  catch (_e) {
@@ -230,7 +294,7 @@ class Router {
230
294
  // timeout and they didn't ack. The operator chose timeoutMs; honor it.
231
295
  this._activeWsConns.forEach(function (conn) {
232
296
  try { if (conn.socket && conn.socket.destroy) conn.socket.destroy(); }
233
- catch (_e) {}
297
+ catch (_e) { /* socket already destroyed */ }
234
298
  });
235
299
  return conns.length;
236
300
  }
@@ -432,15 +496,18 @@ class Router {
432
496
  }
433
497
 
434
498
  _match(route, pathname) {
435
- var match = pathname.match(route.regex);
436
- if (!match) return null;
437
- var params = {};
438
- route.keys.forEach((key, i) => (params[key] = match[i + 1]));
439
- return params;
499
+ return _matchCompiled(route, pathname);
440
500
  }
441
501
 
442
502
  async handle(req, res) {
443
- var parsed = new URL(req.url, "http://" + req.headers.host);
503
+ // Compose an absolute URL from the request's path + Host header so
504
+ // safeUrl.parse can validate the protocol + length. The "http://"
505
+ // base is the relative-resolution origin; the request's actual
506
+ // scheme lives in requestHelpers.requestProtocol elsewhere.
507
+ var absolute = "http://" + (req.headers.host || "localhost") + (req.url || "/");
508
+ var parsed = safeUrl.parse(absolute, {
509
+ allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
510
+ });
444
511
  req.pathname = parsed.pathname;
445
512
  req.query = Object.fromEntries(parsed.searchParams);
446
513
 
@@ -450,8 +517,9 @@ class Router {
450
517
  try {
451
518
  await mw(req, res, () => (next = true));
452
519
  } catch (mwErr) {
453
- console.error("[middleware error]", mw.name || "anonymous", req.method, req.url, mwErr.message,
454
- mwErr.stack ? mwErr.stack.split("\n").slice(0, 3).join(" | ") : "");
520
+ log.error("middleware error: " + (mw.name || "anonymous") + " " +
521
+ req.method + " " + req.url + " " + mwErr.message + " " +
522
+ (mwErr.stack ? mwErr.stack.split("\n").slice(0, 3).join(" | ") : ""));
455
523
  throw mwErr;
456
524
  }
457
525
  if (!next || res.writableEnded) return;
@@ -486,7 +554,7 @@ class Router {
486
554
  if (this.notFoundHandler) {
487
555
  this.notFoundHandler(req, res);
488
556
  } else {
489
- res.writeHead(404, { "Content-Type": "text/html" });
557
+ res.writeHead(HTTP_STATUS.NOT_FOUND, { "Content-Type": "text/html" });
490
558
  res.end("<h1>404 Not Found</h1>");
491
559
  }
492
560
  }
@@ -515,7 +583,7 @@ class Router {
515
583
  var requestHandler = (req, res) => {
516
584
  // Response helpers
517
585
  res.json = (data) => {
518
- res.writeHead(res.statusCode || 200, { "Content-Type": "application/json" });
586
+ res.writeHead(res.statusCode || HTTP_STATUS.OK, { "Content-Type": "application/json" });
519
587
  res.end(JSON.stringify(data));
520
588
  };
521
589
  res.redirect = (url) => {
@@ -525,6 +593,7 @@ class Router {
525
593
  if (typeof url === "string" && url.startsWith("/") && !url.startsWith("//")) {
526
594
  safe = url;
527
595
  }
596
+ // 302 Found — RFC 7231 §6.4.3. Not in HTTP_STATUS table.
528
597
  res.writeHead(302, { Location: safe });
529
598
  res.end();
530
599
  };
@@ -534,17 +603,17 @@ class Router {
534
603
  };
535
604
 
536
605
  self.handle(req, res).catch((err) => {
537
- console.error("[route error]", req.method, req.url, err.message,
538
- err.stack ? err.stack.split("\n").slice(0, 5).join(" | ") : "");
606
+ log.error("route error: " + req.method + " " + req.url + " " + err.message + " " +
607
+ (err.stack ? err.stack.split("\n").slice(0, 5).join(" | ") : ""));
539
608
  if (self.errorHandler) {
540
609
  try { self.errorHandler(err, req, res); } catch (_) {
541
610
  if (!res.writableEnded) {
542
- res.writeHead(500, { "Content-Type": "text/plain" });
611
+ res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "text/plain" });
543
612
  res.end("Internal Server Error");
544
613
  }
545
614
  }
546
615
  } else if (!res.writableEnded) {
547
- res.writeHead(500, { "Content-Type": "text/plain" });
616
+ res.writeHead(HTTP_STATUS.INTERNAL_SERVER_ERROR, { "Content-Type": "text/plain" });
548
617
  res.end("Internal Server Error");
549
618
  }
550
619
  });
@@ -594,8 +663,8 @@ class Router {
594
663
  "Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n" +
595
664
  "\r\n" +
596
665
  body;
597
- try { socket.write(resp); } catch (_e) {}
598
- try { socket.destroy(); } catch (_e) {}
666
+ try { socket.write(resp); } catch (_e) { /* socket already closed */ }
667
+ try { socket.destroy(); } catch (_e) { /* socket already destroyed */ }
599
668
  return;
600
669
  }
601
670
  var conn = websocket.handleUpgrade(req, socket, head, route.opts);
@@ -618,14 +687,14 @@ class Router {
618
687
  var pathname = String(headers[":path"] || "/").split("?")[0];
619
688
  var route = self._wsRoutes.get(pathname);
620
689
  if (!route) {
621
- try { stream.respond({ ":status": 404 }); stream.end(); } catch (_e) {}
690
+ try { stream.respond({ ":status": 404 }); stream.end(); } catch (_e) { /* stream already closed */ }
622
691
  return;
623
692
  }
624
693
  if (route.transport === "h1-only") {
625
694
  try {
626
695
  stream.respond({ ":status": 405, "content-type": "text/plain; charset=utf-8" });
627
696
  stream.end("WebSocket on this path requires HTTP/1.1 Upgrade");
628
- } catch (_e) {}
697
+ } catch (_e) { /* stream already closed */ }
629
698
  return;
630
699
  }
631
700
  var conn = websocket.handleExtendedConnect(stream, headers, route.opts);
@@ -664,7 +733,7 @@ function serveStatic(dir) {
664
733
  var cacheControl = hasVersion
665
734
  ? "public, max-age=31536000, immutable"
666
735
  : "public, max-age=3600";
667
- res.writeHead(200, {
736
+ res.writeHead(HTTP_STATUS.OK, {
668
737
  "Content-Type": mime,
669
738
  "Content-Length": stat.size,
670
739
  "Cache-Control": cacheControl,
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
  }