@blamejs/core 0.8.0 → 0.8.5

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 (65) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/lib/audit-sign.js +1 -1
  3. package/lib/audit.js +62 -2
  4. package/lib/auth/jwt.js +13 -0
  5. package/lib/auth/lockout.js +16 -3
  6. package/lib/auth/oauth.js +15 -1
  7. package/lib/auth/password.js +22 -2
  8. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  9. package/lib/auth/sd-jwt-vc.js +7 -2
  10. package/lib/break-glass.js +53 -14
  11. package/lib/cache-redis.js +1 -1
  12. package/lib/cache.js +6 -1
  13. package/lib/cli.js +3 -3
  14. package/lib/cluster.js +24 -1
  15. package/lib/compliance-ai-act-logging.js +7 -3
  16. package/lib/compliance.js +10 -2
  17. package/lib/config-drift.js +2 -2
  18. package/lib/crypto-field.js +21 -1
  19. package/lib/crypto.js +82 -1
  20. package/lib/db.js +35 -4
  21. package/lib/dev.js +30 -3
  22. package/lib/dual-control.js +19 -1
  23. package/lib/external-db.js +10 -0
  24. package/lib/file-upload.js +30 -3
  25. package/lib/flag.js +1 -1
  26. package/lib/guard-all.js +33 -16
  27. package/lib/guard-csv.js +16 -2
  28. package/lib/guard-html.js +35 -0
  29. package/lib/guard-svg.js +20 -0
  30. package/lib/http-client.js +57 -11
  31. package/lib/inbox.js +34 -10
  32. package/lib/log-stream-syslog.js +8 -0
  33. package/lib/log-stream.js +1 -1
  34. package/lib/mail.js +40 -0
  35. package/lib/middleware/attach-user.js +25 -2
  36. package/lib/middleware/bearer-auth.js +71 -6
  37. package/lib/middleware/body-parser.js +13 -0
  38. package/lib/middleware/cors.js +10 -0
  39. package/lib/middleware/csrf-protect.js +34 -3
  40. package/lib/middleware/dpop.js +3 -3
  41. package/lib/middleware/host-allowlist.js +1 -1
  42. package/lib/middleware/index.js +3 -0
  43. package/lib/middleware/require-aal.js +2 -2
  44. package/lib/middleware/require-mtls.js +179 -0
  45. package/lib/middleware/trace-propagate.js +1 -1
  46. package/lib/mtls-ca.js +23 -29
  47. package/lib/mtls-engine-default.js +21 -1
  48. package/lib/network-tls.js +21 -6
  49. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  50. package/lib/observability-otlp-exporter.js +35 -2
  51. package/lib/outbox.js +3 -3
  52. package/lib/permissions.js +10 -1
  53. package/lib/pqc-agent.js +22 -1
  54. package/lib/pubsub.js +8 -4
  55. package/lib/redact.js +26 -1
  56. package/lib/retention.js +26 -0
  57. package/lib/router.js +1 -0
  58. package/lib/scheduler.js +57 -1
  59. package/lib/session.js +3 -3
  60. package/lib/ssrf-guard.js +19 -4
  61. package/lib/static.js +12 -0
  62. package/lib/totp.js +16 -0
  63. package/lib/ws-client.js +158 -9
  64. package/package.json +3 -2
  65. package/sbom.cyclonedx.json +6 -6
package/lib/retention.js CHANGED
@@ -58,6 +58,7 @@
58
58
  var C = require("./constants");
59
59
  var lazyRequire = require("./lazy-require");
60
60
  var validateOpts = require("./validate-opts");
61
+ var safeSql = require("./safe-sql");
61
62
  var { defineClass } = require("./framework-error");
62
63
 
63
64
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -66,6 +67,21 @@ var cryptoField = require("./crypto-field");
66
67
  var RetentionError = defineClass("RetentionError", { alwaysPermanent: true });
67
68
  var _err = RetentionError.factory;
68
69
 
70
+ // Identifier-level SQLi defense: every operator-supplied table name,
71
+ // column name, and cascade FK must pass safeSql.validateIdentifier
72
+ // before reaching SQL string concatenation. Without this gate a
73
+ // rule registered with `table: 'users"; DROP TABLE audit_log;--'`
74
+ // would break out of the quoted-identifier wrap and execute the
75
+ // embedded statement.
76
+ function _validateRuleIdentifier(value, label) {
77
+ try {
78
+ safeSql.validateIdentifier(value, { allowReserved: true });
79
+ } catch (e) {
80
+ throw _err("BAD_RULE",
81
+ label + " is not a safe SQL identifier: " + (e && e.message || String(e)));
82
+ }
83
+ }
84
+
69
85
  function _validateRule(rule) {
70
86
  if (!rule || typeof rule !== "object") {
71
87
  throw _err("BAD_RULE", "rule must be an object");
@@ -76,9 +92,11 @@ function _validateRule(rule) {
76
92
  if (typeof rule.table !== "string" || rule.table.length === 0) {
77
93
  throw _err("BAD_RULE", "rule.table (string) is required");
78
94
  }
95
+ _validateRuleIdentifier(rule.table, "rule.table");
79
96
  if (typeof rule.ageField !== "string" || rule.ageField.length === 0) {
80
97
  throw _err("BAD_RULE", "rule.ageField (string) is required");
81
98
  }
99
+ _validateRuleIdentifier(rule.ageField, "rule.ageField");
82
100
  if (typeof rule.ttlMs !== "number" || !isFinite(rule.ttlMs) || rule.ttlMs <= 0) {
83
101
  throw _err("BAD_RULE", "rule.ttlMs must be a positive finite number");
84
102
  }
@@ -99,10 +117,16 @@ function _validateRule(rule) {
99
117
  (typeof rule.softDeleteField !== "string" || rule.softDeleteField.length === 0)) {
100
118
  throw _err("BAD_RULE", "rule.softDeleteField must be a non-empty string");
101
119
  }
120
+ if (rule.softDeleteField !== undefined) {
121
+ _validateRuleIdentifier(rule.softDeleteField, "rule.softDeleteField");
122
+ }
102
123
  if (rule.legalHoldField !== undefined &&
103
124
  (typeof rule.legalHoldField !== "string" || rule.legalHoldField.length === 0)) {
104
125
  throw _err("BAD_RULE", "rule.legalHoldField must be a non-empty string");
105
126
  }
127
+ if (rule.legalHoldField !== undefined) {
128
+ _validateRuleIdentifier(rule.legalHoldField, "rule.legalHoldField");
129
+ }
106
130
  if (rule.cascade !== undefined) {
107
131
  if (!Array.isArray(rule.cascade) || rule.cascade.length === 0) {
108
132
  throw _err("BAD_RULE", "rule.cascade must be a non-empty array of { table, foreignKey } entries");
@@ -113,6 +137,8 @@ function _validateRule(rule) {
113
137
  typeof c.foreignKey !== "string" || c.foreignKey.length === 0) {
114
138
  throw _err("BAD_RULE", "rule.cascade[" + ci + "] must be { table: string, foreignKey: string }");
115
139
  }
140
+ _validateRuleIdentifier(c.table, "rule.cascade[" + ci + "].table");
141
+ _validateRuleIdentifier(c.foreignKey, "rule.cascade[" + ci + "].foreignKey");
116
142
  }
117
143
  }
118
144
  if (rule.stages !== undefined) {
package/lib/router.js CHANGED
@@ -651,6 +651,7 @@ class Router {
651
651
  maxHeaderListPairs: 100, // allow:raw-byte-literal — CVE-2024-27983 CONTINUATION-flood cap
652
652
  maxSettings: 32, // allow:raw-byte-literal — SETTINGS-frame entry ceiling
653
653
  peerMaxConcurrentStreams: 100, // allow:raw-byte-literal — peer-side stream cap
654
+ maxOutstandingPings: 10, // allow:raw-byte-literal — CVE-2019-9512 ping-flood cap (pin to Node default rather than letting it drift)
654
655
  unknownProtocolTimeout: C.TIME.seconds(10),
655
656
  }, tlsOptions), requestHandler);
656
657
  } else {
package/lib/scheduler.js CHANGED
@@ -682,8 +682,63 @@ function create(opts) {
682
682
  started = false;
683
683
  }
684
684
 
685
- return {
685
+ // Shorthand for the common interval-based registration shape:
686
+ // register("rotate-keys", C.TIME.minutes(5), runFn)
687
+ // is equivalent to schedule({ name, every: 300000, run: runFn }).
688
+ // Operators wanting cron expressions or job-queue dispatch keep
689
+ // using schedule() — register() is the every-N-ms direct-function
690
+ // path. Returns the scheduler instance for method chaining.
691
+ function register(name, intervalMs, fn) {
692
+ if (typeof name !== "string" || name.length === 0) {
693
+ throw _err("INVALID_NAME", "scheduler.register: name must be a non-empty string", true);
694
+ }
695
+ if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs < C.TIME.seconds(1)) {
696
+ throw _err("INVALID_SPEC",
697
+ "scheduler.register: intervalMs must be a finite number ≥ 1000", true);
698
+ }
699
+ if (typeof fn !== "function") {
700
+ throw _err("INVALID_SPEC", "scheduler.register: fn must be a function", true);
701
+ }
702
+ schedule({ name: name, every: intervalMs, run: fn });
703
+ return facade;
704
+ }
705
+
706
+ // Operator-facing health surface — every task with its lifecycle
707
+ // counters plus an aggregate. Probes / dashboards / readiness gates
708
+ // get a single object they can serialize. This is `list()` plus
709
+ // started state and aggregate stats.
710
+ function getStatus() {
711
+ var taskList = list();
712
+ var aggregate = {
713
+ total: taskList.length,
714
+ running: 0,
715
+ withErrors: 0,
716
+ totalFires: 0,
717
+ totalMisses: 0,
718
+ nonLeaderSkips: 0,
719
+ tickClaimLost: 0,
720
+ };
721
+ for (var i = 0; i < taskList.length; i++) {
722
+ var t = taskList[i];
723
+ if (t.running) aggregate.running += 1;
724
+ if (t.lastError) aggregate.withErrors += 1;
725
+ aggregate.totalFires += t.fires || 0;
726
+ aggregate.totalMisses += t.misses || 0;
727
+ aggregate.nonLeaderSkips += t.nonLeaderSkips || 0;
728
+ aggregate.tickClaimLost += t.tickClaimLost || 0;
729
+ }
730
+ return {
731
+ started: started,
732
+ isLeader: _isLeaderHere(),
733
+ tasks: taskList,
734
+ aggregate: aggregate,
735
+ };
736
+ }
737
+
738
+ var facade = {
686
739
  schedule: schedule,
740
+ register: register,
741
+ getStatus: getStatus,
687
742
  start: start,
688
743
  stop: stop,
689
744
  list: list,
@@ -695,6 +750,7 @@ function create(opts) {
695
750
  },
696
751
  _resetForTest: _resetForTest,
697
752
  };
753
+ return facade;
698
754
  }
699
755
 
700
756
  module.exports = {
package/lib/session.js CHANGED
@@ -238,7 +238,7 @@ async function verify(token, verifyOpts) {
238
238
  if ((nowMs - lastActivity) > idleMs) {
239
239
  try {
240
240
  audit.safeEmit({
241
- action: "auth.session.expired_idle", outcome: "warning",
241
+ action: "auth.session.expired_idle", outcome: "success",
242
242
  metadata: { idleMs: nowMs - lastActivity, threshold: idleMs },
243
243
  });
244
244
  } catch (_ignored) { /* audit best-effort */ }
@@ -253,7 +253,7 @@ async function verify(token, verifyOpts) {
253
253
  if ((nowMs - createdAt) > absMs) {
254
254
  try {
255
255
  audit.safeEmit({
256
- action: "auth.session.expired_absolute", outcome: "warning",
256
+ action: "auth.session.expired_absolute", outcome: "success",
257
257
  metadata: { ageMs: nowMs - createdAt, threshold: absMs },
258
258
  });
259
259
  } catch (_ignored) { /* audit best-effort */ }
@@ -334,7 +334,7 @@ async function verify(token, verifyOpts) {
334
334
  try {
335
335
  audit.safeEmit({
336
336
  action: "auth.session.fingerprint_drift",
337
- outcome: "warning",
337
+ outcome: "success",
338
338
  metadata: { hasUserId: !!unsealed.userId,
339
339
  anomalyScore: fingerprintAnomalyScore },
340
340
  });
package/lib/ssrf-guard.js CHANGED
@@ -140,10 +140,25 @@ var CLOUD_METADATA_IPS = [
140
140
 
141
141
  function _ipv4ToInt(ip) {
142
142
  var parts = ip.split(".");
143
- return ((parts[0] | 0) << 24 >>> 0) +
144
- ((parts[1] | 0) << 16) +
145
- ((parts[2] | 0) << 8) +
146
- (parts[3] | 0);
143
+ if (parts.length !== 4) return NaN;
144
+ var nums = [0, 0, 0, 0];
145
+ for (var i = 0; i < 4; i += 1) {
146
+ var s = parts[i];
147
+ // Strict octet validation: each segment must be 1-3 ASCII digits
148
+ // representing 0-255. The previous `parts[i] | 0` coerced
149
+ // anything non-numeric to 0 silently — exposed via cidrContains
150
+ // (network-allowlist) where a typo'd CIDR could collapse to
151
+ // 0.0.0.0/16 with no signal.
152
+ if (typeof s !== "string" || s.length === 0 || s.length > 3) return NaN;
153
+ if (!/^\d{1,3}$/.test(s)) return NaN;
154
+ var n = parseInt(s, 10);
155
+ if (n < 0 || n > 255) return NaN;
156
+ nums[i] = n;
157
+ }
158
+ return ((nums[0] << 24) >>> 0) +
159
+ (nums[1] << 16) +
160
+ (nums[2] << 8) +
161
+ nums[3];
147
162
  }
148
163
 
149
164
  function _ipv6ToBytes(ip) {
package/lib/static.js CHANGED
@@ -100,6 +100,12 @@ var DEFAULT_CONTENT_TYPES = {
100
100
  var DEFAULTS = Object.freeze({
101
101
  defaultMaxAge: DEFAULT_MAX_AGE_SEC,
102
102
  acceptRanges: true,
103
+ // Per-range byte cap — slowloris-range defense. A single Range
104
+ // request that asks for 1 GiB pins a worker on a long read; many
105
+ // concurrent requests asking for the same exhaust the process pool.
106
+ // The cap rejects ranges larger than maxRangeBytes with 416. Set to
107
+ // Infinity to opt out (audited reason).
108
+ maxRangeBytes: C.BYTES.mib(64),
103
109
  // Empty array = no MIME allowlist gate.
104
110
  allowedFileTypes: Object.freeze([]),
105
111
  // Bandwidth + concurrency caps default to 0 = "no cap". Operators opt
@@ -844,6 +850,12 @@ function create(opts) {
844
850
  return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
845
851
  "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
846
852
  }
853
+ if (range && cfg.maxRangeBytes !== Infinity && range.length > cfg.maxRangeBytes) {
854
+ stats.failures += 1;
855
+ _emitObs("staticServe.range_too_large", 1, { route: urlPath });
856
+ return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_too_large",
857
+ "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
858
+ }
847
859
  if (range) {
848
860
  stats.rangeRequests += 1;
849
861
  _emitObs("staticServe.range_requests", 1, { route: urlPath });
package/lib/totp.js CHANGED
@@ -134,6 +134,22 @@ function _resolveOpts(opts) {
134
134
  throw new AuthError("auth-totp/bad-alg",
135
135
  "algorithm must be one of " + SUPPORTED_ALGORITHMS.join(", ") + " (got: " + alg + ")");
136
136
  }
137
+ // SHA-256 is supported for back-compat with authenticator apps that
138
+ // don't yet honor SHA-512. Emit an audit signal each time it's
139
+ // selected so operator compliance dashboards see which accounts run
140
+ // on the weaker hash and can plan the migration.
141
+ if (alg === "sha256") {
142
+ setImmediate(function () {
143
+ try {
144
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense
145
+ auditMod.safeEmit({
146
+ action: "auth.totp.algorithm_downgraded",
147
+ outcome: "success",
148
+ metadata: { algorithm: alg, frameworkDefault: DEFAULT_ALGORITHM },
149
+ });
150
+ } catch (_e) { /* drop-silent */ }
151
+ });
152
+ }
137
153
  var digits = opts.digits != null ? opts.digits : DEFAULT_DIGITS;
138
154
  if (typeof digits !== "number" || digits < 6 || digits > 10) {
139
155
  throw new AuthError("auth-totp/bad-digits", "digits must be 6–10 (got: " + digits + ")");
package/lib/ws-client.js CHANGED
@@ -58,6 +58,8 @@ var fwCrypto = lazyRequire(function () { return require("./crypto"); });
58
58
  var websocket = lazyRequire(function () { return require("./websocket"); });
59
59
  var audit = lazyRequire(function () { return require("./audit"); });
60
60
  var networkTls = lazyRequire(function () { return require("./network-tls"); });
61
+ var safeJson = lazyRequire(function () { return require("./safe-json"); });
62
+ var ssrfGuard = require("./ssrf-guard");
61
63
  var C = require("./constants");
62
64
  var { defineClass } = require("./framework-error");
63
65
 
@@ -164,9 +166,27 @@ function connect(target, opts) {
164
166
  "maxMessageBytes", "maxFrameBytes",
165
167
  "handshakeTimeoutMs", "reconnect",
166
168
  "permessageDeflate", "audit", "origin",
167
- "handshakeGuid",
169
+ "handshakeGuid", "allowInternal",
170
+ "parse", "parser",
171
+ "urlFor", "tlsOptsFor",
168
172
  ], "wsClient.connect");
169
173
 
174
+ // Per-dial state-injection callbacks. Both fire on every connect
175
+ // attempt (initial + each reconnect) so operators can rotate
176
+ // bearer tokens, DNS targets, or TLS material between hops without
177
+ // tearing the client down.
178
+ // urlFor(attempt) -> string — overrides target URL
179
+ // tlsOptsFor(attempt) -> object — overrides TLS opts
180
+ // attempt is a 0-based hop index; 0 is the initial dial.
181
+ if (opts.urlFor != null && typeof opts.urlFor !== "function") {
182
+ throw new WsClientError("ws-client/bad-url-for",
183
+ "wsClient.connect: urlFor must be a function");
184
+ }
185
+ if (opts.tlsOptsFor != null && typeof opts.tlsOptsFor !== "function") {
186
+ throw new WsClientError("ws-client/bad-tls-opts-for",
187
+ "wsClient.connect: tlsOptsFor must be a function");
188
+ }
189
+
170
190
  // Operators with a non-RFC-6455 GUID (private protocols on top of
171
191
  // the WebSocket framing layer, framework-specific handshake variants)
172
192
  // pass a custom handshakeGuid. The server-side b.websocket already
@@ -219,8 +239,29 @@ function connect(target, opts) {
219
239
  permessageDeflate: permessageDeflate,
220
240
  auditOn: auditOn,
221
241
  handshakeGuid: handshakeGuid,
242
+ allowInternal: opts.allowInternal,
243
+ parse: opts.parse || null,
244
+ parser: typeof opts.parser === "function" ? opts.parser : null,
245
+ urlFor: typeof opts.urlFor === "function" ? opts.urlFor : null,
246
+ tlsOptsFor: typeof opts.tlsOptsFor === "function" ? opts.tlsOptsFor : null,
247
+ });
248
+ // SSRF gate — refuse private / loopback / link-local / cloud-metadata /
249
+ // reserved IP destinations by default. Symmetric to b.httpClient. The
250
+ // returned `ips` are pinned through tls.connect / net.connect so the
251
+ // actual TCP connect targets the validated address (closes the DNS-
252
+ // rebinding TOCTOU window). Cloud-metadata IPs are unconditional
253
+ // hard-deny — `allowInternal: true` does not bypass them.
254
+ var hostnameForUrl = parsed.protocol === "wss:" ? "https:" : "http:";
255
+ var probeUrl = new url.URL(hostnameForUrl + "//" + parsed.host + parsed.pathname + parsed.search);
256
+ ssrfGuard.checkUrl(probeUrl, {
257
+ allowInternal: opts.allowInternal,
258
+ errorClass: WsClientError,
259
+ }).then(function (result) {
260
+ client._ssrfPinnedIps = result && result.ips;
261
+ client._dial();
262
+ }).catch(function (e) {
263
+ setImmediate(function () { client._handleSocketError(e); });
222
264
  });
223
- client._dial();
224
265
  return client;
225
266
  }
226
267
 
@@ -278,13 +319,74 @@ class WsClient extends EventEmitter {
278
319
  var opts = this._opts;
279
320
  this._readyState = "connecting";
280
321
 
281
- var parsed = opts.parsedUrl;
322
+ // Per-dial overrides — urlFor swaps the target URL, tlsOptsFor
323
+ // overrides TLS material. Both fire every dial including reconnects
324
+ // so callers can rotate state. urlFor's result is re-validated
325
+ // through ssrfGuard so a hostile upstream can't direct the client
326
+ // at a private address mid-reconnect.
327
+ var attempt = this._reconnectAttempt || 0;
328
+ var dialTarget = opts.target;
329
+ var dialParsed = opts.parsedUrl;
330
+ if (typeof opts.urlFor === "function") {
331
+ try {
332
+ var nextTarget = opts.urlFor(attempt);
333
+ if (typeof nextTarget === "string" && nextTarget.length > 0 && nextTarget !== dialTarget) {
334
+ dialParsed = _parseUrl(nextTarget);
335
+ dialTarget = nextTarget;
336
+ var probeProto = dialParsed.protocol === "wss:" ? "https:" : "http:";
337
+ var probeUrl = new url.URL(probeProto + "//" + dialParsed.host + dialParsed.pathname + dialParsed.search);
338
+ var probe = ssrfGuard.checkUrl(probeUrl, {
339
+ allowInternal: opts.allowInternal,
340
+ errorClass: WsClientError,
341
+ });
342
+ this._ssrfPinnedIps = probe && probe.ips ? probe.ips : null;
343
+ }
344
+ } catch (e) {
345
+ return self._handleSocketError(e);
346
+ }
347
+ }
348
+
349
+ var dialTlsOpts = opts.tlsOpts;
350
+ if (typeof opts.tlsOptsFor === "function") {
351
+ try {
352
+ var override = opts.tlsOptsFor(attempt);
353
+ if (override && typeof override === "object") {
354
+ dialTlsOpts = Object.assign({}, opts.tlsOpts || {}, override);
355
+ }
356
+ } catch (e) {
357
+ return self._handleSocketError(e);
358
+ }
359
+ }
360
+
361
+ var parsed = dialParsed;
282
362
  var port = parsed.port ? parseInt(parsed.port, 10) :
283
363
  (parsed.protocol === "wss:" ? 443 : 80); // allow:raw-byte-literal — TLS / HTTP default port
284
364
  var host = parsed.hostname;
285
365
 
286
366
  function _onError(err) { self._handleSocketError(err); }
287
367
 
368
+ // Pin the connect to the SSRF-validated IPs returned by
369
+ // ssrfGuard.checkUrl — closes the DNS-rebinding TOCTOU window where
370
+ // the gate resolves a public IP and the kernel re-resolves to a
371
+ // private one between the check and the connect.
372
+ var pinnedIps = self._ssrfPinnedIps || null;
373
+ var lookup = null;
374
+ if (pinnedIps && pinnedIps.length > 0) {
375
+ lookup = function (h, lookupOpts, cb) {
376
+ // Node's lookup callback signatures:
377
+ // (err, address, family) for legacy { all: false } (default)
378
+ // (err, addresses) for { all: true }
379
+ if (typeof lookupOpts === "function") { cb = lookupOpts; lookupOpts = {}; }
380
+ var first = pinnedIps[0];
381
+ if (lookupOpts && lookupOpts.all) {
382
+ return cb(null, pinnedIps.map(function (ip) {
383
+ return { address: ip.address, family: ip.family };
384
+ }));
385
+ }
386
+ return cb(null, first.address, first.family);
387
+ };
388
+ }
389
+
288
390
  var socket;
289
391
  if (parsed.protocol === "wss:") {
290
392
  var tls = require("tls"); // allow:inline-require — node:tls only on TLS path
@@ -294,7 +396,8 @@ class WsClient extends EventEmitter {
294
396
  servername: host,
295
397
  rejectUnauthorized: true,
296
398
  minVersion: "TLSv1.3",
297
- }, opts.tlsOpts || {});
399
+ }, dialTlsOpts || {});
400
+ if (lookup) tlsOpts.lookup = lookup;
298
401
  try {
299
402
  var pqcShares = networkTls().pqc.getKeyShares();
300
403
  if (Array.isArray(pqcShares) && pqcShares.length > 0 && !tlsOpts.curves) {
@@ -303,7 +406,9 @@ class WsClient extends EventEmitter {
303
406
  } catch (_e) { /* drop-silent — tls module pre-init or non-Node */ }
304
407
  socket = tls.connect(tlsOpts);
305
408
  } else {
306
- socket = net.connect({ host: host, port: port });
409
+ var netOpts = { host: host, port: port };
410
+ if (lookup) netOpts.lookup = lookup;
411
+ socket = net.connect(netOpts);
307
412
  }
308
413
  this._socket = socket;
309
414
  socket.on("error", _onError);
@@ -406,8 +511,17 @@ class WsClient extends EventEmitter {
406
511
  }
407
512
  var status = parseInt(match[1], 10);
408
513
  if (status !== 101) { // allow:raw-byte-literal — HTTP 101
409
- this._handleSocketError(new WsClientError("ws-client/bad-status",
410
- "handshake response status was " + status + " (expected 101 Switching Protocols)"));
514
+ // Body bytes after the header section are the server's
515
+ // explanation. Surface them on the error so callers can branch
516
+ // on the status code and inspect the body without re-parsing
517
+ // the message string.
518
+ var bodyText = "";
519
+ try { bodyText = rest.toString("utf8"); } catch (_e) { /* drop-silent */ }
520
+ var statusErr = new WsClientError("ws-client/bad-status",
521
+ "handshake response status was " + status + " (expected 101 Switching Protocols)");
522
+ statusErr.status = status;
523
+ statusErr.body = bodyText;
524
+ this._handleSocketError(statusErr);
411
525
  return;
412
526
  }
413
527
 
@@ -624,7 +738,29 @@ class WsClient extends EventEmitter {
624
738
  return;
625
739
  }
626
740
  }
627
- this.emit("message", data, isBinary);
741
+ // Auto-parse text frames as JSON when the operator opted in via
742
+ // `parse: "json"` or supplied `parser: fn`. JSON-only protocols
743
+ // (most modern WS APIs) get a typed message argument without a
744
+ // wrapper layer; parse failures surface as 'error' events
745
+ // rather than crashing the message handler.
746
+ var parsed = data;
747
+ var parsedOk = true;
748
+ if (!isBinary && this._opts.parse === "json") {
749
+ try { parsed = safeJson().parse(data, { maxBytes: this._opts.maxMessageBytes }); }
750
+ catch (e) {
751
+ parsedOk = false;
752
+ this.emit("error", new WsClientError("ws-client/json-parse",
753
+ "text frame is not valid JSON: " + ((e && e.message) || String(e))));
754
+ }
755
+ } else if (!isBinary && typeof this._opts.parser === "function") {
756
+ try { parsed = this._opts.parser(data); }
757
+ catch (e) {
758
+ parsedOk = false;
759
+ this.emit("error", new WsClientError("ws-client/parser-failed",
760
+ "operator parser threw: " + ((e && e.message) || String(e))));
761
+ }
762
+ }
763
+ if (parsedOk) this.emit("message", parsed, isBinary);
628
764
  }
629
765
  }
630
766
 
@@ -741,12 +877,25 @@ class WsClient extends EventEmitter {
741
877
  }
742
878
 
743
879
  _handleSocketError(err) {
880
+ // Swallow post-close socket errors. After a consumer-initiated
881
+ // close() the framework waits for the server to send back its
882
+ // close frame; if the server tears down its end with an
883
+ // ECONNRESET / EPIPE / "premature close" instead of a graceful
884
+ // close frame, the underlying socket emits an error that bubbles
885
+ // up here. From the consumer's perspective the close already
886
+ // happened — surfacing a "socket error" event after `close` was
887
+ // called is noise that hides real bugs.
888
+ if (this._closed && err && (
889
+ err.code === "ECONNRESET" || err.code === "EPIPE" ||
890
+ err.code === "ECONNABORTED" || err.code === "ERR_STREAM_PREMATURE_CLOSE")) {
891
+ return;
892
+ }
744
893
  var permanent = _isPermanentError(err);
745
894
  if (this._opts.auditOn) {
746
895
  try {
747
896
  audit().safeEmit({
748
897
  action: "wsclient.error",
749
- outcome: "fail",
898
+ outcome: "failure",
750
899
  actor: null,
751
900
  metadata: {
752
901
  host: this._opts.parsedUrl.host,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.0",
3
+ "version": "0.8.5",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -70,7 +70,8 @@
70
70
  ],
71
71
  "scripts": {
72
72
  "test": "node test/smoke.js",
73
- "prepack": "node scripts/check-pack-against-gitignore.js"
73
+ "prepack": "node scripts/check-pack-against-gitignore.js",
74
+ "check:vendor-currency": "node scripts/check-vendor-currency.js"
74
75
  },
75
76
  "dependencies": {},
76
77
  "devDependencies": {}
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:4bde75d4-1385-4f13-b927-d67aa27d910d",
5
+ "serialNumber": "urn:uuid:1f60d33c-fc3b-4da1-a753-bb1c98de9968",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T18:52:54.402Z",
8
+ "timestamp": "2026-05-06T22:33:10.715Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.8.0",
22
+ "bom-ref": "@blamejs/core@0.8.5",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.0",
25
+ "version": "0.8.5",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.8.0",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.5",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.8.0",
57
+ "ref": "@blamejs/core@0.8.5",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]