@blamejs/core 0.8.11 → 0.8.13

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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - **0.8.13** (2026-05-07) — Streaming multipart uploads + `onChunk` response hook on `b.httpClient`. **Streaming multipart** — `b.httpClient.request({ multipart: { files: [...] } })` now accepts file entries in three shapes: the existing `{ field, content: Buffer | string }` (in-memory), plus new `{ field, filePath: string }` (stream-from-disk via `fs.createReadStream`) and `{ field, stream: Readable, size?: number }` (operator-supplied stream). When every entry's size is statically resolvable (Buffer length / `fs.statSync().size` / explicit `opts.size`), the framework sets `Content-Length` and uses identity transfer; otherwise the framework omits the header and Node's HTTP layer falls back to chunked transfer. Closes the `Buffer.concat` OOM class on large uploads — the body is materialized one chunk at a time through a `Readable.from(asyncIterator)` that yields boundary headers, source bytes, and CRLF in order. Fast-path preserved: when no streaming source is involved, `_buildMultipartBody` still returns a single Buffer with a known `Content-Length`. **`onChunk(chunk)` response hook** — fires for each response data chunk in BOTH `responseMode: "buffer"` and `responseMode: "stream"`. Use case: hash bytes during pipe-to-disk without an extra Transform pass (`onChunk: (c) => hasher.update(c)`). Throws inside the hook are caught and dropped — a hash-mismatch detector can raise without breaking the pipe; callers surface the error through their own pipe handler. Verified-already-shipped during the v0.8.x framework-gap audit: `b.httpClient` `responseMode: "always-resolve"`, `onRedirect({from, to, hop, headersStripped, statusCode})` hook, `body: Readable` upload path; `b.cryptoField` derived-hash domain separation (`bj-<table>-<field>:` per-field namespace prefix matches the indexed-lookup requirement); `b.config` `redactKeys` allowlist + `redacted()` view.
12
+
13
+ - **0.8.12** (2026-05-07) — WebSocket upgrade refuses credential-shaped query parameters by default. `validateUpgradeRequest(req, opts)` now scans the request URL for the credential-leak names `access_token`, `bearer`, `bearer_token`, `apikey`, `api_key`, `api-key`, `authorization` (case-insensitive, with percent-decoding) and refuses the upgrade with HTTP 400 when one is present. URL query strings leak through web-server access logs, browser history, the Referer header forwarded to third-party CDN / analytics, in-process / proxy log captures, and crash dumps — RFC 6750 §2.3 explicitly cautions against bearer tokens in URI query parameters for these reasons. Operators with a non-credential parameter that happens to share a credential-shaped name opt out per route via `opts.allowQueryAuthParams: true` with an audited operator reason. The refused list is deliberately narrow: overloaded names (`token`, `auth`, `key`, `session`) have non-credential meanings (CSRF tokens, file-share tokens, session-resume identifiers) and are NOT refused.
14
+
11
15
  - **0.8.11** (2026-05-07) — Three new state-and-federal regulatory primitives + a per-primitive test-coverage gate. **`b.breach.deadline` + `b.breach.report`** — all-50-states data-breach-notification deadline registry. `b.breach.deadline.forStates(states, detectedAt)` returns per-state `{ state, kind, dueBy, citation }` records (`kind: "as-soon-as-possible"` for AS-OF / `"hard-deadline"` for fixed-day deadlines like Texas / Florida / Maine). `b.breach.report.create()` opens a multi-state breach with a single record, tracks per-state filings via `fileNotice(id, state, ...)`, exposes `pending(id)` for dashboards, and auto-closes once every affected state has filed. Every transition records a `breach.report.*` audit event. Statutory citations + day counts wired in `lib/breach-deadline.js` per-state. **`b.ai.adverseDecision`** — wraps an operator-supplied `decide(subject)` predicate, automatically attaches a consumer-rights notice when the outcome is `"adverse"` / `"denied"` / `"rejected"`. Built-in regulation templates for `gdpr-22` (Article 22 automated-decision rights), `ai-act-86` (EU AI Act high-risk consumer recourse), `ecoa-1002.9` (US Equal Credit Opportunity Act adverse-action notice), `colorado-ai-act` (CO SB 24-205 §6-1-1701), `nyc-ll-144` (NYC Local Law 144 employment AEDT), `fcra-615` (US FCRA adverse action), and `operator-defined`. Notice carries `principalReasons` + `consumerRights: { requestData, requestExplanation, contestDecision, requestHumanReview }` shaped per regime. **`b.middleware.ageGate`** — request-level age-classification middleware. Operator-supplied `getAge(req)` returns the subject age (or null/undefined when unknown); middleware classifies as `"above-threshold"` / `"below-threshold"` / `"unknown"` against `consentRequired`, sets `X-Privacy-Posture` header, and refuses with 451 + audited reason when `requireAge` is set and `hasParentalConsent(req)` is unmet. Composes upstream of session / authn for COPPA / AADC / UK Children's Code postures. **Per-primitive test-coverage gate** — new `test/layer-0-primitives/test-coverage.test.js` walks every operator-facing `b.*` primitive and refuses release unless the primitive has at least one test reference (or an explicit `UNTESTED_BACKLOG` entry naming the reason). Closes the drift class where a primitive landed on `b.*` but never gained a unit test.
12
16
 
13
17
  - **0.8.10** (2026-05-07) — Five new compliance / regulatory primitives composing on v0.8.9's `b.incident.report`. **`b.cra.report`** — EU Cyber Resilience Act (Regulation (EU) 2024/2847) Article 14 §1 incident reporting wrapper. Three-stage statutory deadlines: 24h early warning / 72h incident notification / 14d final report. Required `productId` + `manufacturer` per Annex VII §1. Optional ENISA submission via `opts.enisaEndpoint` + `b.httpClient`; submission is operator-opt-in per stage call (regulators uniformly require operator review before filing). **`b.nis2.report`** — NIS2 Directive (Directive (EU) 2022/2555) Article 23 §4 incident reporting wrapper. Three-stage deadlines: 24h / 72h / 1 month. Annex I (essential) / Annex II (important) entity classification + sector codes (`I.6` drinking water / `II.6` digital providers / etc.). **`b.gdpr.ropa`** — GDPR Article 30 Records of Processing Activities registry + JSON / CSV / Markdown exporter. Validates required fields per Article 30 §1; legal-basis enum per Article 6(1); produces a regulator-friendly RoPA document for the operator's DPO to file. **`b.compliance.eaa`** — EU Accessibility Act (Directive (EU) 2019/882) Article 13 declared-conformance generator. Operators declare per-criterion conformance against WCAG 2.1/2.2 AA / EN 301 549; non-conformances ship with reason + mitigation. JSON / Markdown export for the operator's accessibility statement. **`b.middleware.botDisclose`** — California SB 1001 (Cal. Bus. & Prof. Code §17941) bot-disclosure middleware. Injects a disclosure banner into HTML responses, sets `X-Bot-Disclosure` header for API consumers, audits every conversation-initiating request. Operators wire `mountPaths` to scope and `bannerHtml` for visual customization.
@@ -20,6 +20,10 @@
20
20
  * maxResponseBytes, // for buffer mode (default 16 MiB control,
21
21
  * // 1 GiB GET — operators with > 1 GiB
22
22
  * // stored objects must use stream mode)
23
+ * onChunk, // (chunk: Buffer) => void — fires for each
24
+ * // response chunk in BOTH buffer and stream
25
+ * // modes. Use to hash bytes during pipe-to-disk
26
+ * // without an extra Transform pass.
23
27
  * signal, // AbortSignal — propagated to req/stream
24
28
  * errorClass, // FrameworkError subclass
25
29
  * observer, // optional (stage, info) => void hook
@@ -426,20 +430,78 @@ function _attachJarCookie(headers, jar, url) {
426
430
  // Mirrors the wire format that lib/middleware/body-parser.js's multipart
427
431
  // parser accepts so round-trip from one blamejs app's outbound to
428
432
  // another's inbound is exact.
433
+ //
434
+ // Two output shapes:
435
+ //
436
+ // - { boundary, body: Buffer, contentLength }
437
+ // When every file entry is a Buffer / string (size known
438
+ // up front) and no operator opted into streaming, the result
439
+ // is a fully-materialized body. Smaller payloads avoid the
440
+ // streaming overhead and let HTTP/1.1 KeepAlive reuse with a
441
+ // known Content-Length.
442
+ //
443
+ // - { boundary, body: Readable, contentLength }
444
+ // When at least one file entry is `{ filePath }` / `{ stream }`
445
+ // OR opts.streaming === true, the result is a Readable that
446
+ // emits boundary headers + content + CRLF in order. Avoids the
447
+ // Buffer.concat() OOM class on large uploads. contentLength is
448
+ // a finite number when every source's size is statically
449
+ // resolvable (Buffer length, fs.statSync().size, opts.size on
450
+ // a stream entry); null otherwise — caller falls back to
451
+ // chunked transfer.
452
+ //
453
+ // File entry shapes (all require `field`):
454
+ //
455
+ // { field, content: Buffer | string } — in-memory (existing)
456
+ // { field, filePath: string } — stream-from-disk
457
+ // { field, stream: Readable, size?: number } — operator-supplied stream
458
+ //
459
+ // `filename` and `contentType` apply to all three shapes; for
460
+ // `filePath` entries, `filename` defaults to path.basename(filePath).
429
461
  function _buildMultipartBody(spec) {
430
462
  var boundary = "----blamejs-mp-" + crypto.generateToken(C.BYTES.bytes(16));
431
463
  var CRLF = "\r\n";
432
- var parts = [];
464
+ var fs = require("fs"); // allow:inline-require — only on multipart paths that touch the filesystem
465
+ var path = require("path"); // allow:inline-require — same
466
+ var nodeStream = require("stream"); // allow:inline-require — Readable subclass only when streaming
467
+
468
+ // Each entry is { headerBytes, source } where source is one of:
469
+ // { kind: "buffer", buf: Buffer }
470
+ // { kind: "filePath", filePath: string, size: number }
471
+ // { kind: "stream", stream: Readable, size: number | null }
472
+ var entries = [];
473
+ var anyStreaming = false;
474
+ var totalSize = 0;
475
+ var sizeKnown = true;
476
+
477
+ function _entryHeaderBytes(disposition, contentType) {
478
+ var head = "--" + boundary + CRLF + disposition + CRLF;
479
+ if (contentType) head += "Content-Type: " + contentType + CRLF;
480
+ head += CRLF;
481
+ return Buffer.from(head, "utf8");
482
+ }
483
+
484
+ function _addEntry(headerBytes, source) {
485
+ entries.push({ header: headerBytes, source: source });
486
+ totalSize += headerBytes.length;
487
+ if (source.kind === "buffer") {
488
+ totalSize += source.buf.length;
489
+ } else if (typeof source.size === "number" && isFinite(source.size) && source.size >= 0) {
490
+ totalSize += source.size;
491
+ } else {
492
+ sizeKnown = false;
493
+ }
494
+ totalSize += CRLF.length;
495
+ }
433
496
 
434
497
  function _pushField(name, value) {
435
498
  if (typeof name !== "string" || name.length === 0) {
436
499
  throw new Error("multipart: field name must be a non-empty string");
437
500
  }
438
- var head = "--" + boundary + CRLF +
439
- 'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF;
440
- parts.push(Buffer.from(head, "utf8"));
441
- parts.push(Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8"));
442
- parts.push(Buffer.from(CRLF, "utf8"));
501
+ var disposition = 'Content-Disposition: form-data; name="' + name + '"';
502
+ var head = _entryHeaderBytes(disposition, null);
503
+ var bodyBuf = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
504
+ _addEntry(head, { kind: "buffer", buf: bodyBuf });
443
505
  }
444
506
 
445
507
  function _pushFile(file) {
@@ -447,21 +509,50 @@ function _buildMultipartBody(spec) {
447
509
  if (typeof file.field !== "string" || file.field.length === 0) {
448
510
  throw new Error("multipart: file.field must be a non-empty string");
449
511
  }
450
- var filename = typeof file.filename === "string" && file.filename.length > 0
451
- ? file.filename : "blob";
512
+ var hasContent = file.content !== undefined && file.content !== null;
513
+ var hasFilePath = typeof file.filePath === "string" && file.filePath.length > 0;
514
+ var hasStream = file.stream && typeof file.stream.pipe === "function";
515
+ var sourceCount = (hasContent ? 1 : 0) + (hasFilePath ? 1 : 0) + (hasStream ? 1 : 0);
516
+ if (sourceCount === 0) {
517
+ throw new Error("multipart: file entry requires one of { content, filePath, stream }");
518
+ }
519
+ if (sourceCount > 1) {
520
+ throw new Error("multipart: file entry must have exactly one of { content, filePath, stream }");
521
+ }
522
+
523
+ var filename;
524
+ if (typeof file.filename === "string" && file.filename.length > 0) {
525
+ filename = file.filename;
526
+ } else if (hasFilePath) {
527
+ filename = path.basename(file.filePath);
528
+ } else {
529
+ filename = "blob";
530
+ }
452
531
  var mimeType = file.contentType || file.mimeType || "application/octet-stream";
453
- var content = file.content;
454
- if (typeof content === "string") content = Buffer.from(content, "utf8");
455
- if (!Buffer.isBuffer(content)) {
456
- throw new Error("multipart: file.content must be a Buffer or string");
532
+ var disposition = 'Content-Disposition: form-data; name="' + file.field + '"' +
533
+ '; filename="' + filename.replace(/"/g, "%22") + '"';
534
+ var head = _entryHeaderBytes(disposition, mimeType);
535
+
536
+ if (hasContent) {
537
+ var content = file.content;
538
+ if (typeof content === "string") content = Buffer.from(content, "utf8");
539
+ if (!Buffer.isBuffer(content)) {
540
+ throw new Error("multipart: file.content must be a Buffer or string");
541
+ }
542
+ _addEntry(head, { kind: "buffer", buf: content });
543
+ } else if (hasFilePath) {
544
+ anyStreaming = true;
545
+ var st;
546
+ try { st = fs.statSync(file.filePath); }
547
+ catch (e) { throw new Error("multipart: file.filePath not readable: " + e.message); }
548
+ if (!st.isFile()) throw new Error("multipart: file.filePath is not a regular file");
549
+ _addEntry(head, { kind: "filePath", filePath: file.filePath, size: st.size });
550
+ } else {
551
+ anyStreaming = true;
552
+ var streamSize = (typeof file.size === "number" && isFinite(file.size) && file.size >= 0)
553
+ ? file.size : null;
554
+ _addEntry(head, { kind: "stream", stream: file.stream, size: streamSize });
457
555
  }
458
- var head = "--" + boundary + CRLF +
459
- 'Content-Disposition: form-data; name="' + file.field + '"' +
460
- '; filename="' + filename.replace(/"/g, "%22") + '"' + CRLF +
461
- "Content-Type: " + mimeType + CRLF + CRLF;
462
- parts.push(Buffer.from(head, "utf8"));
463
- parts.push(content);
464
- parts.push(Buffer.from(CRLF, "utf8"));
465
556
  }
466
557
 
467
558
  if (spec && spec.fields && typeof spec.fields === "object") {
@@ -479,8 +570,54 @@ function _buildMultipartBody(spec) {
479
570
  if (spec && Array.isArray(spec.files)) {
480
571
  for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
481
572
  }
482
- parts.push(Buffer.from("--" + boundary + "--" + CRLF, "utf8"));
483
- return { boundary: boundary, body: Buffer.concat(parts) };
573
+ var trailer = Buffer.from("--" + boundary + "--" + CRLF, "utf8");
574
+ totalSize += trailer.length;
575
+
576
+ // All-buffer fast path — return a fully-materialized body when no
577
+ // streaming sources are involved AND the operator didn't ask for
578
+ // streaming explicitly. Existing callers that pass small in-memory
579
+ // payloads keep the buffer codepath.
580
+ if (!anyStreaming && !(spec && spec.streaming === true)) {
581
+ var parts = [];
582
+ for (var ei = 0; ei < entries.length; ei++) {
583
+ parts.push(entries[ei].header);
584
+ parts.push(entries[ei].source.buf);
585
+ parts.push(Buffer.from(CRLF, "utf8"));
586
+ }
587
+ parts.push(trailer);
588
+ return { boundary: boundary, body: Buffer.concat(parts), contentLength: totalSize };
589
+ }
590
+
591
+ // Streaming path — produce a Readable from an async iterator that
592
+ // yields the bytes for each entry in order.
593
+ var crlfBuf = Buffer.from(CRLF, "utf8");
594
+ async function* _iter() {
595
+ for (var ix = 0; ix < entries.length; ix++) {
596
+ var entry = entries[ix];
597
+ yield entry.header;
598
+ if (entry.source.kind === "buffer") {
599
+ yield entry.source.buf;
600
+ } else if (entry.source.kind === "filePath") {
601
+ var rs = fs.createReadStream(entry.source.filePath);
602
+ try {
603
+ for await (var chunk of rs) yield chunk;
604
+ } finally {
605
+ try { rs.destroy(); } catch (_e) { /* best-effort cleanup */ }
606
+ }
607
+ } else {
608
+ // operator-supplied stream
609
+ for await (var chunk2 of entry.source.stream) yield chunk2;
610
+ }
611
+ yield crlfBuf;
612
+ }
613
+ yield trailer;
614
+ }
615
+ var body = nodeStream.Readable.from(_iter());
616
+ return {
617
+ boundary: boundary,
618
+ body: body,
619
+ contentLength: sizeKnown ? totalSize : null,
620
+ };
484
621
  }
485
622
 
486
623
  // Headers stripped on cross-origin redirect to defend against accidental
@@ -525,6 +662,10 @@ function request(opts) {
525
662
  return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
526
663
  "onDownloadProgress must be a function", true));
527
664
  }
665
+ if (opts.onChunk !== undefined && typeof opts.onChunk !== "function") {
666
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
667
+ "onChunk must be a function (chunk: Buffer) -> void", true));
668
+ }
528
669
  if (opts.jar !== undefined && opts.jar !== null) {
529
670
  if (typeof opts.jar !== "object" ||
530
671
  typeof opts.jar.cookieHeaderFor !== "function" ||
@@ -567,13 +708,20 @@ function request(opts) {
567
708
  catch (e) {
568
709
  return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
569
710
  }
711
+ var mpHeaders = Object.assign({}, opts.headers || {}, {
712
+ "Content-Type": "multipart/form-data; boundary=" + built.boundary,
713
+ });
714
+ // Content-Length is set when the framework can statically resolve
715
+ // every source's byte size. Otherwise the framework omits the
716
+ // header and Node's HTTP layer falls back to chunked transfer —
717
+ // valid HTTP/1.1, requires no operator opt-in.
718
+ if (typeof built.contentLength === "number" && isFinite(built.contentLength)) {
719
+ mpHeaders["Content-Length"] = String(built.contentLength);
720
+ }
570
721
  opts = Object.assign({}, opts, {
571
- method: opts.method || "POST",
572
- body: built.body,
573
- headers: Object.assign({}, opts.headers || {}, {
574
- "Content-Type": "multipart/form-data; boundary=" + built.boundary,
575
- "Content-Length": String(built.body.length),
576
- }),
722
+ method: opts.method || "POST",
723
+ body: built.body,
724
+ headers: mpHeaders,
577
725
  multipart: undefined,
578
726
  });
579
727
  }
@@ -894,6 +1042,7 @@ function _requestH1(transport, u, opts) {
894
1042
 
895
1043
  var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
896
1044
  var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
1045
+ var onChunk = typeof opts.onChunk === "function" ? opts.onChunk : null;
897
1046
 
898
1047
  var req = transport.lib.request(reqOpts, function (res) {
899
1048
  if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
@@ -927,13 +1076,24 @@ function _requestH1(transport, u, opts) {
927
1076
  "HTTP " + res.statusCode + " " + (res.statusMessage || ""),
928
1077
  _isPermanentStatus(res.statusCode), res.statusCode));
929
1078
  }
930
- if (onDownloadProgress) {
931
- // Wrap the stream so chunks emit progress to the operator.
932
- // The framework's contract is to hand back the response stream
933
- // unmodified; fix-up via a passthrough keeps that contract while
934
- // observing the chunk sizes.
1079
+ if (onDownloadProgress || onChunk) {
1080
+ // Wrap the stream so chunks emit progress + onChunk to the
1081
+ // operator. The framework's contract is to hand back the
1082
+ // response stream unmodified; fix-up via a passthrough keeps
1083
+ // that contract while observing the chunk sizes. onChunk
1084
+ // gets the buffer itself (for hash-as-you-go); a throw from
1085
+ // it is caught and dropped so a hash-mismatch detector can
1086
+ // raise without breaking the response stream — caller
1087
+ // surfaces the error through their own pipe handler.
935
1088
  var passthrough = new nodeStream.PassThrough();
936
- res.on("data", function (chunk) { _emitDownload(chunk.length); passthrough.write(chunk); });
1089
+ res.on("data", function (chunk) {
1090
+ _emitDownload(chunk.length);
1091
+ if (onChunk) {
1092
+ try { onChunk(chunk); }
1093
+ catch (_e) { /* operator-supplied hook — drop-silent */ }
1094
+ }
1095
+ passthrough.write(chunk);
1096
+ });
937
1097
  res.on("end", function () { passthrough.end(); });
938
1098
  res.on("error", function (e) { passthrough.destroy(e); });
939
1099
  return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
@@ -955,6 +1115,10 @@ function _requestH1(transport, u, opts) {
955
1115
  return;
956
1116
  }
957
1117
  _emitDownload(chunk.length);
1118
+ if (onChunk) {
1119
+ try { onChunk(chunk); }
1120
+ catch (_e) { /* operator-supplied hook — drop-silent */ }
1121
+ }
958
1122
  });
959
1123
  res.on("end", function () {
960
1124
  if (capExceeded) return;
@@ -1072,6 +1236,7 @@ function _requestH2(transport, u, opts) {
1072
1236
  (method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
1073
1237
  var observer = typeof opts.observer === "function" ? opts.observer : null;
1074
1238
  var startedAt = Date.now();
1239
+ var onChunkH2 = typeof opts.onChunk === "function" ? opts.onChunk : null;
1075
1240
 
1076
1241
  var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
1077
1242
  if (signal && signal.aborted) {
@@ -1128,6 +1293,17 @@ function _requestH2(transport, u, opts) {
1128
1293
  return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
1129
1294
  "HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
1130
1295
  }
1296
+ if (onChunkH2) {
1297
+ var passthroughH2 = new nodeStream.PassThrough();
1298
+ stream.on("data", function (chunk) {
1299
+ try { onChunkH2(chunk); }
1300
+ catch (_e) { /* operator-supplied hook — drop-silent */ }
1301
+ passthroughH2.write(chunk);
1302
+ });
1303
+ stream.on("end", function () { passthroughH2.end(); });
1304
+ stream.on("error", function (e) { passthroughH2.destroy(e); });
1305
+ return _resolve({ statusCode: statusCode, headers: responseHeaders, body: passthroughH2 });
1306
+ }
1131
1307
  return _resolve({ statusCode: statusCode, headers: responseHeaders, body: stream });
1132
1308
  }
1133
1309
 
@@ -1142,6 +1318,11 @@ function _requestH2(transport, u, opts) {
1142
1318
  try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e2) { /* best-effort h2 stream cancel */ }
1143
1319
  _reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
1144
1320
  "response body exceeds " + maxResponseBytes + " bytes", true));
1321
+ return;
1322
+ }
1323
+ if (onChunkH2) {
1324
+ try { onChunkH2(chunk); }
1325
+ catch (_e) { /* operator-supplied hook — drop-silent */ }
1145
1326
  }
1146
1327
  });
1147
1328
  stream.on("end", function () {
package/lib/websocket.js CHANGED
@@ -108,6 +108,36 @@ var GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
108
108
  // catches the typo class.
109
109
  var GUID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
110
110
 
111
+ // Credential-shaped query parameter names refused at upgrade time. URL
112
+ // query strings end up in: web-server access logs, the browser's
113
+ // history + Referer header forwarded to third-party CDN / analytics
114
+ // requests, in-process / proxy log captures, and crash dumps. Any
115
+ // authentication credential placed in the query string is leaked
116
+ // through one of those channels by default. RFC 6750 §2.3 explicitly
117
+ // cautions against bearer tokens in URI query parameters for exactly
118
+ // these reasons.
119
+ //
120
+ // Operators with a non-credential query parameter that happens to
121
+ // match one of these names (e.g. an "apikey" field passed to a
122
+ // downstream tenant API by mistake) opt out per route via
123
+ // `opts.allowQueryAuthParams: true` with an audited operator reason —
124
+ // the lift exists, but the operator owns the audit trail.
125
+ //
126
+ // The list is deliberately narrow — overloaded names like `token`,
127
+ // `auth`, `key`, `session` have non-credential meanings (CSRF tokens,
128
+ // file-share tokens, ICE candidates, session-resume identifiers) and
129
+ // would create false-positive friction without closing a genuine
130
+ // leak vector. The names below are unambiguously credential-shaped.
131
+ var REFUSED_AUTH_QUERY_PARAMS = Object.freeze([
132
+ "access_token", // OAuth 2.0 bearer (RFC 6750)
133
+ "bearer", // synonym
134
+ "bearer_token", // synonym
135
+ "apikey", // common convention
136
+ "api_key", // common convention
137
+ "api-key", // common convention
138
+ "authorization", // literal Authorization-header value
139
+ ]);
140
+
111
141
  var OPCODE_CONTINUATION = 0x0;
112
142
  var OPCODE_TEXT = 0x1;
113
143
  var OPCODE_BINARY = 0x2;
@@ -186,7 +216,7 @@ function computeAcceptKey(secWebSocketKey, handshakeGuid) {
186
216
  return hash.digest("base64");
187
217
  }
188
218
 
189
- function validateUpgradeRequest(req) {
219
+ function validateUpgradeRequest(req, opts) {
190
220
  if (req.method !== "GET") {
191
221
  return { ok: false, status: HTTP.METHOD_NOT_ALLOWED, reason: "method must be GET" };
192
222
  }
@@ -205,9 +235,54 @@ function validateUpgradeRequest(req) {
205
235
  if (h["sec-websocket-version"] !== "13") {
206
236
  return { ok: false, status: HTTP.BAD_REQUEST, reason: "Sec-WebSocket-Version must be 13" };
207
237
  }
238
+ if (!(opts && opts.allowQueryAuthParams === true)) {
239
+ var leaked = _findCredentialQueryParam(req.url);
240
+ if (leaked) {
241
+ return {
242
+ ok: false,
243
+ status: HTTP.BAD_REQUEST,
244
+ reason: "credential-shaped query parameter '" + leaked +
245
+ "' refused — query strings leak via logs / Referer / history. " +
246
+ "Move the credential to the Authorization header, or set " +
247
+ "opts.allowQueryAuthParams: true with an audited operator reason " +
248
+ "if this parameter is not actually a credential.",
249
+ };
250
+ }
251
+ }
208
252
  return { ok: true };
209
253
  }
210
254
 
255
+ // _findCredentialQueryParam walks the request's query string and
256
+ // returns the first credential-shaped parameter name it finds, or
257
+ // null. Comparison is case-insensitive; an attacker who URL-encodes
258
+ // the parameter name (e.g. "%41ccess_token") still hits the check
259
+ // because URL parsing decodes the name before comparison.
260
+ function _findCredentialQueryParam(reqUrl) {
261
+ if (typeof reqUrl !== "string" || reqUrl.length === 0) return null;
262
+ var qIdx = reqUrl.indexOf("?");
263
+ if (qIdx === -1) return null;
264
+ var query = reqUrl.slice(qIdx + 1);
265
+ // Strip a fragment if any (defensive — real HTTP requests don't carry
266
+ // one, but req.url has been observed with appended fragments behind
267
+ // misconfigured proxies).
268
+ var fIdx = query.indexOf("#");
269
+ if (fIdx !== -1) query = query.slice(0, fIdx);
270
+ if (query.length === 0) return null;
271
+ var pairs = query.split("&");
272
+ for (var p = 0; p < pairs.length; p++) {
273
+ var eqIdx = pairs[p].indexOf("=");
274
+ var rawName = eqIdx === -1 ? pairs[p] : pairs[p].slice(0, eqIdx);
275
+ if (rawName.length === 0) continue;
276
+ var name;
277
+ try { name = decodeURIComponent(rawName).toLowerCase(); }
278
+ catch (_e) { name = rawName.toLowerCase(); }
279
+ for (var r = 0; r < REFUSED_AUTH_QUERY_PARAMS.length; r++) {
280
+ if (name === REFUSED_AUTH_QUERY_PARAMS[r]) return name;
281
+ }
282
+ }
283
+ return null;
284
+ }
285
+
211
286
  function negotiateSubprotocol(req, supported) {
212
287
  if (!supported || supported.length === 0) return null;
213
288
  var raw = (req.headers || {})["sec-websocket-protocol"] || "";
@@ -885,9 +960,9 @@ function handleUpgrade(req, socket, head, opts) {
885
960
  // Validate handshake first — refusing here writes a plain HTTP/1.1
886
961
  // response and closes the socket, matching what the upgrade-event
887
962
  // consumer would expect for a malformed request.
888
- var v = validateUpgradeRequest(req);
963
+ var v = validateUpgradeRequest(req, opts);
889
964
  if (!v.ok) {
890
- _refuseUpgrade(socket, v.status || 400, v.reason);
965
+ _refuseUpgrade(socket, v.status || 400, v.reason); // allow:raw-byte-literal — HTTP 400 fallback
891
966
  return null;
892
967
  }
893
968
 
@@ -1047,6 +1122,7 @@ module.exports = {
1047
1122
  handleExtendedConnect: handleExtendedConnect, // h2 — RFC 8441 Extended CONNECT
1048
1123
  // Constants
1049
1124
  GUID: GUID,
1125
+ REFUSED_AUTH_QUERY_PARAMS: REFUSED_AUTH_QUERY_PARAMS,
1050
1126
  OPCODE_CONTINUATION: OPCODE_CONTINUATION,
1051
1127
  OPCODE_TEXT: OPCODE_TEXT,
1052
1128
  OPCODE_BINARY: OPCODE_BINARY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:ae68f706-2c92-4dfa-b852-f29896f91c62",
5
+ "serialNumber": "urn:uuid:52762d7e-86ab-4e69-9636-eacc1acc2d2d",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T03:30:10.287Z",
8
+ "timestamp": "2026-05-07T04:52:07.494Z",
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.11",
22
+ "bom-ref": "@blamejs/core@0.8.13",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.11",
25
+ "version": "0.8.13",
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.11",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.13",
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.11",
57
+ "ref": "@blamejs/core@0.8.13",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]