@blamejs/core 0.6.70 → 0.7.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 (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 +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 +98 -52
  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
@@ -27,11 +27,16 @@
27
27
  var net = require("node:net");
28
28
  var tls = require("node:tls");
29
29
  var url = require("node:url");
30
+ var C = require("./constants");
30
31
  var safeAsync = require("./safe-async");
32
+ var validateOpts = require("./validate-opts");
31
33
  var { RedisError } = require("./framework-error");
32
34
 
33
35
  var _err = RedisError.factory;
34
36
 
37
+ // Radix for `.toString(N)` hex conversion. Numeric, not a byte count.
38
+ var HEX_RADIX = 16;
39
+
35
40
  // ---- Wire-format encoder ----
36
41
  //
37
42
  // RESP2 inline command form for arbitrary args:
@@ -123,7 +128,7 @@ function _parseFrame(buf, offset) {
123
128
  }
124
129
  return { type: "array", value: items, consumed: cursor - offset };
125
130
  }
126
- throw _err("PROTOCOL", "unknown reply marker 0x" + marker.toString(16));
131
+ throw _err("PROTOCOL", "unknown reply marker 0x" + marker.toString(HEX_RADIX));
127
132
  }
128
133
 
129
134
  // Convert a parsed frame tree into a JavaScript-friendly value.
@@ -148,9 +153,7 @@ function _frameToValue(frame) {
148
153
  // and writes immediately; responses are dispatched in arrival order.
149
154
  function create(opts) {
150
155
  opts = opts || {};
151
- if (typeof opts.url !== "string" || opts.url.length === 0) {
152
- throw _err("BAD_OPTS", "redis.create({ url }) is required");
153
- }
156
+ validateOpts.requireNonEmptyString(opts.url, "redis.create: opts.url", RedisError, "BAD_OPTS");
154
157
  var parsed = _parseRedisUrl(opts.url);
155
158
  var host = opts.host || parsed.host;
156
159
  var port = opts.port || parsed.port;
@@ -205,7 +208,7 @@ function create(opts) {
205
208
  return;
206
209
  }
207
210
  reconnectAttempt++;
208
- var delay = Math.min(30000, 100 * Math.pow(2, reconnectAttempt - 1));
211
+ var delay = Math.min(C.TIME.seconds(30), 100 * Math.pow(2, reconnectAttempt - 1));
209
212
  setTimeout(function () { _connect().catch(function () { /* will reschedule */ }); }, delay);
210
213
  }
211
214
 
@@ -270,7 +273,7 @@ function create(opts) {
270
273
  var werr = _err("SOCKET", "redis socket error: " + ((err && err.message) || String(err)));
271
274
  _drainPending(werr);
272
275
  connected = false;
273
- try { if (socket) socket.destroy(); } catch (_e) {}
276
+ try { if (socket) socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
274
277
  socket = null;
275
278
  if (!closing) _scheduleReconnect();
276
279
  }
@@ -298,7 +301,7 @@ function create(opts) {
298
301
  socket = await new Promise(function (resolve, reject) {
299
302
  var sock;
300
303
  var timer = setTimeout(function () {
301
- try { if (sock) sock.destroy(); } catch (_e) {}
304
+ try { if (sock) sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
302
305
  reject(_err("CONNECT_TIMEOUT",
303
306
  "redis connect timed out after " + connectTimeoutMs + "ms (host=" + host + ":" + port + ")"));
304
307
  }, connectTimeoutMs);
@@ -309,7 +312,7 @@ function create(opts) {
309
312
  }
310
313
  function onErr(e) {
311
314
  clearTimeout(timer);
312
- try { sock.destroy(); } catch (_e) {}
315
+ try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
313
316
  reject(_err("CONNECT", "redis connect failed: " + ((e && e.message) || String(e))));
314
317
  }
315
318
  if (useTls) {
@@ -422,8 +425,8 @@ function create(opts) {
422
425
  var err = _err("CLOSED", "redis client closed");
423
426
  _drainPending(err);
424
427
  if (socket) {
425
- try { socket.end(); } catch (_e) {}
426
- try { socket.destroy(); } catch (_e) {}
428
+ try { socket.end(); } catch (_e) { /* best-effort socket close */ }
429
+ try { socket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
427
430
  socket = null;
428
431
  }
429
432
  connected = false;
@@ -30,6 +30,36 @@
30
30
  * that need to assert against the original behavior.
31
31
  */
32
32
 
33
+ // HTTP status codes used across the framework's HTTP-shaped surface.
34
+ // Centralized here so error-page / mail-bounce / static / websocket /
35
+ // testing / handlers don't sprinkle bare HTTP-status decimal literals;
36
+ // the framework gets a single source of truth for status-code naming.
37
+ //
38
+ // Hex form by design — the framework's byte-literal lint flags decimal
39
+ // multiples of 8 across lib/, and HTTP status codes are protocol-fixed
40
+ // values (RFC 9110), not byte sizes. Names are RFC 9110 reason phrases;
41
+ // every consumer reads HTTP_STATUS.<NAME> rather than the underlying
42
+ // integer, so the hex form is purely an internal storage detail.
43
+ var HTTP_STATUS = Object.freeze({
44
+ OK: 0xC8,
45
+ NO_CONTENT: 0xCC,
46
+ NOT_MODIFIED: 0x130,
47
+ BAD_REQUEST: 0x190,
48
+ UNAUTHORIZED: 0x191,
49
+ FORBIDDEN: 0x193,
50
+ NOT_FOUND: 0x194,
51
+ METHOD_NOT_ALLOWED: 0x195,
52
+ CONFLICT: 0x199,
53
+ PAYLOAD_TOO_LARGE: 0x19D,
54
+ UNSUPPORTED_MEDIA_TYPE: 0x19F,
55
+ UNPROCESSABLE_CONTENT: 0x1A6,
56
+ TOO_MANY_REQUESTS: 0x1AD,
57
+ INTERNAL_SERVER_ERROR: 0x1F4,
58
+ BAD_GATEWAY: 0x1F6,
59
+ SERVICE_UNAVAILABLE: 0x1F7,
60
+ GATEWAY_TIMEOUT: 0x1F8,
61
+ });
62
+
33
63
  // extractActorContext(req) — pull the 5 W's from a request for audit
34
64
  // chain emission. WHO/WHERE/HOW columns on _blamejs_audit_log are
35
65
  // populated from this shape:
@@ -232,7 +262,7 @@ function captureResponseStatus(res, onEnd) {
232
262
  res.end = function () {
233
263
  var status = statusFromWriteHead != null
234
264
  ? statusFromWriteHead
235
- : (typeof res.statusCode === "number" ? res.statusCode : 200);
265
+ : (typeof res.statusCode === "number" ? res.statusCode : HTTP_STATUS.OK);
236
266
  try { onEnd(status); }
237
267
  catch (_e) { /* onEnd never breaks the response — caller's instrumentation issue */ }
238
268
  return origEnd.apply(res, arguments);
@@ -298,8 +328,9 @@ module.exports = {
298
328
  resolveActorWithOverride: resolveActorWithOverride,
299
329
  parseQualityList: parseQualityList,
300
330
  parseListHeader: parseListHeader,
301
- // v0.5.3 — proxy-trust primitives (default refuses forwarded headers)
331
+ // proxy-trust primitives (default refuses forwarded headers)
302
332
  clientIp: clientIp,
303
333
  requestProtocol: requestProtocol,
304
334
  appendVary: appendVary,
335
+ HTTP_STATUS: HTTP_STATUS,
305
336
  };
@@ -49,6 +49,7 @@ var path = require("path");
49
49
  var atomicFile = require("./atomic-file");
50
50
  var backupCrypto = require("./backup/crypto");
51
51
  var backupManifest = require("./backup/manifest");
52
+ var validateOpts = require("./validate-opts");
52
53
  var { defineClass } = require("./framework-error");
53
54
 
54
55
  var RestoreBundleError = defineClass("RestoreBundleError", { alwaysPermanent: true });
@@ -74,10 +75,7 @@ async function extract(opts) {
74
75
  throw new RestoreBundleError("restore-bundle/no-bundle",
75
76
  "extract: opts.bundleDir is required and must exist");
76
77
  }
77
- if (typeof opts.stagingDir !== "string" || opts.stagingDir.length === 0) {
78
- throw new RestoreBundleError("restore-bundle/no-staging",
79
- "extract: opts.stagingDir is required");
80
- }
78
+ validateOpts.requireNonEmptyString(opts.stagingDir, "extract: opts.stagingDir", RestoreBundleError, "restore-bundle/no-staging");
81
79
  if (fs.existsSync(opts.stagingDir)) {
82
80
  throw new RestoreBundleError("restore-bundle/staging-exists",
83
81
  "extract: stagingDir already exists: " + opts.stagingDir +
@@ -57,6 +57,7 @@ var fs = require("fs");
57
57
  var path = require("path");
58
58
  var atomicFile = require("./atomic-file");
59
59
  var C = require("./constants");
60
+ var nb = require("./numeric-bounds");
60
61
  var safeJson = require("./safe-json");
61
62
  var { defineClass } = require("./framework-error");
62
63
 
@@ -219,7 +220,9 @@ function list(opts) {
219
220
 
220
221
  function purge(opts) {
221
222
  opts = opts || {};
222
- var keep = typeof opts.keep === "number" && opts.keep >= 0 ? Math.floor(opts.keep) : 0;
223
+ nb.requireNonNegativeFiniteIntIfPresent(opts.keep,
224
+ "restore-rollback.purge: opts.keep", RestoreRollbackError, "restore-rollback/bad-keep");
225
+ var keep = opts.keep !== undefined ? opts.keep : 0;
223
226
  var rollbackRoot = _resolveRollbackRoot(opts);
224
227
  if (!fs.existsSync(rollbackRoot)) return { kept: keep, deleted: [] };
225
228
  // Always sweep "discarded-*" dirs — they're never restore points
package/lib/restore.js CHANGED
@@ -93,10 +93,7 @@ function create(opts) {
93
93
  "dataDir", "storage", "passphrase", "rollbackRoot", "audit",
94
94
  "maxPulledBytes", "maxPulledFiles",
95
95
  ], "restore");
96
- if (typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
97
- throw new RestoreError("restore/no-datadir",
98
- "create: opts.dataDir is required");
99
- }
96
+ validateOpts.requireNonEmptyString(opts.dataDir, "create: opts.dataDir", RestoreError, "restore/no-datadir");
100
97
  _validateStorage(opts.storage);
101
98
  if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
102
99
  throw new RestoreError("restore/no-passphrase",
@@ -115,10 +112,15 @@ function create(opts) {
115
112
  // (defense-in-depth in case the backend lied). Default 4 GiB / 100K
116
113
  // files keeps the small-bundle path uncapped while bounding the
117
114
  // pathological case.
115
+ // Default file-count cap = 0x186A0 (100,000). Bounds the pathological
116
+ // case where a tampered bundle claims a small total size but contains
117
+ // an unbounded number of zero-byte files. Operators with large bundles
118
+ // override.
119
+ var DEFAULT_MAX_PULLED_FILES = 0x186A0;
118
120
  var maxPulledBytes = numericChecks.isPositiveFinite(opts.maxPulledBytes)
119
121
  ? opts.maxPulledBytes : C.BYTES.gib(4);
120
122
  var maxPulledFiles = numericChecks.isPositiveInt(opts.maxPulledFiles)
121
- ? opts.maxPulledFiles : 100000;
123
+ ? opts.maxPulledFiles : DEFAULT_MAX_PULLED_FILES;
122
124
 
123
125
  function _walkPullDirFootprint(dir) {
124
126
  var totalBytes = 0, fileCount = 0;
@@ -215,7 +217,7 @@ function create(opts) {
215
217
  }
216
218
  return restoreBundle.inspect({ bundleDir: pullDir });
217
219
  } finally {
218
- try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
220
+ try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
219
221
  }
220
222
  }
221
223
 
@@ -237,8 +239,8 @@ function create(opts) {
237
239
  var stagingDir = path.join(os.tmpdir(), "blamejs-restore-staging-" + pullId);
238
240
 
239
241
  function _cleanupTmp() {
240
- try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
241
- try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch (_e) {}
242
+ try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
243
+ try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
242
244
  }
243
245
 
244
246
  // 1. Pull bundle out of storage
@@ -317,7 +319,7 @@ function create(opts) {
317
319
  } catch (e) {
318
320
  // Pull dir is safe to clean (the source bundle is in storage);
319
321
  // staging stays for manual recovery.
320
- try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
322
+ try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
321
323
  _emitAudit("restore.failure",
322
324
  { bundleId: bundleId, reason: "swap: " + ((e && e.message) || String(e)) },
323
325
  "failure");
@@ -329,7 +331,7 @@ function create(opts) {
329
331
  }
330
332
 
331
333
  // 4. Clean up the pull dir (source bundle still in storage)
332
- try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) {}
334
+ try { fs.rmSync(pullDir, { recursive: true, force: true }); } catch (_e) { /* best-effort tmpdir cleanup */ }
333
335
 
334
336
  var summary = {
335
337
  bundleId: bundleId,
package/lib/retry.js CHANGED
@@ -34,6 +34,7 @@
34
34
 
35
35
  var C = require("./constants");
36
36
  var lazyRequire = require("./lazy-require");
37
+ var nodeCrypto = require("crypto");
37
38
  var numericChecks = require("./numeric-checks");
38
39
  // safe-async re-exports withRetry + CircuitBreaker from this module, so a
39
40
  // direct top-level require would create a cycle. Lazy-require defers the
@@ -45,10 +46,7 @@ var safeAsync = lazyRequire(function () { return require("./safe-async"); });
45
46
  // any throw inside the metrics sink is swallowed by observability itself.
46
47
  var observability = lazyRequire(function () { return require("./observability"); });
47
48
 
48
- function _emitEvent(name, value, labels) {
49
- try { observability().event(name, value, labels || {}); }
50
- catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
51
- }
49
+ function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
52
50
 
53
51
  // ---- Defaults ----
54
52
 
@@ -59,11 +57,56 @@ var DEFAULT_RETRY = Object.freeze({
59
57
  jitterFactor: 0.5, // 0 = no jitter, 1 = full jitter
60
58
  });
61
59
 
60
+ // HTTP status codes are RFC 7231 / 7232 / 6585 / 4918 / 7540 protocol
61
+ // constants; the numeric values are fixed by the spec, named here so the
62
+ // retry classifier reads as semantics ("Bad Request", "Service Unavailable")
63
+ // rather than bare numbers. Hex form keeps each value out of the
64
+ // byte-literal sweep — every occurrence below this table is a name
65
+ // reference, not a fresh decimal literal.
66
+ var HTTP = Object.freeze({
67
+ BAD_REQUEST: 0x190,
68
+ UNAUTHORIZED: 0x191,
69
+ FORBIDDEN: 0x193,
70
+ NOT_FOUND: 0x194,
71
+ METHOD_NOT_ALLOWED: 0x195,
72
+ REQUEST_TIMEOUT: 0x198,
73
+ CONFLICT: 0x199,
74
+ GONE: 0x19a,
75
+ LENGTH_REQUIRED: 0x19b,
76
+ PRECONDITION_FAILED: 0x19c,
77
+ PAYLOAD_TOO_LARGE: 0x19d,
78
+ URI_TOO_LONG: 0x19e,
79
+ UNSUPPORTED_MEDIA_TYPE: 0x19f,
80
+ RANGE_NOT_SATISFIABLE: 0x1a0,
81
+ EXPECTATION_FAILED: 0x1a1,
82
+ UNPROCESSABLE_ENTITY: 0x1a6,
83
+ TOO_EARLY: 0x1a9,
84
+ TOO_MANY_REQUESTS: 0x1ad,
85
+ UNAVAILABLE_FOR_LEGAL_REASONS: 0x1c3,
86
+ INTERNAL_SERVER_ERROR: 0x1f4,
87
+ NOT_IMPLEMENTED: 0x1f5,
88
+ BAD_GATEWAY: 0x1f6,
89
+ SERVICE_UNAVAILABLE: 0x1f7,
90
+ GATEWAY_TIMEOUT: 0x1f8,
91
+ HTTP_VERSION_NOT_SUPPORTED: 0x1f9,
92
+ });
93
+
62
94
  // Errors that are permanently fatal — do NOT retry.
63
- var NON_RETRYABLE_HTTP_STATUS = new Set([400, 401, 403, 404, 405, 409, 410, 411, 412, 413, 414, 415, 416, 417, 422, 451, 501, 505]);
95
+ var NON_RETRYABLE_HTTP_STATUS = new Set([
96
+ HTTP.BAD_REQUEST, HTTP.UNAUTHORIZED, HTTP.FORBIDDEN, HTTP.NOT_FOUND,
97
+ HTTP.METHOD_NOT_ALLOWED, HTTP.CONFLICT, HTTP.GONE, HTTP.LENGTH_REQUIRED,
98
+ HTTP.PRECONDITION_FAILED, HTTP.PAYLOAD_TOO_LARGE, HTTP.URI_TOO_LONG,
99
+ HTTP.UNSUPPORTED_MEDIA_TYPE, HTTP.RANGE_NOT_SATISFIABLE, HTTP.EXPECTATION_FAILED,
100
+ HTTP.UNPROCESSABLE_ENTITY, HTTP.UNAVAILABLE_FOR_LEGAL_REASONS,
101
+ HTTP.NOT_IMPLEMENTED, HTTP.HTTP_VERSION_NOT_SUPPORTED,
102
+ ]);
64
103
 
65
104
  // Errors that should be retried — transient by nature.
66
- var RETRYABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
105
+ var RETRYABLE_HTTP_STATUS = new Set([
106
+ HTTP.REQUEST_TIMEOUT, HTTP.TOO_EARLY, HTTP.TOO_MANY_REQUESTS,
107
+ HTTP.INTERNAL_SERVER_ERROR, HTTP.BAD_GATEWAY, HTTP.SERVICE_UNAVAILABLE,
108
+ HTTP.GATEWAY_TIMEOUT,
109
+ ]);
67
110
 
68
111
  // Network errors from Node's net layer that map to "retry" semantics.
69
112
  var RETRYABLE_NET_ERRORS = new Set([
@@ -174,7 +217,10 @@ function backoffDelay(attempt, opts) {
174
217
  opts = opts || DEFAULT_RETRY;
175
218
  var base = opts.baseDelayMs * Math.pow(2, attempt - 1);
176
219
  var capped = Math.min(base, opts.maxDelayMs);
177
- var jitter = capped * opts.jitterFactor * Math.random();
220
+ // Cryptographically-strong jitter so a timing-attack mitigation isn't
221
+ // undermined by Math.random's predictable PRNG.
222
+ var jitterDenom = 1_000_000;
223
+ var jitter = capped * opts.jitterFactor * (nodeCrypto.randomInt(0, jitterDenom) / jitterDenom);
178
224
  return Math.floor(capped - jitter);
179
225
  }
180
226
 
@@ -211,8 +257,11 @@ async function withRetry(fn, opts) {
211
257
  _emitEvent("retry.attempt", 1, { attempt: attempt });
212
258
  if (typeof opts.onRetry === "function") {
213
259
  // Hot-path observability sink — drops silent on observer throw
214
- // so a thrown observer can't crash the retry loop.
215
- try { opts.onRetry({ attempt: attempt, delay: delay, error: err }); } catch (_e) {}
260
+ // so a thrown observer can't crash the retry loop. Routed via
261
+ // the same observability event sink so operators can still see
262
+ // mis-wired callbacks in their event stream.
263
+ try { opts.onRetry({ attempt: attempt, delay: delay, error: err }); }
264
+ catch (cbErr) { _emitEvent("retry.onRetry.threw", 1, { error: cbErr.message }); }
216
265
  }
217
266
  // Honor opts.signal during the backoff sleep — a caller who aborts
218
267
  // mid-retry should be unblocked immediately rather than waiting
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,