@blamejs/blamejs-shop 0.3.56 → 0.3.58

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 (84) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/SECURITY.md +11 -0
  4. package/lib/asset-manifest.json +3 -3
  5. package/lib/checkout.js +89 -10
  6. package/lib/security-middleware.js +17 -1
  7. package/lib/storefront.js +156 -51
  8. package/lib/vendor/MANIFEST.json +84 -72
  9. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  10. package/lib/vendor/blamejs/README.md +3 -3
  11. package/lib/vendor/blamejs/SECURITY.md +5 -0
  12. package/lib/vendor/blamejs/api-snapshot.json +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +10 -4
  14. package/lib/vendor/blamejs/lib/ai-prompt.js +1 -1
  15. package/lib/vendor/blamejs/lib/app-shutdown.js +28 -0
  16. package/lib/vendor/blamejs/lib/archive-read.js +215 -16
  17. package/lib/vendor/blamejs/lib/archive.js +206 -52
  18. package/lib/vendor/blamejs/lib/auth/oauth.js +58 -0
  19. package/lib/vendor/blamejs/lib/auth/oid4vci.js +84 -27
  20. package/lib/vendor/blamejs/lib/breach-deadline.js +166 -1
  21. package/lib/vendor/blamejs/lib/cloud-events.js +3 -1
  22. package/lib/vendor/blamejs/lib/codepoint-class.js +21 -0
  23. package/lib/vendor/blamejs/lib/db-schema.js +120 -3
  24. package/lib/vendor/blamejs/lib/db.js +10 -3
  25. package/lib/vendor/blamejs/lib/error-page.js +93 -9
  26. package/lib/vendor/blamejs/lib/external-db.js +164 -13
  27. package/lib/vendor/blamejs/lib/guard-email.js +36 -3
  28. package/lib/vendor/blamejs/lib/http-client.js +37 -7
  29. package/lib/vendor/blamejs/lib/mail-auth.js +554 -55
  30. package/lib/vendor/blamejs/lib/mail-send-deliver.js +15 -5
  31. package/lib/vendor/blamejs/lib/mail-sieve.js +2 -1
  32. package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +88 -19
  33. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +58 -11
  34. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +56 -4
  35. package/lib/vendor/blamejs/lib/middleware/attach-user.js +45 -10
  36. package/lib/vendor/blamejs/lib/middleware/body-parser.js +70 -14
  37. package/lib/vendor/blamejs/lib/middleware/csp-report.js +30 -2
  38. package/lib/vendor/blamejs/lib/middleware/deny-response.js +29 -9
  39. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +56 -4
  40. package/lib/vendor/blamejs/lib/middleware/scim-server.js +301 -14
  41. package/lib/vendor/blamejs/lib/openapi-paths-builder.js +105 -29
  42. package/lib/vendor/blamejs/lib/openapi.js +225 -100
  43. package/lib/vendor/blamejs/lib/problem-details.js +15 -3
  44. package/lib/vendor/blamejs/lib/queue-local.js +148 -38
  45. package/lib/vendor/blamejs/lib/queue.js +41 -11
  46. package/lib/vendor/blamejs/lib/render.js +21 -3
  47. package/lib/vendor/blamejs/lib/router.js +13 -6
  48. package/lib/vendor/blamejs/lib/safe-buffer.js +55 -0
  49. package/lib/vendor/blamejs/lib/sse.js +7 -5
  50. package/lib/vendor/blamejs/lib/static.js +46 -17
  51. package/lib/vendor/blamejs/lib/uri-template.js +3 -1
  52. package/lib/vendor/blamejs/package.json +1 -1
  53. package/lib/vendor/blamejs/release-notes/v0.14.17.json +57 -0
  54. package/lib/vendor/blamejs/release-notes/v0.14.18.json +127 -0
  55. package/lib/vendor/blamejs/release-notes/v0.14.19.json +61 -0
  56. package/lib/vendor/blamejs/test/00-primitives.js +151 -0
  57. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +18 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +86 -0
  59. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +58 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +201 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/archive.test.js +179 -0
  62. package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +19 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/attach-user-bearer-scheme.test.js +154 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-chunked-malformed.test.js +10 -8
  65. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-smuggling.test.js +99 -20
  66. package/lib/vendor/blamejs/test/layer-0-primitives/breach-deadline.test.js +85 -0
  67. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +50 -1
  68. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +63 -0
  69. package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +107 -2
  70. package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +145 -0
  71. package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +32 -0
  72. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +119 -0
  73. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +121 -1
  74. package/lib/vendor/blamejs/test/layer-0-primitives/guard-email.test.js +14 -0
  75. package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +53 -0
  76. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -5
  77. package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +45 -0
  78. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +80 -0
  79. package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +177 -0
  80. package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +312 -0
  81. package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +165 -2
  82. package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +33 -1
  83. package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +59 -0
  84. package/package.json +1 -1
@@ -287,7 +287,7 @@ function create(req, res, opts) {
287
287
  _writeRaw(":" + text + "\n\n");
288
288
  }
289
289
 
290
- function close() {
290
+ function close(cause) {
291
291
  if (closed) return;
292
292
  closed = true;
293
293
  if (heartbeatTimer) {
@@ -296,10 +296,12 @@ function create(req, res, opts) {
296
296
  }
297
297
  try { res.end(); } catch (_e) { /* already destroyed */ }
298
298
  if (auditOn) {
299
+ var closeMeta = { lastEventId: lastEventId };
300
+ if (cause) closeMeta.reason = cause.reason || "fault";
299
301
  audit.safeEmit({
300
302
  action: "sse.channel_closed",
301
- outcome: "success",
302
- metadata: { lastEventId: lastEventId },
303
+ outcome: cause ? "failure" : "success",
304
+ metadata: closeMeta,
303
305
  });
304
306
  }
305
307
  }
@@ -308,7 +310,7 @@ function create(req, res, opts) {
308
310
  // the heartbeat timer.
309
311
  if (typeof res.on === "function") {
310
312
  res.on("close", close);
311
- res.on("error", function (_e) { close(); });
313
+ res.on("error", function (_e) { close({ reason: "stream-error" }); });
312
314
  res.on("finish", function () { closed = true; if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } });
313
315
  }
314
316
 
@@ -325,7 +327,7 @@ function create(req, res, opts) {
325
327
  heartbeatTimer = setInterval(function () {
326
328
  if (closed) return;
327
329
  try { _writeRaw(":keepalive\n\n"); }
328
- catch (_e) { close(); }
330
+ catch (_e) { close({ reason: "heartbeat-write-failed" }); }
329
331
  }, heartbeatMs).unref();
330
332
  }
331
333
 
@@ -6,7 +6,8 @@
6
6
  * quotas (cluster-shared via b.cache), Range support (RFC 7233 single-range),
7
7
  * the full conditional-request set (If-None-Match / If-Match /
8
8
  * If-Modified-Since / If-Unmodified-Since), MIME allowlist with magic-byte
9
- * verification (composes b.fileType), per-request operator hook, idle-stream
9
+ * verification (composes b.fileType), per-request operator hooks (onServe
10
+ * on the success path, onError on every refusal path), idle-stream
10
11
  * timeout, cancellation propagation, force-revoke, and compliance-retention
11
12
  * gating.
12
13
  *
@@ -16,6 +17,12 @@
16
17
  * var mw = serve.middleware; // (req, res, next)
17
18
  * await b.staticServe.integrity(absFilePath); // SRI helper (SHA-384)
18
19
  *
20
+ * // onError mirrors onServe for the refusal paths (403 / 404 / 415 /
21
+ * // 412 / 416 / 429 / 451 / 500): it receives
22
+ * // { req, res, urlPath, absPath, status, code, actor } AFTER the
23
+ * // refusal response is written. Observability-only — a throw is
24
+ * // swallowed so a broken hook can't tear down the socket.
25
+ *
19
26
  * Backwards compatible: every existing opt (root, mountPath,
20
27
  * hashedPathPattern, indexFile, defaultMaxAge, contentTypes) keeps its
21
28
  * original meaning. New opts (permissions, cache, audit, observability,
@@ -450,6 +457,7 @@ function _validateCreateOpts(opts) {
450
457
  validateOpts.auditShape(opts.audit, "staticServe.create", StaticServeError);
451
458
  validateOpts.observabilityShape(opts.observability, "staticServe.create", StaticServeError);
452
459
  validateOpts.optionalFunction(opts.onServe, "staticServe.create: onServe", StaticServeError);
460
+ validateOpts.optionalFunction(opts.onError, "staticServe.create: onError", StaticServeError);
453
461
  // contentSafety — extension-keyed gate map. Default behaviour: when
454
462
  // undefined, the framework wires b.guardAll.byExtension({ profile:
455
463
  // "strict" }) automatically so every shipped guard is ON by default.
@@ -604,7 +612,7 @@ function create(opts) {
604
612
  "root", "mountPath", "hashedPathPattern",
605
613
  "indexFile", "defaultMaxAge", "contentTypes",
606
614
  "permissions", "cache", "fileType", "retention", "revokeStore",
607
- "allowedFileTypes", "audit", "observability", "onServe",
615
+ "allowedFileTypes", "audit", "observability", "onServe", "onError",
608
616
  "acceptRanges", "auditSuccess", "auditFailures",
609
617
  "maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
610
618
  "bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
@@ -657,6 +665,7 @@ function create(opts) {
657
665
  contentSafety = opts.contentSafety;
658
666
  }
659
667
  var onServe = opts.onServe || null;
668
+ var onError = opts.onError || null;
660
669
  var audit = opts.audit || null;
661
670
  var auditSuccess = cfg.auditSuccess;
662
671
  var auditFailures = cfg.auditFailures;
@@ -756,6 +765,26 @@ function create(opts) {
756
765
  var actorCtx = requestHelpers.extractActorContext(req);
757
766
  var actorKey = _actorKeyFromContext(actorCtx);
758
767
 
768
+ // Request-scoped error writer. Wraps the module-level _writeError so
769
+ // every refusal path (403 / 404 / 415 / 412 / 416 / 429 / 451 / 500)
770
+ // also invokes the operator's onError hook — the success-path mirror
771
+ // of onServe. The signature matches _writeError exactly; the `code`
772
+ // argument routes through to the hook so operators can branch on the
773
+ // refusal reason. The hook is observability-only: it runs AFTER the
774
+ // response is written and a throw is swallowed so a broken sink can't
775
+ // turn a 4xx into a torn-down socket.
776
+ function writeErr(r, status, code, message, headers) {
777
+ _writeError(r, status, code, message, headers);
778
+ if (onError) {
779
+ try {
780
+ onError({
781
+ req: req, res: r, urlPath: urlPath, absPath: absPath,
782
+ status: status, code: code, actor: actorCtx,
783
+ });
784
+ } catch (_he) { /* hook best-effort */ }
785
+ }
786
+ }
787
+
759
788
  // Permission gate (403)
760
789
  var perm = await _checkPermission(req);
761
790
  if (!perm.ok) {
@@ -766,7 +795,7 @@ function create(opts) {
766
795
  outcome: "failure", reason: "permission_denied", resource: urlPath,
767
796
  }, actorCtx));
768
797
  }
769
- return _writeError(res, HTTP.FORBIDDEN, "permission_denied",
798
+ return writeErr(res, HTTP.FORBIDDEN, "permission_denied",
770
799
  "Forbidden");
771
800
  }
772
801
 
@@ -788,7 +817,7 @@ function create(opts) {
788
817
  outcome: "failure", reason: "revoked", resource: urlPath,
789
818
  }, actorCtx));
790
819
  }
791
- return _writeError(res, HTTP.NOT_FOUND, "not_found", "Not Found");
820
+ return writeErr(res, HTTP.NOT_FOUND, "not_found", "Not Found");
792
821
  }
793
822
 
794
823
  // Compliance retention (451)
@@ -800,7 +829,7 @@ function create(opts) {
800
829
  outcome: "failure", reason: "retention_blocked", resource: urlPath,
801
830
  }, actorCtx));
802
831
  }
803
- return _writeError(res, HTTP.UNAVAILABLE_FOR_LEGAL_REASONS,
832
+ return writeErr(res, HTTP.UNAVAILABLE_FOR_LEGAL_REASONS,
804
833
  "retention_blocked", "Unavailable For Legal Reasons");
805
834
  }
806
835
 
@@ -820,7 +849,7 @@ function create(opts) {
820
849
  detectedMime: mimeCheck.detected || null,
821
850
  }, actorCtx));
822
851
  }
823
- return _writeError(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
852
+ return writeErr(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
824
853
  "mime_rejected", "Unsupported Media Type");
825
854
  }
826
855
  }
@@ -861,7 +890,7 @@ function create(opts) {
861
890
  catch (_e) {
862
891
  stats.failures += 1;
863
892
  if (gateHandle) { try { await gateHandle.close(); } catch (_ce) { /* close best-effort */ } }
864
- return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
893
+ return writeErr(res, HTTP.INTERNAL_SERVER_ERROR,
865
894
  "read_failed", "Internal Server Error");
866
895
  }
867
896
  try { await gateHandle.close(); } catch (_ce) { /* close best-effort */ }
@@ -885,7 +914,7 @@ function create(opts) {
885
914
  error: gateErr && gateErr.message,
886
915
  }, actorCtx));
887
916
  }
888
- return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
917
+ return writeErr(res, HTTP.INTERNAL_SERVER_ERROR,
889
918
  "content_safety_threw", "Internal Server Error");
890
919
  }
891
920
  if (!gateDecision.ok || gateDecision.action === "refuse") {
@@ -898,7 +927,7 @@ function create(opts) {
898
927
  issues: gateContract.summarizeIssues(gateDecision.issues),
899
928
  }, actorCtx));
900
929
  }
901
- return _writeError(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
930
+ return writeErr(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
902
931
  "content_safety_refused", "Unsupported Media Type");
903
932
  }
904
933
  if (gateDecision.action === "sanitize" && gateDecision.sanitized) {
@@ -929,7 +958,7 @@ function create(opts) {
929
958
  if (ifMatch && ifMatch !== "*" && ifMatch !== meta.etag) {
930
959
  stats.failures += 1;
931
960
  _emitObs("staticServe.precondition_failed", 1, { route: urlPath, header: "if-match" });
932
- return _writeError(res, HTTP.PRECONDITION_FAILED || 412,
961
+ return writeErr(res, HTTP.PRECONDITION_FAILED || 412,
933
962
  "precondition_failed", "Precondition Failed");
934
963
  }
935
964
 
@@ -956,7 +985,7 @@ function create(opts) {
956
985
  if (isFinite(ius) && Math.floor(meta.mtimeMs / C.TIME.seconds(1)) > Math.floor(ius / C.TIME.seconds(1))) {
957
986
  stats.failures += 1;
958
987
  _emitObs("staticServe.precondition_failed", 1, { route: urlPath, header: "if-unmodified-since" });
959
- return _writeError(res, HTTP.PRECONDITION_FAILED,
988
+ return writeErr(res, HTTP.PRECONDITION_FAILED,
960
989
  "precondition_failed", "Precondition Failed");
961
990
  }
962
991
  }
@@ -970,19 +999,19 @@ function create(opts) {
970
999
  if (range && (range.malformed || range.multi)) {
971
1000
  stats.failures += 1;
972
1001
  _emitObs("staticServe.range_invalid", 1, { route: urlPath });
973
- return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
1002
+ return writeErr(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
974
1003
  "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
975
1004
  }
976
1005
  if (range && range.unsatisfiable) {
977
1006
  stats.failures += 1;
978
1007
  _emitObs("staticServe.range_invalid", 1, { route: urlPath });
979
- return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
1008
+ return writeErr(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
980
1009
  "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
981
1010
  }
982
1011
  if (range && cfg.maxRangeBytes !== Infinity && range.length > cfg.maxRangeBytes) {
983
1012
  stats.failures += 1;
984
1013
  _emitObs("staticServe.range_too_large", 1, { route: urlPath });
985
- return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_too_large",
1014
+ return writeErr(res, HTTP.RANGE_NOT_SATISFIABLE, "range_too_large",
986
1015
  "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
987
1016
  }
988
1017
  if (range) {
@@ -1005,7 +1034,7 @@ function create(opts) {
1005
1034
  current: concCheck.current, cap: concCheck.cap,
1006
1035
  }, actorCtx));
1007
1036
  }
1008
- return _writeError(res, HTTP.TOO_MANY_REQUESTS,
1037
+ return writeErr(res, HTTP.TOO_MANY_REQUESTS,
1009
1038
  "concurrency_cap", "Too Many Requests",
1010
1039
  { "Retry-After": "5" });
1011
1040
  }
@@ -1021,7 +1050,7 @@ function create(opts) {
1021
1050
  scope: bwCheck.scope, used: bwCheck.used, cap: bwCheck.cap,
1022
1051
  }, actorCtx));
1023
1052
  }
1024
- return _writeError(res, HTTP.TOO_MANY_REQUESTS,
1053
+ return writeErr(res, HTTP.TOO_MANY_REQUESTS,
1025
1054
  "bandwidth_quota", "Too Many Requests",
1026
1055
  { "Retry-After": String(bwCheck.retryAfter) });
1027
1056
  }
@@ -1077,7 +1106,7 @@ function create(opts) {
1077
1106
  error: e && e.message,
1078
1107
  }, actorCtx));
1079
1108
  }
1080
- return _writeError(res, HTTP.INTERNAL_SERVER_ERROR, "onServe_threw",
1109
+ return writeErr(res, HTTP.INTERNAL_SERVER_ERROR, "onServe_threw",
1081
1110
  "Internal Server Error");
1082
1111
  }
1083
1112
  }
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  var { defineClass } = require("./framework-error");
32
+ var codepointClass = require("./codepoint-class");
32
33
 
33
34
  var UriTemplateError = defineClass("UriTemplateError", { alwaysPermanent: true });
34
35
 
@@ -59,7 +60,8 @@ function _pctEncode(str, allowReserved) {
59
60
  var ch = str.charAt(i);
60
61
  // Preserve existing percent-encoded triplets when the reserved set is
61
62
  // allowed (operators "+" and "#").
62
- if (allowReserved && ch === "%" && /^[0-9A-Fa-f]{2}$/.test(str.substr(i + 1, 2))) {
63
+ // allow:regex-no-length-cap the substr is a fixed 2-char window
64
+ if (allowReserved && ch === "%" && codepointClass.HEX_PAIR_RE.test(str.substr(i + 1, 2))) {
63
65
  out += str.substr(i, 3); i += 2; continue;
64
66
  }
65
67
  if (UNRESERVED.test(ch) || (allowReserved && RESERVED.test(ch))) { out += ch; continue; }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.16",
3
+ "version": "0.14.19",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.14.17",
4
+ "date": "2026-05-31",
5
+ "headline": "In-session API-encrypt errors stay confidential, and the encrypted client can read them",
6
+ "summary": "When b.middleware.apiEncrypt is active, a normal response is sealed in the { _ct } envelope, but a terminal error that bypassed res.json (an error page, a validation refusal, an RFC 9457 problem+json document, a deny-middleware body) shipped in plaintext on the otherwise-encrypted channel, and the b.httpClient.encrypted client threw on the response shape because it tried to decrypt every reply. This release makes errors symmetric: those four sinks now seal their body in the same envelope a success uses whenever a session is active (via a new req.apiEncryptEncode the middleware installs after a successful decrypt), and the client gains a responseMode: \"passthrough\" that reads a non-2xx — decrypting an in-session error and returning a plaintext one verbatim — instead of throwing. Errors raised before a session exists (a Bearer 401, a handshake rejection, a replay refusal) deliberately stay plaintext and human-readable. Two adjacent streaming fixes ride along: a streamed (responseMode \"stream\") non-2xx now keeps a bounded prefix of the error body on the thrown error instead of draining it, and an SSE channel closed by a transport fault audits as a failure with a reason rather than looking like a clean operator close.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.httpClient.encrypted` gains `responseMode: \"passthrough\"`",
13
+ "body": "The encrypted client defaults to `responseMode: \"reject\"` (a non-2xx rejects, exactly as before). Set `responseMode: \"passthrough\"` at create time, or per request, to resolve a non-2xx instead: the result is `{ statusCode, headers, body, ok }`, where `body` is the decrypted object when the reply carries an encrypted `_ct` envelope (an in-session error) and the parsed plaintext otherwise (a pre-session error such as a Bearer 401 or a proxy 502). The additive `ok` boolean (status 200–299) is present on every result. This lets a client read an error's status and detail instead of failing on the response shape."
14
+ },
15
+ {
16
+ "title": "`req.apiEncryptEncode` — the in-session error encoder",
17
+ "body": "`b.middleware.apiEncrypt` installs `req.apiEncryptEncode(obj)` after it successfully decrypts a request body. It returns the same `{ _ct }` (plus `_sid` / `_ctr` in per-session mode) envelope a normal response uses, so a terminal handler that writes its body directly (bypassing the wrapped `res.json`) can keep an error confidential on the encrypted channel. It is present only after a valid decrypt, so every pre-session path has no encoder and its errors stay plaintext."
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "heading": "Changed",
23
+ "items": [
24
+ {
25
+ "title": "In-session terminal errors are sealed in the encrypted envelope",
26
+ "body": "The error page (JSON branch), the router's schema-validation refusal, `b.problemDetails.respond` (now accepting an optional `req`), and the access-deny middleware now seal their error body via `req.apiEncryptEncode` when a session is active — so an in-session error no longer ships as plaintext while the surrounding traffic is encrypted. A sealed problem+json is labelled `application/json` (the envelope), not `application/problem+json`. With no active session (or if encoding fails) the body stays plaintext and readable, unchanged."
27
+ },
28
+ {
29
+ "title": "Streamed non-2xx responses preserve a bounded error-body prefix",
30
+ "body": "`b.httpClient.request({ responseMode: \"stream\" })` (and `b.httpClient.downloadStream`, which composes it) previously drained and discarded the body of a `>= 400` response. The rejection is unchanged, but the thrown error now carries a bounded (16 KiB) prefix of the body on `err.body`, so a caller can read the problem+json / error detail. The prefix is collected through `b.safeBuffer.boundedChunkCollector`, so a hostile oversized error body can't accumulate unbounded."
31
+ },
32
+ {
33
+ "title": "SSE transport-fault close audits as a failure",
34
+ "body": "An SSE channel closed by a transport fault (a stream `error`, a failed heartbeat write) now emits its `sse.channel_closed` audit event with `outcome: \"failure\"` and a `reason`, instead of the `\"success\"` outcome an intentional `channel.close()` records — so an operator's evidence stream can tell a dropped connection from a clean shutdown."
35
+ }
36
+ ]
37
+ },
38
+ {
39
+ "heading": "Security",
40
+ "items": [
41
+ {
42
+ "title": "Error bodies no longer leak in plaintext on an encrypted channel",
43
+ "body": "Before this release, a request that established an apiEncrypt session but then failed (validation error, access denial, server error rendered through the error page or problem-details) returned its error body in plaintext, even though every successful response on the same channel was encrypted — exposing error detail (paths, field names, refusal reasons) to a network observer. In-session errors are now encrypted symmetrically with successes; pre-session errors, which a client must be able to read to recover, remain plaintext by design."
44
+ }
45
+ ]
46
+ },
47
+ {
48
+ "heading": "Migration",
49
+ "items": [
50
+ {
51
+ "title": "Opt into reading non-2xx encrypted errors",
52
+ "body": "No change is required for existing callers — `b.httpClient.encrypted` still defaults to `responseMode: \"reject\"` (throw on non-2xx). To read an error's status and decrypted detail, create the client with `responseMode: \"passthrough\"` (or pass it per request) and branch on the returned `ok` / `statusCode`. The new `ok` field is additive and does not affect existing `{ statusCode, headers, body }` consumers."
53
+ }
54
+ ]
55
+ }
56
+ ]
57
+ }
@@ -0,0 +1,127 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.14.18",
4
+ "date": "2026-06-01",
5
+ "headline": "Advertised endpoints that silently failed now work, plus opt-in response-shaping, transport hardening, and ZIP64 read",
6
+ "summary": "This release closes a set of advertised-but-broken surfaces and adds the extensibility hooks operators kept having to work around. Three endpoints silently failed: the CSP-report endpoint returned 413 to every POST (it never parsed a report), the SCIM server broke on any streamed request body, and both came from misusing the bounded-buffer collector as if it consumed a stream — so this release adds the missing b.safeBuffer.collectStream(stream, opts) primitive and routes both through it. The agent orchestrator's graceful-drain phase never registered (wrong method name), the OpenAPI/AsyncAPI doc endpoints ignored a documented single-origin CORS allowlist, and the EU AI Act Article 50 HTML banner skipped Buffer response bodies. Deny-path refusals gained an RFC 9457 problem-type derived from the problem code (a 429 read about:blank before) and now treat a consumer hook that commits headers as terminal. On top of the fixes, several middlewares gain operator hooks (custom JSON/HTML error formatters, problem+json, refusal callbacks), several entry points gain escape-hatch opts (a configurable Authorization scheme, legacy filename charsets, a submission relay port, an exit-after-phases shutdown), the local job queue can point at an operator-supplied database/table/schema, breach-notification deadlines gain a running escalation clock, the archive reader now reads ZIP64, and external databases gain an opt-in TLS-required posture plus OpenTelemetry db.* trace attributes. Every behavior-changing addition is strictly opt-in — an operator who sets no new option sees no change.",
7
+ "sections": [
8
+ {
9
+ "heading": "Fixed",
10
+ "items": [
11
+ {
12
+ "title": "`b.middleware.cspReport` returned 413 to every POST; `b.middleware.scimServer` broke on streamed bodies",
13
+ "body": "Both read the request body by calling `b.safeBuffer.boundedChunkCollector(req, …)` — but that primitive takes a single options object and returns a push-collector, it does not consume a stream. The call passed the request as the options argument (so `maxBytes` was undefined and it threw), turning every CSP-report POST into a 413 and failing every streamed SCIM request. Both now read the body through the new `b.safeBuffer.collectStream`. A valid CSP report now reaches the parse/audit/`onReport` path and returns 204."
14
+ },
15
+ {
16
+ "title": "Agent orchestrator graceful-drain phase never registered",
17
+ "body": "`b.middleware.agentOrchestrator` registered its drain phase against a method the shutdown handle does not expose, so the phase silently never ran — connections were not drained on a graceful shutdown. It now registers through the real `addPhase` so the documented drain actually fires."
18
+ },
19
+ {
20
+ "title": "OpenAPI / AsyncAPI doc endpoints ignored `accessControl: { allowOrigin }`",
21
+ "body": "The documented single-origin CORS form was read by neither serve middleware (only `accessControl: \"public\"` was handled), so an operator passing `{ allowOrigin: \"https://docs.example.com\" }` got no `Access-Control-Allow-Origin` header. The object form now emits a validated origin (passed through `b.safeUrl`, rejecting CR/LF injection, userinfo, and non-http(s) schemes) with `Vary: Origin`."
22
+ },
23
+ {
24
+ "title": "EU AI Act Article 50 HTML banner skipped Buffer response bodies",
25
+ "body": "In HTML mode the disclosure banner was injected only when the final response chunk was a string; a `res.end(Buffer.from(html))` body (the common path through templating/render) silently received no banner. Buffer bodies are now decoded under the response charset, injected, and re-encoded (with a one-time warning and untouched bytes for charsets without a transcoder)."
26
+ },
27
+ {
28
+ "title": "Deny-path 429 (and any refusal) emitted `type: about:blank`",
29
+ "body": "The shared deny-path writer documented deriving the RFC 9457 problem-type URI from the refusal's problem code, but only ever read an explicit problem type — so a rate-limit 429 (which supplies a code, not a type) reported `about:blank`. The type is now derived from the problem code (`<base>/<code>`) when no explicit type is given, so a 429 reads `…/rate-limit-exceeded`."
30
+ },
31
+ {
32
+ "title": "A deny-path `onDeny` hook that commits headers is treated as terminal",
33
+ "body": "The deny-path writer's terminal check looked only at `res.writableEnded`; a response-wrapping consumer whose `onDeny` sent headers without ending the response fell through into a second `writeHead` and hit \"headers already sent\". The writer now also treats `res.headersSent` as terminal, so wrapping responders compose cleanly."
34
+ },
35
+ {
36
+ "title": "`b.mail.send.deliver` DANE TLSA lookup ignored the configured port",
37
+ "body": "The DANE TLSA query name (`_<port>._tcp.<host>`) was hardcoded to port 25; a delivery configured for a 587/465 relay still queried port 25. The lookup now uses the configured port."
38
+ }
39
+ ]
40
+ },
41
+ {
42
+ "heading": "Added",
43
+ "items": [
44
+ {
45
+ "title": "`b.safeBuffer.collectStream(stream, opts)` — bounded stream-to-Buffer reader",
46
+ "body": "Reads a Node Readable (a request body, an upstream response) fully into one Buffer with the byte cap enforced at every chunk — the streaming sibling of `boundedChunkCollector`, which is a push-collector, not a stream consumer. Resolves the concatenated Buffer on end; rejects (and destroys the stream) the moment a chunk would overflow `maxBytes`."
47
+ },
48
+ {
49
+ "title": "Operator response-shaping hooks",
50
+ "body": "`b.errorPage` gains `jsonFormatter(info, req)` and `renderHtml(info, req)` overrides plus a `problemDetails: true` RFC 9457 mode (each falls back to the built-in envelope on throw, so the original error is never masked); `b.render.json` gains an `opts.replacer` passthrough (BigInt/Date); `b.middleware.cspReport` gains `onReject(req, res, { status, reason })` for the otherwise-empty-bodied 405/413/400 refusals; `b.static` gains `onError` firing on every refusal path, mirroring `onServe`."
51
+ },
52
+ {
53
+ "title": "Escape-hatch opts for non-default deployments",
54
+ "body": "`b.appShutdown.create({ exitAfterPhases })` lets a manual `shutdown()` exit after its phases (not only a signal-driven shutdown); `b.middleware.attachUser({ bearerScheme, tokenExtractor })` reads `Token`/`DPoP`/gateway Authorization schemes (RFC 6750 / RFC 9449); `b.middleware.bodyParser` multipart `filenameCharsets` opts iso-8859-1 `filename*` decoding in (RFC 5987); `b.mail.send.deliver.create({ port })` routes through a 587/465 submission relay (RFC 6409 / RFC 8314)."
55
+ },
56
+ {
57
+ "title": "Local job queue: bring-your-own database, table, and schema",
58
+ "body": "`b.queue.init` local-backend config accepts `db` (an operator-supplied handle), `table`, and `schema`. Table and schema names are validated and quoted as SQL identifiers (refused at init time when unsafe); the sealed `payload`/`lastError` columns stay sealed regardless of the physical table. Defaults are unchanged (`_blamejs_jobs` on the framework/cluster database)."
59
+ },
60
+ {
61
+ "title": "`b.breach.deadline.createClock` — running breach-notification escalation clock",
62
+ "body": "A detection-to-notification clock that escalates each affected US state's statutory breach-notification deadline (an approaching warning, a passed alert) and accepts per-state filing acknowledgements, complementing the existing static deadline lookup. It composes the incident-report deadline clock, so there is a single timer to start and stop."
63
+ },
64
+ {
65
+ "title": "`b.archive.read.zip` reads ZIP64",
66
+ "body": "Archives with more than 65535 entries or with sizes/offsets past 4 GiB now decode transparently (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.5.3). Previously such archives were refused outright. The bomb, Zip-Slip, PATH_MAX, and entry-type defenses all continue to apply to the resolved 64-bit values."
67
+ },
68
+ {
69
+ "title": "External-database opt-in TLS posture and OpenTelemetry `db.*` traces",
70
+ "body": "`b.externalDb` backends accept `requireTls: true` (default off), which refuses a backend at config time unless its declared transport is TLS (`tls` / a truthy `ssl` / `sslmode` `require|verify-ca|verify-full`) — for cardholder data or ePHI (PCI-DSS v4.0 Req 4 / HIPAA §164.312(e)). Query/transaction/read traces now also carry OpenTelemetry database semantic-convention attributes (`db.system`, `db.operation`, `db.statement` (sanitized), `db.name`)."
71
+ },
72
+ {
73
+ "title": "Opt-in schema-drift detection",
74
+ "body": "`b.db.init({ onDrift })` (and the underlying `reconcile({ onDrift })`) opts into config-vs-live column-drift detection: `\"warn\"` reports columns present in the live database but absent from the declared schema; `\"refuse\"` makes the framework refuse to boot on drift (a strict-schema posture — ISO 27001:2022 A.8.9 / SOC 2 CC8.1). The default (`\"ignore\"`) is unchanged, and drift is never resolved destructively."
75
+ }
76
+ ]
77
+ },
78
+ {
79
+ "heading": "Changed",
80
+ "items": [
81
+ {
82
+ "title": "`b.guardEmail` documents its actual Unicode scope",
83
+ "body": "Domain-side IDN/Punycode and mixed-script confusable detection are supported (and unchanged); the local-part is ASCII atext only (RFC 5321/5322). The documentation previously implied RFC 6531 SMTPUTF8/EAI mailbox names were accepted — they are rejected by default, re-openable behind a future `allowUnicodeLocalPart` opt-in, to keep homograph/confusable exposure bounded to the domain side. No validation behavior changed."
84
+ },
85
+ {
86
+ "title": "Body-parse 4xx responses send `Connection: close`",
87
+ "body": "Every body-parse rejection (malformed JSON, poisoned key, oversized payload, bad content-length) now closes the connection, matching the chunked-decode path, to deny an upstream proxy reusing a socket whose request stream the parser abandoned mid-body (RFC 9112 §9.6)."
88
+ },
89
+ {
90
+ "title": "Archive-reader default entry-count cap raised to 1,048,576",
91
+ "body": "To read large (ZIP64) archives, the default `bombPolicy.maxEntries` rises from 65535 to 2^20. An operator who set `maxEntries` explicitly is unaffected; the cap can still be lowered."
92
+ }
93
+ ]
94
+ },
95
+ {
96
+ "heading": "Security",
97
+ "items": [
98
+ {
99
+ "title": "Closed a request-smuggling socket-reuse window on body-parse rejections",
100
+ "body": "Pairing every body-parse 4xx with `Connection: close` stops an upstream proxy from reusing a connection whose request body the parser abandoned mid-stream — a request/response desync vector that previously applied only to the chunked-decode rejections."
101
+ },
102
+ {
103
+ "title": "Opt-in TLS-required posture for regulated external databases",
104
+ "body": "Set `requireTls: true` on an external-database backend carrying cardholder data or ePHI so a non-TLS (or fallback-permitting `sslmode`) connection is refused at boot rather than silently transmitting in the clear (PCI-DSS v4.0 Req 4 / HIPAA §164.312(e))."
105
+ }
106
+ ]
107
+ },
108
+ {
109
+ "heading": "Detectors",
110
+ "items": [
111
+ {
112
+ "title": "Regression guards for every fixed bug class",
113
+ "body": "The internal pattern gate gains detectors so each fixed mistake cannot return: a bounded-collector call used as a stream consumer, a deny-path writer that does not guard `headersSent`, an `appShutdown` phase registered through a non-existent method, a body-parse error writer missing `Connection: close`, a raw `_blamejs_jobs` table reference bypassing the configured-table quoting, and a breach clock that re-rolls its own timer instead of composing the incident-report clock."
114
+ }
115
+ ]
116
+ },
117
+ {
118
+ "heading": "Migration",
119
+ "items": [
120
+ {
121
+ "title": "No action required; new behavior is opt-in",
122
+ "body": "Every addition is additive or opt-in: `requireTls`, schema-drift `onDrift`, the response-shaping hooks, the new entry-point opts, and the breach clock all default to prior behavior when unset. Two defaults shift in a backward-compatible direction: body-parse 4xx now send `Connection: close`, and the archive reader's default entry-count ceiling rises to 2^20 (lower it via `bombPolicy.maxEntries` if you relied on the old cap). `b.db.reconcile` now returns a drift report object instead of undefined; the framework's own call ignores the return, so existing callers are unaffected."
123
+ }
124
+ ]
125
+ }
126
+ ]
127
+ }
@@ -0,0 +1,61 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.14.19",
4
+ "date": "2026-06-02",
5
+ "headline": "ZIP64 write completes large-archive support, plus a PKCE-downgrade defense, SCIM Bulk, OID4VCI kid-resolution, SPF macros, DMARC report building, and OpenAPI 3.2",
6
+ "summary": "This release fills out several standards the framework previously read but could not write, or advertised but did not fully implement. The ZIP writer now emits ZIP64, so archives larger than 65535 entries or 4 GiB are produced instead of refused (the reader gained ZIP64 in the previous release; the two now round-trip). The OAuth client refuses an OP whose discovery metadata advertises PKCE methods without S256 — closing a stripped-S256 downgrade. The SCIM server gains opt-in Bulk operations, the OID4VCI issuer accepts a kid-resolution hook for EUDI-Wallet attested-key proofs (and a latent cache-cleanup crash on the issuance path is fixed), the inbound SPF check expands RFC 7208 macros and evaluates the exists mechanism, a DMARC aggregate-report builder is the inverse of the existing parser, and the OpenAPI emitter supports 3.2 with webhooks and a JSON Schema dialect. Every behavior-changing addition is opt-in or additive: classic archives emit byte-for-byte unchanged, OpenAPI still defaults to 3.1.0, SCIM Bulk stays disabled unless configured, and the PKCE refusal only fires for an OP that explicitly advertises a non-S256 method set.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.archive.zip` writes ZIP64",
13
+ "body": "When an archive exceeds 65535 entries, or any entry's compressed/uncompressed size or local-header offset exceeds 4 GiB, the writer now emits ZIP64 — the classic field carries the `0xFFFF`/`0xFFFFFFFF` sentinel and a ZIP64 extended-information extra field (header id `0x0001`) plus a ZIP64 EOCD record and locator are written (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.4.8 / §4.5.3). Archives within the classic limits emit byte-for-byte unchanged, and `b.archive.read.zip` reads the ZIP64 output transparently. Previously the writer refused past 65535 entries."
14
+ },
15
+ {
16
+ "title": "`b.middleware.scimServer` opt-in SCIM 2.0 Bulk operations",
17
+ "body": "A `/Bulk` POST endpoint (RFC 7644 §3.7) with `maxOperations` / `maxPayloadSize` caps, `bulkId` cross-reference resolution (§3.7.2), and `failOnErrors` short-circuiting, advertised in `ServiceProviderConfig` when enabled via `opts.bulk`. The request body is read through the bounded stream reader. Bulk stays disabled (and `/Bulk` returns 501) unless `opts.bulk` is set."
18
+ },
19
+ {
20
+ "title": "`b.auth.oid4vci` accepts a `resolveKid` proof-key hook",
21
+ "body": "An OID4VCI proof JWT that carries a header `kid` without an inline `jwk` (the EUDI-Wallet attested-key shape) can now be verified by supplying `create({ resolveKid(kid, header) })`, which maps the key id to a holder key. The resolved key passes the same alg/key-type cross-check (CVE-2026-22817) as the inline path before import and becomes the credential's `cnf` binding. Without a resolver, a kid-only proof is still refused, exactly as before."
22
+ },
23
+ {
24
+ "title": "SPF macro expansion + `exists`; DMARC aggregate-report builder",
25
+ "body": "`b.mail.spf.verify` now expands RFC 7208 §7 macros (`%{i}`/`%{s}`/`%{l}`/`%{d}`/`%{o}`/`%{h}`/`%{v}` with the reverse / digit-count / delimiter transformers and `%%`/`%_`/`%-` escapes) and evaluates the `exists` mechanism (§5.7); the §4.6.4 DNS-lookup and void-lookup ceilings still bound every macro-driven query. New `b.mail.dmarc.buildAggregateReport` serializes a DMARC aggregate (RUA) report to RFC 7489 Appendix C XML — the inverse of `parseAggregateReport` — with optional gzip and XML-metacharacter escaping of observed identifiers."
26
+ },
27
+ {
28
+ "title": "OpenAPI 3.2 — webhooks and `jsonSchemaDialect`",
29
+ "body": "`b.openapi.create({ openapi: \"3.2.0\" })` opts into OpenAPI 3.2; a `webhook(name, method, opts)` builder registers top-level `webhooks` (API-initiated out-of-band operations), and `jsonSchemaDialect` declares the document's default JSON Schema dialect. `parse()` accepts 3.1.x or 3.2.x and validates webhook operations (including dangling-security references) with the same rules as paths. 3.1.0 stays the default emitted version and existing 3.1 documents are unchanged."
30
+ }
31
+ ]
32
+ },
33
+ {
34
+ "heading": "Fixed",
35
+ "items": [
36
+ {
37
+ "title": "OID4VCI issuance crashed on cache cleanup",
38
+ "body": "The pre-authorized-code exchange and credential-issuance paths called `.delete()` on their internal `b.cache` stores, which expose `del()` — so the cleanup step threw and the issuance flow could not complete end-to-end. The three call sites now use `del()`."
39
+ }
40
+ ]
41
+ },
42
+ {
43
+ "heading": "Security",
44
+ "items": [
45
+ {
46
+ "title": "PKCE-downgrade defense for OAuth / OIDC clients",
47
+ "body": "The OAuth client already mandates PKCE-S256 on its own side but never inspected the OP's published `code_challenge_methods_supported`. It now refuses, at `authorizationUrl` / `pushAuthorizationRequest`, an OP whose discovery metadata advertises that field without `\"S256\"` (plain-only / empty) — blocking a stripped-S256 downgrade that would otherwise drive the client into an authorization request the OP claims it cannot verify (RFC 9700 §4.13, RFC 7636). An OP that does not publish the field, and static-endpoint clients that perform no discovery, are unaffected."
48
+ }
49
+ ]
50
+ },
51
+ {
52
+ "heading": "Migration",
53
+ "items": [
54
+ {
55
+ "title": "No action required; additions are additive or opt-in",
56
+ "body": "ZIP64 write is additive — archives within the classic 65535-entry / 4 GiB limits emit identical bytes, and the writer simply no longer refuses larger ones. SCIM Bulk, the OID4VCI `resolveKid` hook, the DMARC report builder, and OpenAPI 3.2 are all opt-in (OpenAPI still defaults to 3.1.0). The SPF `exists` mechanism, previously returning permerror, now evaluates per RFC 7208 — `b.mail.spf.verify` returns a verdict and refuses nothing. The PKCE-downgrade refusal only fires for an OP that explicitly advertises a non-S256 PKCE method set."
57
+ }
58
+ ]
59
+ }
60
+ ]
61
+ }