@blamejs/blamejs-shop 0.3.70 → 0.3.72

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 (93) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/lib/admin.js +254 -2
  4. package/lib/asset-manifest.json +1 -1
  5. package/lib/customer-segments.js +150 -0
  6. package/lib/vendor/MANIFEST.json +95 -83
  7. package/lib/vendor/blamejs/.github/workflows/actions-lint.yml +3 -3
  8. package/lib/vendor/blamejs/.github/workflows/cflite_batch.yml +1 -1
  9. package/lib/vendor/blamejs/.github/workflows/cflite_pr.yml +1 -1
  10. package/lib/vendor/blamejs/.github/workflows/ci.yml +10 -10
  11. package/lib/vendor/blamejs/.github/workflows/codeql.yml +3 -3
  12. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +2 -2
  13. package/lib/vendor/blamejs/.github/workflows/release-container.yml +4 -4
  14. package/lib/vendor/blamejs/.github/workflows/scorecard.yml +2 -2
  15. package/lib/vendor/blamejs/.github/workflows/sha-to-tag-verify.yml +1 -1
  16. package/lib/vendor/blamejs/CHANGELOG.md +4 -0
  17. package/lib/vendor/blamejs/README.md +1 -1
  18. package/lib/vendor/blamejs/SECURITY.md +2 -0
  19. package/lib/vendor/blamejs/api-snapshot.json +108 -4
  20. package/lib/vendor/blamejs/lib/auth/oauth.js +736 -1
  21. package/lib/vendor/blamejs/lib/auth/oid4vci.js +124 -5
  22. package/lib/vendor/blamejs/lib/auth/oid4vp.js +14 -4
  23. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc-holder.js +46 -1
  24. package/lib/vendor/blamejs/lib/break-glass.js +1 -2
  25. package/lib/vendor/blamejs/lib/config.js +28 -31
  26. package/lib/vendor/blamejs/lib/crypto-field.js +274 -17
  27. package/lib/vendor/blamejs/lib/dora.js +8 -5
  28. package/lib/vendor/blamejs/lib/dsr.js +2 -2
  29. package/lib/vendor/blamejs/lib/flag-evaluation-context.js +7 -0
  30. package/lib/vendor/blamejs/lib/guard-html-wcag-aria.js +4 -2
  31. package/lib/vendor/blamejs/lib/guard-html-wcag-forms.js +4 -2
  32. package/lib/vendor/blamejs/lib/guard-html-wcag-tables.js +4 -2
  33. package/lib/vendor/blamejs/lib/guard-html-wcag-tagwalk.js +20 -0
  34. package/lib/vendor/blamejs/lib/guard-html-wcag.js +1 -1
  35. package/lib/vendor/blamejs/lib/honeytoken.js +27 -20
  36. package/lib/vendor/blamejs/lib/mail-auth.js +333 -0
  37. package/lib/vendor/blamejs/lib/mail-deploy.js +1 -1
  38. package/lib/vendor/blamejs/lib/mail-send-deliver.js +13 -4
  39. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +140 -13
  40. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +3 -0
  41. package/lib/vendor/blamejs/lib/middleware/csp-report.js +13 -9
  42. package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +115 -14
  43. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +3 -0
  44. package/lib/vendor/blamejs/lib/middleware/scim-server.js +297 -19
  45. package/lib/vendor/blamejs/lib/middleware/security-headers.js +47 -0
  46. package/lib/vendor/blamejs/lib/middleware/security-txt.js +1 -2
  47. package/lib/vendor/blamejs/lib/middleware/trace-log-correlation.js +1 -2
  48. package/lib/vendor/blamejs/lib/network-smtp-policy.js +4 -4
  49. package/lib/vendor/blamejs/lib/object-store/sigv4-bucket-ops.js +11 -2
  50. package/lib/vendor/blamejs/lib/observability-tracer.js +1 -1
  51. package/lib/vendor/blamejs/lib/observability.js +39 -1
  52. package/lib/vendor/blamejs/lib/problem-details.js +56 -11
  53. package/lib/vendor/blamejs/lib/pubsub-cluster.js +16 -3
  54. package/lib/vendor/blamejs/lib/queue-sqs.js +20 -2
  55. package/lib/vendor/blamejs/lib/redis-client.js +32 -4
  56. package/lib/vendor/blamejs/lib/safe-redirect.js +16 -2
  57. package/lib/vendor/blamejs/memory/specs/node-26-map-getorinsert-migration.md +3 -2
  58. package/lib/vendor/blamejs/package.json +1 -1
  59. package/lib/vendor/blamejs/release-notes/v0.14.20.json +73 -0
  60. package/lib/vendor/blamejs/release-notes/v0.14.21.json +98 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +339 -0
  62. package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +37 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/break-glass.test.js +22 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +315 -5
  65. package/lib/vendor/blamejs/test/layer-0-primitives/config.test.js +46 -0
  66. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-unseal-rate-cap.test.js +176 -0
  67. package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +86 -0
  68. package/lib/vendor/blamejs/test/layer-0-primitives/dora.test.js +38 -0
  69. package/lib/vendor/blamejs/test/layer-0-primitives/dsr.test.js +29 -0
  70. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +236 -1
  71. package/lib/vendor/blamejs/test/layer-0-primitives/fetch-metadata.test.js +190 -0
  72. package/lib/vendor/blamejs/test/layer-0-primitives/flag.test.js +23 -0
  73. package/lib/vendor/blamejs/test/layer-0-primitives/guard-html-wcag.test.js +59 -0
  74. package/lib/vendor/blamejs/test/layer-0-primitives/honeytoken.test.js +26 -0
  75. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -0
  76. package/lib/vendor/blamejs/test/layer-0-primitives/mail-deploy-tlsrpt.test.js +16 -0
  77. package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +108 -0
  78. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +269 -0
  79. package/lib/vendor/blamejs/test/layer-0-primitives/observability-tracing.test.js +28 -0
  80. package/lib/vendor/blamejs/test/layer-0-primitives/observability.test.js +39 -0
  81. package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +37 -0
  82. package/lib/vendor/blamejs/test/layer-0-primitives/problem-details.test.js +79 -0
  83. package/lib/vendor/blamejs/test/layer-0-primitives/pubsub.test.js +49 -0
  84. package/lib/vendor/blamejs/test/layer-0-primitives/queue-sqs.test.js +48 -0
  85. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +60 -0
  86. package/lib/vendor/blamejs/test/layer-0-primitives/safe-redirect.test.js +118 -0
  87. package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +259 -0
  88. package/lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc.test.js +46 -0
  89. package/lib/vendor/blamejs/test/layer-0-primitives/security-headers.test.js +113 -0
  90. package/lib/vendor/blamejs/test/layer-0-primitives/security-txt.test.js +111 -0
  91. package/lib/vendor/blamejs/test/layer-0-primitives/sigv4-bucket-ops.test.js +62 -0
  92. package/lib/vendor/blamejs/test/layer-0-primitives/smtp-policy.test.js +39 -0
  93. package/package.json +1 -1
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ /**
3
+ * b.middleware.fetchMetadata — Sec-Fetch-* resource-isolation gate.
4
+ *
5
+ * Covers the destination-vocabulary surface added for FedCM /
6
+ * Storage Access API traffic (Fetch Metadata Request Headers spec):
7
+ *
8
+ * - default behavior unchanged when the new opts are unset;
9
+ * - deniedDest refuses a "webidentity" (FedCM) Sec-Fetch-Dest on a
10
+ * non-identity route, regardless of Sec-Fetch-Site;
11
+ * - allowStorageAccess:false refuses the cross-site
12
+ * Sec-Fetch-Storage-Access: active|inactive escalation, and "none"
13
+ * passes through;
14
+ * - strictDest throws at config time on an unknown destination value;
15
+ * - membership tests are exact (no substring / prototype-pollution
16
+ * bypass).
17
+ */
18
+
19
+ var helpers = require("../helpers");
20
+ var b = helpers.b;
21
+ var check = helpers.check;
22
+ var _bodyReq = helpers._bodyReq;
23
+ var _bodyRes = helpers._bodyRes;
24
+
25
+ function _run(mw, req, res) {
26
+ return new Promise(function (resolve) {
27
+ var settled = false;
28
+ function done(via) {
29
+ if (settled) return;
30
+ settled = true;
31
+ resolve({ next: via === "next", status: res._endedStatus, captured: res._captured });
32
+ }
33
+ // Register the finish listener BEFORE invoking the middleware: the
34
+ // refusal path is fully synchronous (writeHead + end → emit "finish")
35
+ // so a listener attached after mw() would miss the event entirely.
36
+ res.on("finish", function () { done("finish"); });
37
+ mw(req, res, function () { done("next"); });
38
+ });
39
+ }
40
+
41
+ function _post(headers) {
42
+ return _bodyReq("POST", headers || {}, "");
43
+ }
44
+
45
+ function _reason(captured) {
46
+ try { return JSON.parse(captured || "{}").error || ""; }
47
+ catch (_e) { return ""; }
48
+ }
49
+
50
+ // Default surface: opts unset → prior behavior is preserved. A FedCM
51
+ // webidentity Sec-Fetch-Dest cross-site is still refused by the existing
52
+ // cross-site rule (not by the new deniedDest path), and a same-origin
53
+ // webidentity passes through.
54
+ async function testDefaultUnchanged() {
55
+ check("middleware.fetchMetadata exposed",
56
+ typeof b.middleware.fetchMetadata === "function");
57
+
58
+ var mw = b.middleware.fetchMetadata({});
59
+
60
+ var sameOrigin = await _run(mw, _post({
61
+ "sec-fetch-site": "same-origin", "sec-fetch-dest": "webidentity",
62
+ }), _bodyRes());
63
+ check("same-origin webidentity passes through by default",
64
+ sameOrigin.next === true);
65
+
66
+ var cross = await _run(mw, _post({
67
+ "sec-fetch-site": "cross-site", "sec-fetch-dest": "webidentity",
68
+ }), _bodyRes());
69
+ check("cross-site request still refused by default (unchanged)",
70
+ cross.next === false && cross.status === 403);
71
+ }
72
+
73
+ // deniedDest gates webidentity first-class — refused even when the
74
+ // request is same-origin (a route that is not a FedCM endpoint should
75
+ // never see a webidentity destination).
76
+ async function testDeniedDestWebIdentity() {
77
+ var mw = b.middleware.fetchMetadata({ deniedDest: ["webidentity"] });
78
+
79
+ var denied = await _run(mw, _post({
80
+ "sec-fetch-site": "same-origin", "sec-fetch-dest": "webidentity",
81
+ }), _bodyRes());
82
+ check("deniedDest webidentity → 403 even same-origin",
83
+ denied.next === false && denied.status === 403);
84
+ check("deniedDest refusal carries the dest-not-allowed message",
85
+ /destination not allowed/i.test(_reason(denied.captured)));
86
+
87
+ // A non-denied destination on the same gate still passes through.
88
+ var allowed = await _run(mw, _post({
89
+ "sec-fetch-site": "same-origin", "sec-fetch-dest": "document",
90
+ }), _bodyRes());
91
+ check("non-denied destination passes through with deniedDest set",
92
+ allowed.next === true);
93
+ }
94
+
95
+ // Storage Access API escalation: active|inactive refused when
96
+ // allowStorageAccess:false; none passes through; default (unset) lets it
97
+ // through.
98
+ async function testStorageAccessGate() {
99
+ var strict = b.middleware.fetchMetadata({ allowStorageAccess: false });
100
+
101
+ var active = await _run(strict, _post({
102
+ "sec-fetch-site": "cross-site", "sec-fetch-storage-access": "active",
103
+ }), _bodyRes());
104
+ check("allowStorageAccess:false refuses cross-site active escalation",
105
+ active.next === false && active.status === 403 &&
106
+ /storage access/i.test(_reason(active.captured)));
107
+
108
+ var inactive = await _run(strict, _post({
109
+ "sec-fetch-site": "cross-site", "sec-fetch-storage-access": "inactive",
110
+ }), _bodyRes());
111
+ check("allowStorageAccess:false refuses cross-site inactive escalation",
112
+ inactive.next === false && inactive.status === 403);
113
+
114
+ var none = await _run(strict, _post({
115
+ "sec-fetch-site": "same-origin", "sec-fetch-storage-access": "none",
116
+ }), _bodyRes());
117
+ check("storage-access none on same-origin passes through",
118
+ none.next === true);
119
+
120
+ // Default (opt unset) does not refuse the escalation.
121
+ var dflt = b.middleware.fetchMetadata({ allowCrossSite: true });
122
+ var permitted = await _run(dflt, _post({
123
+ "sec-fetch-site": "cross-site", "sec-fetch-storage-access": "active",
124
+ }), _bodyRes());
125
+ check("storage-access escalation permitted by default",
126
+ permitted.next === true);
127
+ }
128
+
129
+ // strictDest is a config-time tier opt — an unknown Sec-Fetch-Dest value
130
+ // throws at construction so an operator typo surfaces at boot.
131
+ function testStrictDestThrows() {
132
+ var threw = null;
133
+ try {
134
+ b.middleware.fetchMetadata({ strictDest: true, deniedDest: ["web-identity"] });
135
+ } catch (e) { threw = e; }
136
+ check("strictDest rejects an unknown Sec-Fetch-Dest value at config time",
137
+ threw && /not a known Sec-Fetch-Dest value/.test(threw.message || ""));
138
+
139
+ // A known value under strictDest constructs cleanly.
140
+ var ok = true;
141
+ try {
142
+ b.middleware.fetchMetadata({ strictDest: true, deniedDest: ["webidentity"], allowedDest: ["empty"] });
143
+ } catch (_e) { ok = false; }
144
+ check("strictDest accepts known destination values", ok === true);
145
+ }
146
+
147
+ // Exact membership — a header value that is a substring of, or a
148
+ // prototype name relative to, a denied value must NOT match.
149
+ async function testExactMembershipNoBypass() {
150
+ var mw = b.middleware.fetchMetadata({ deniedDest: ["webidentity"] });
151
+
152
+ // Substring of the denied value — must not be treated as denied.
153
+ var substr = await _run(mw, _post({
154
+ "sec-fetch-site": "same-origin", "sec-fetch-dest": "web",
155
+ }), _bodyRes());
156
+ check("substring of a denied dest is not refused (exact match only)",
157
+ substr.next === true);
158
+
159
+ // Prototype-name header value must not satisfy the membership map.
160
+ var proto = await _run(mw, _post({
161
+ "sec-fetch-site": "same-origin", "sec-fetch-dest": "__proto__",
162
+ }), _bodyRes());
163
+ check("__proto__ as Sec-Fetch-Dest does not match the deny map",
164
+ proto.next === true);
165
+
166
+ // deniedDest rejects a prototype-name value at config time (non-empty
167
+ // string array validation passes it; it simply never matches a real
168
+ // header). Construction with such a value must not pollute.
169
+ var safe = b.middleware.fetchMetadata({ deniedDest: ["constructor"] });
170
+ var plain = await _run(safe, _post({
171
+ "sec-fetch-site": "same-origin", "sec-fetch-dest": "image",
172
+ }), _bodyRes());
173
+ check("deniedDest with a reserved-name entry does not pollute the map",
174
+ plain.next === true);
175
+ }
176
+
177
+ async function run() {
178
+ await testDefaultUnchanged();
179
+ await testDeniedDestWebIdentity();
180
+ await testStorageAccessGate();
181
+ testStrictDestThrows();
182
+ await testExactMembershipNoBypass();
183
+ }
184
+
185
+ module.exports = { run: run };
186
+
187
+ if (require.main === module) {
188
+ run().then(function () { console.log("OK"); })
189
+ .catch(function (e) { process.exitCode = 1; throw e; });
190
+ }
@@ -145,6 +145,29 @@ function run() {
145
145
  var ctxAnon = b.flag.context.fromRequest({ headers: { "x-forwarded-for": "1.2.3.4", "user-agent": "ua" } });
146
146
  check("fromRequest: anon targetingKey", ctxAnon.targetingKey.indexOf("anon:") === 0);
147
147
 
148
+ // explicit tenantKey supplies the tenant id (gateway-resolved tenancy)
149
+ var ctxTenant = b.flag.context.fromRequest(
150
+ { user: { id: "u-1" }, headers: {} },
151
+ { tenantKey: "tenant-explicit" });
152
+ check("fromRequest: tenantKey sets tenantId", ctxTenant.tenantId === "tenant-explicit");
153
+
154
+ // tenantKey overrides the tenant id derived from req.user.tenantId
155
+ var ctxTenantOverride = b.flag.context.fromRequest(
156
+ { user: { id: "u-2", tenantId: "from-user" }, headers: {} },
157
+ { tenantKey: "from-opts" });
158
+ check("fromRequest: tenantKey overrides req.user.tenantId", ctxTenantOverride.tenantId === "from-opts");
159
+
160
+ // default unchanged: no tenantKey → tenantId still derived from req.user
161
+ var ctxTenantDefault = b.flag.context.fromRequest(
162
+ { user: { id: "u-3", tenantId: "user-tenant" }, headers: {} });
163
+ check("fromRequest: no tenantKey keeps req.user.tenantId", ctxTenantDefault.tenantId === "user-tenant");
164
+
165
+ // empty-string tenantKey is ignored (falls back to req.user.tenantId)
166
+ var ctxTenantEmpty = b.flag.context.fromRequest(
167
+ { user: { id: "u-4", tenantId: "user-tenant" }, headers: {} },
168
+ { tenantKey: "" });
169
+ check("fromRequest: empty tenantKey ignored", ctxTenantEmpty.tenantId === "user-tenant");
170
+
148
171
  // ---- targeting evaluation ----
149
172
  var t = b.flag.targeting;
150
173
  check("targeting.VALID_OPS", Array.isArray(t.VALID_OPS) && t.VALID_OPS.length > 10);
@@ -331,6 +331,62 @@ function testFindingFields() {
331
331
  imgFinding && imgFinding.level === "A");
332
332
  }
333
333
 
334
+ // ---- de-advertised opts ----
335
+
336
+ function testAuditRejectsCheckAll() {
337
+ // checkAll was an accepted-but-never-read knob; it is no longer in the
338
+ // validateOpts allowlist, so passing it is now a config-time error.
339
+ var threw = false;
340
+ try { wcag.audit("<html lang=\"en\"><head><title>Real page title</title></head><body></body></html>",
341
+ { checkAll: true }); }
342
+ catch (_e) { threw = true; }
343
+ check("audit: unknown checkAll opt throws", threw);
344
+ }
345
+
346
+ // ---- scopeUrl stamped onto sub-scanner findings ----
347
+
348
+ function testSubScannerScopeUrlStamped() {
349
+ var url = "https://example.com/page";
350
+
351
+ var ariaFindings = wcag.aria.audit('<div role="invalidrole"></div>', { scopeUrl: url });
352
+ check("aria sub-scanner: scopeUrl stamped on finding",
353
+ ariaFindings.length >= 1 &&
354
+ ariaFindings.every(function (f) { return f.scopeUrl === url; }));
355
+
356
+ var tableFindings = wcag.tables.audit('<table><tbody><tr><td>1</td></tr></tbody></table>', { scopeUrl: url });
357
+ check("tables sub-scanner: scopeUrl stamped on finding",
358
+ tableFindings.length >= 1 &&
359
+ tableFindings.every(function (f) { return f.scopeUrl === url; }));
360
+
361
+ var formFindings = wcag.forms.audit('<textarea></textarea>', { scopeUrl: url });
362
+ check("forms sub-scanner: scopeUrl stamped on finding",
363
+ formFindings.length >= 1 &&
364
+ formFindings.every(function (f) { return f.scopeUrl === url; }));
365
+ }
366
+
367
+ function testSubScannerScopeUrlDefaultUnchanged() {
368
+ // No scopeUrl → findings carry no scopeUrl field (default behavior).
369
+ var ariaFindings = wcag.aria.audit('<div role="invalidrole"></div>');
370
+ check("aria sub-scanner: no scopeUrl field by default",
371
+ ariaFindings.length >= 1 &&
372
+ ariaFindings.every(function (f) { return f.scopeUrl === undefined; }));
373
+
374
+ var tableFindings = wcag.tables.audit('<table><tbody><tr><td>1</td></tr></tbody></table>');
375
+ check("tables sub-scanner: no scopeUrl field by default",
376
+ tableFindings.length >= 1 &&
377
+ tableFindings.every(function (f) { return f.scopeUrl === undefined; }));
378
+
379
+ var formFindings = wcag.forms.audit('<textarea></textarea>');
380
+ check("forms sub-scanner: no scopeUrl field by default",
381
+ formFindings.length >= 1 &&
382
+ formFindings.every(function (f) { return f.scopeUrl === undefined; }));
383
+
384
+ // Empty-string scopeUrl is treated as absent (no stamp).
385
+ var emptyScope = wcag.aria.audit('<div role="invalidrole"></div>', { scopeUrl: "" });
386
+ check("aria sub-scanner: empty scopeUrl not stamped",
387
+ emptyScope.every(function (f) { return f.scopeUrl === undefined; }));
388
+ }
389
+
334
390
  // ---- SC registry ----
335
391
 
336
392
  function testRegistryShape() {
@@ -820,6 +876,9 @@ function testFormsStandalone() {
820
876
  testScoreFullClean();
821
877
  testScoreManyErrors();
822
878
  testAuditValidation();
879
+ testAuditRejectsCheckAll();
880
+ testSubScannerScopeUrlStamped();
881
+ testSubScannerScopeUrlDefaultUnchanged();
823
882
  testReportShape();
824
883
  testFindingFields();
825
884
  testRegistryShape();
@@ -38,6 +38,32 @@ async function run() {
38
38
 
39
39
  check("honeytoken.HoneytokenError class registered",
40
40
  typeof b.honeytoken.HoneytokenError === "function");
41
+
42
+ // ---- injected audit sink (opts.audit) ----
43
+ // The documented `audit: b.audit` sink must receive issued + tripped
44
+ // rows when supplied, instead of the module's default audit log.
45
+ var captured = [];
46
+ var sink = { safeEmit: function (rec) { captured.push(rec); } };
47
+ var honeyInj = b.honeytoken.create({ audit: sink });
48
+ var injIssued = honeyInj.issue({ kind: "url", metadata: { plantedAt: "admin-listing" } });
49
+ check("honeytoken.audit: injected sink received issued row",
50
+ captured.length === 1 && captured[0].action === "honeytoken.issued" &&
51
+ captured[0].metadata.id === injIssued.id && captured[0].metadata.kind === "url");
52
+ honeyInj.lookup(injIssued.value, { ip: "198.51.100.7" });
53
+ check("honeytoken.audit: injected sink received tripped row",
54
+ captured.length === 2 && captured[1].action === "honeytoken.tripped" &&
55
+ captured[1].outcome === "failure" && captured[1].metadata.id === injIssued.id &&
56
+ captured[1].metadata.observedActor && captured[1].metadata.observedActor.ip === "198.51.100.7");
57
+ check("honeytoken.audit: lookup miss emits nothing to the sink",
58
+ honeyInj.lookup("not-a-canary") === null && captured.length === 2);
59
+
60
+ // Default path (no sink) still emits to the module audit log without
61
+ // throwing — exercised by the surface tests above, asserted here via
62
+ // a fresh registry whose issue/lookup don't blow up.
63
+ var honeyDefault = b.honeytoken.create({});
64
+ var d = honeyDefault.issue({ kind: "rowId", metadata: null });
65
+ check("honeytoken.audit: default sink path issues without throwing",
66
+ typeof d.value === "string" && d.value.indexOf("ht_canary_") === 0);
41
67
  }
42
68
 
43
69
  module.exports = { run: run };
@@ -766,6 +766,178 @@ function testDmarcRuaBuildBadInput() {
766
766
  }
767
767
  }
768
768
 
769
+ // RFC 6591 §4.1 / RFC 7489 §7.3 — a DMARC forensic (RUF) failure
770
+ // report: multipart/report (report-type=feedback-report) whose
771
+ // message/feedback-report part carries Feedback-Type: auth-failure plus
772
+ // the forensic-specific fields (Auth-Failure, Delivery-Result,
773
+ // Identity-Alignment, DKIM-*/SPF-*), and a message/rfc822 part with the
774
+ // reported message.
775
+ var DMARC_RUF_SAMPLE =
776
+ "From: <postmaster@example.com>\r\n" +
777
+ "Date: Fri, 15 May 2026 12:00:00 -0400\r\n" +
778
+ "Subject: FW: DMARC failure report\r\n" +
779
+ "To: <ruf@sender.example>\r\n" +
780
+ "MIME-Version: 1.0\r\n" +
781
+ "Content-Type: multipart/report; report-type=feedback-report;\r\n" +
782
+ '\tboundary="ruf_boundary_abc"\r\n' +
783
+ "\r\n" +
784
+ "--ruf_boundary_abc\r\n" +
785
+ "Content-Type: text/plain; charset=\"US-ASCII\"\r\n" +
786
+ "\r\n" +
787
+ "This is a DMARC authentication-failure report.\r\n" +
788
+ "\r\n" +
789
+ "--ruf_boundary_abc\r\n" +
790
+ "Content-Type: message/feedback-report\r\n" +
791
+ "\r\n" +
792
+ "Feedback-Type: auth-failure\r\n" +
793
+ "User-Agent: ExampleReporter/1.0\r\n" +
794
+ "Version: 1\r\n" +
795
+ "Original-Mail-From: <bounce@sender.example>\r\n" +
796
+ "Original-Rcpt-To: <victim@example.com>\r\n" +
797
+ "Arrival-Date: Fri, 15 May 2026 11:59:00 -0400\r\n" +
798
+ "Source-IP: 203.0.113.7\r\n" +
799
+ "Reported-Domain: sender.example\r\n" +
800
+ "Authentication-Results: mx.example.com; dmarc=fail header.from=sender.example\r\n" +
801
+ "Auth-Failure: dmarc\r\n" +
802
+ "Delivery-Result: reject\r\n" +
803
+ "Identity-Alignment: none\r\n" +
804
+ "DKIM-Domain: sender.example\r\n" +
805
+ "DKIM-Identity: @sender.example\r\n" +
806
+ "DKIM-Selector: sel2026\r\n" +
807
+ "DKIM-Canonicalized-Header: from:Sender <noreply@sender.example>\r\n" +
808
+ "SPF-DNS: txt sender.example \"v=spf1 ip4:198.51.100.0/24 -all\"\r\n" +
809
+ "\r\n" +
810
+ "--ruf_boundary_abc\r\n" +
811
+ "Content-Type: message/rfc822\r\n" +
812
+ "\r\n" +
813
+ "From: Sender <noreply@sender.example>\r\n" +
814
+ "To: <victim@example.com>\r\n" +
815
+ "Subject: You have won\r\n" +
816
+ "Message-ID: <forged-123@sender.example>\r\n" +
817
+ "\r\n" +
818
+ "Body of the reported message.\r\n" +
819
+ "--ruf_boundary_abc--\r\n";
820
+
821
+ function testDmarcForensicSurface() {
822
+ check("dmarc.parseForensicReport is a function",
823
+ typeof b.mail.dmarc.parseForensicReport === "function");
824
+ }
825
+
826
+ function testDmarcForensicParse() {
827
+ var rv = b.mail.dmarc.parseForensicReport(DMARC_RUF_SAMPLE);
828
+ check("dmarc.parseForensicReport: ok envelope on a valid report",
829
+ rv && rv.ok === true && rv.report && typeof rv.report === "object");
830
+ var rep = rv.report;
831
+ check("dmarc.parseForensicReport: feedbackType=auth-failure",
832
+ rep.feedbackType === "auth-failure");
833
+ check("dmarc.parseForensicReport: authFailure=dmarc (RFC 6591 §3.1)",
834
+ rep.authFailure === "dmarc");
835
+ check("dmarc.parseForensicReport: deliveryResult=reject",
836
+ rep.deliveryResult === "reject");
837
+ check("dmarc.parseForensicReport: identityAlignment=none (RFC 7489 §7.3)",
838
+ rep.identityAlignment === "none");
839
+ check("dmarc.parseForensicReport: DKIM-* fields shaped",
840
+ rep.dkim && rep.dkim.domain === "sender.example" &&
841
+ rep.dkim.selector === "sel2026" &&
842
+ rep.dkim.identity === "@sender.example" &&
843
+ /noreply@sender\.example/.test(rep.dkim.canonicalizedHeader || ""));
844
+ check("dmarc.parseForensicReport: spf.dns captured",
845
+ rep.spf && /v=spf1/.test(rep.spf.dns || ""));
846
+ check("dmarc.parseForensicReport: base ARF fields carried through",
847
+ rep.sourceIp === "203.0.113.7" &&
848
+ rep.reportedDomain === "sender.example" &&
849
+ /dmarc=fail/.test(rep.authenticationResults || ""));
850
+ check("dmarc.parseForensicReport: reported message headers parsed",
851
+ Array.isArray(rep.reportedHeaders) &&
852
+ rep.reportedHeaderMap["message-id"] === "<forged-123@sender.example>" &&
853
+ rep.reportedHeaderMap.subject === "You have won");
854
+ check("dmarc.parseForensicReport: reportedHeaderMap is null-prototype",
855
+ Object.getPrototypeOf(rep.reportedHeaderMap) === null);
856
+ }
857
+
858
+ function testDmarcForensicNotAuthFailure() {
859
+ // An RFC 5965 abuse report is a valid ARF report but NOT a DMARC
860
+ // forensic report — Feedback-Type must be auth-failure (RFC 7489 §7.3).
861
+ var abuseReport = DMARC_RUF_SAMPLE.replace(
862
+ "Feedback-Type: auth-failure", "Feedback-Type: abuse");
863
+ var rv = b.mail.dmarc.parseForensicReport(abuseReport);
864
+ check("dmarc.parseForensicReport: non-auth-failure Feedback-Type → typed error (not a throw)",
865
+ rv && rv.ok === false &&
866
+ /dmarc-ruf-not-auth-failure/.test(rv.error.code || ""));
867
+ }
868
+
869
+ function testDmarcForensicMissingAuthFailure() {
870
+ // RFC 6591 §3.1 — Auth-Failure is required in an auth-failure report.
871
+ var noAuthFailure = DMARC_RUF_SAMPLE.replace("Auth-Failure: dmarc\r\n", "");
872
+ var rv = b.mail.dmarc.parseForensicReport(noAuthFailure);
873
+ check("dmarc.parseForensicReport: missing Auth-Failure → typed error",
874
+ rv && rv.ok === false &&
875
+ /dmarc-ruf-missing-auth-failure/.test(rv.error.code || ""));
876
+ }
877
+
878
+ function testDmarcForensicHostileInputDoesNotThrow() {
879
+ // Defensive reader — hostile / malformed input MUST return a typed
880
+ // error, never throw in the hot path that ingested the report.
881
+ var cases = [null, 12345, "", "not-a-multipart-report",
882
+ Buffer.from("garbage"),
883
+ "Content-Type: text/plain\r\n\r\nnope"];
884
+ for (var i = 0; i < cases.length; i += 1) {
885
+ var threw = null;
886
+ var rv = null;
887
+ try { rv = b.mail.dmarc.parseForensicReport(cases[i]); }
888
+ catch (e) { threw = e; }
889
+ check("dmarc.parseForensicReport: hostile input #" + i + " returns typed error, no throw",
890
+ threw === null && rv && rv.ok === false &&
891
+ rv.error && typeof rv.error.code === "string");
892
+ }
893
+ }
894
+
895
+ function testDmarcForensicReportedHeaderCap() {
896
+ // RFC 6591 §3.2 reported-header cap — a report whose reported message
897
+ // carries > the header cap clips the parsed list and flags it, while
898
+ // still surfacing the verbatim message. Build a reported message with
899
+ // many headers.
900
+ var lines = [];
901
+ for (var i = 0; i < 400; i += 1) lines.push("X-Pad-" + i + ": v" + i);
902
+ var bigReported = lines.join("\r\n") + "\r\n\r\nbody\r\n";
903
+ var sample = DMARC_RUF_SAMPLE.replace(
904
+ "From: Sender <noreply@sender.example>\r\n" +
905
+ "To: <victim@example.com>\r\n" +
906
+ "Subject: You have won\r\n" +
907
+ "Message-ID: <forged-123@sender.example>\r\n" +
908
+ "\r\n" +
909
+ "Body of the reported message.\r\n",
910
+ bigReported);
911
+ var rv = b.mail.dmarc.parseForensicReport(sample);
912
+ check("dmarc.parseForensicReport: over-cap reported headers are clipped + flagged",
913
+ rv && rv.ok === true &&
914
+ rv.report.reportedHeaders.length === 256 &&
915
+ rv.report.reportedHeadersTruncated === true);
916
+ }
917
+
918
+ function testDmarcForensicPrototypePollutionSafe() {
919
+ // A hostile report naming a feedback-report field or a reported-message
920
+ // header `__proto__` / `constructor` must not pollute Object.prototype.
921
+ var sample = DMARC_RUF_SAMPLE
922
+ // feedback-report field block — exercises the extraFields path.
923
+ .replace("Auth-Failure: dmarc\r\n",
924
+ "Auth-Failure: dmarc\r\n__proto__: fieldpoison\r\n")
925
+ // reported message header block — exercises the reportedHeaderMap path.
926
+ .replace("From: Sender <noreply@sender.example>\r\n",
927
+ "From: Sender <noreply@sender.example>\r\n__proto__: hdrpoison\r\n");
928
+ var rv = b.mail.dmarc.parseForensicReport(sample);
929
+ check("dmarc.parseForensicReport: __proto__ in feedback field → own data on null-prototype extraFields",
930
+ rv && rv.ok === true &&
931
+ Object.getPrototypeOf(rv.report.extraFields) === null &&
932
+ rv.report.extraFields["__proto__"] === "fieldpoison");
933
+ check("dmarc.parseForensicReport: __proto__ reported header → own data, no prototype pollution",
934
+ rv.report.reportedHeaderMap["__proto__"] === "hdrpoison" &&
935
+ ({}).fieldpoison === undefined &&
936
+ ({}).hdrpoison === undefined &&
937
+ Object.prototype.fieldpoison === undefined &&
938
+ Object.prototype.hdrpoison === undefined);
939
+ }
940
+
769
941
  async function testIprevValidatesPtrShape() {
770
942
  // MAIL-50 — PTR result MUST be a valid DNS-name shape (LDH labels,
771
943
  // 1..63 octets, total 1..253 octets). Synthesize via a mocked
@@ -1072,6 +1244,13 @@ async function run() {
1072
1244
  testDmarcRuaGunzipBombDistinguished();
1073
1245
  testDmarcRuaBuildRoundTrip();
1074
1246
  testDmarcRuaBuildBadInput();
1247
+ testDmarcForensicSurface();
1248
+ testDmarcForensicParse();
1249
+ testDmarcForensicNotAuthFailure();
1250
+ testDmarcForensicMissingAuthFailure();
1251
+ testDmarcForensicHostileInputDoesNotThrow();
1252
+ testDmarcForensicReportedHeaderCap();
1253
+ testDmarcForensicPrototypePollutionSafe();
1075
1254
  await testIprevValidatesPtrShape();
1076
1255
  await testArcHeaderSourceOrder();
1077
1256
  }
@@ -331,6 +331,21 @@ function testHttpAuthenticateHookValidatesType() {
331
331
  check("authenticate non-function refused at construct", threw);
332
332
  }
333
333
 
334
+ function testHttpRejectsRemovedComplianceOpt() {
335
+ // `compliance` was an accepted-but-never-read allowlist key (it was not
336
+ // documented in @opts and nothing consumed it). It is removed from the
337
+ // validateOpts allowlist, so passing it is now a config-time error.
338
+ var threw = false;
339
+ try { b.mail.deploy.tlsRptIngestHttp({ compliance: "hipaa" }); }
340
+ catch (_e) { threw = true; }
341
+ check("tlsRptIngestHttp: removed `compliance` opt refused at construct", threw);
342
+
343
+ // Default construct still works (no opts).
344
+ var threwBare = false;
345
+ try { b.mail.deploy.tlsRptIngestHttp({}); } catch (_e) { threwBare = true; }
346
+ check("tlsRptIngestHttp: bare construct still works", !threwBare);
347
+ }
348
+
334
349
  function testTlsRptParseErrorClassExported() {
335
350
  check("b.mail.deploy.TlsRptParseError is a constructor",
336
351
  typeof b.mail.deploy.TlsRptParseError === "function");
@@ -359,6 +374,7 @@ async function run() {
359
374
  testRefusesNonFiniteSummary();
360
375
  await testHttpAuthenticateHookSyncFalsy();
361
376
  testHttpAuthenticateHookValidatesType();
377
+ testHttpRejectsRemovedComplianceOpt();
362
378
  testTlsRptParseErrorClassExported();
363
379
  }
364
380
 
@@ -72,6 +72,62 @@ function testFactoryRefusesBadOpts() {
72
72
  });
73
73
  check("port 0 → DeliverError (connect port must be >=1)",
74
74
  e7 && e7.code === "deliver/bad-port");
75
+
76
+ // retry.maxAttempts / timeouts.mxLookupMs / timeouts.perHostMs are
77
+ // config-time entry-point opts: a typo must throw at create(), not be
78
+ // swallowed by a valid-or-default fallback. Absent keeps the default.
79
+ var e8 = threw(function () {
80
+ b.mail.send.deliver({ hostname: "m.example", retry: { maxAttempts: "5" } });
81
+ });
82
+ check("retry.maxAttempts as string → DeliverError",
83
+ e8 && e8.code === "deliver/bad-retry-maxAttempts");
84
+
85
+ var e9 = threw(function () {
86
+ b.mail.send.deliver({ hostname: "m.example", retry: { maxAttempts: -1 } });
87
+ });
88
+ check("retry.maxAttempts negative → DeliverError",
89
+ e9 && e9.code === "deliver/bad-retry-maxAttempts");
90
+
91
+ var e10 = threw(function () {
92
+ b.mail.send.deliver({ hostname: "m.example", retry: { maxAttempts: 0 } });
93
+ });
94
+ check("retry.maxAttempts 0 → DeliverError (must be >= 1)",
95
+ e10 && e10.code === "deliver/bad-retry-maxAttempts");
96
+
97
+ var e11 = threw(function () {
98
+ b.mail.send.deliver({ hostname: "m.example", timeouts: { mxLookupMs: -1 } });
99
+ });
100
+ check("timeouts.mxLookupMs negative → DeliverError",
101
+ e11 && e11.code === "deliver/bad-timeout-mxLookupMs");
102
+
103
+ var e12 = threw(function () {
104
+ b.mail.send.deliver({ hostname: "m.example", timeouts: { mxLookupMs: "10000" } });
105
+ });
106
+ check("timeouts.mxLookupMs as string → DeliverError",
107
+ e12 && e12.code === "deliver/bad-timeout-mxLookupMs");
108
+
109
+ var e13 = threw(function () {
110
+ b.mail.send.deliver({ hostname: "m.example", timeouts: { perHostMs: 0 } });
111
+ });
112
+ check("timeouts.perHostMs 0 → DeliverError (must be >= 1)",
113
+ e13 && e13.code === "deliver/bad-timeout-perHostMs");
114
+
115
+ // Absent retry / timeouts keys keep the defaults — create() succeeds.
116
+ var okDefault = threw(function () {
117
+ b.mail.send.deliver({ hostname: "m.example", retry: {}, timeouts: {}, audit: false });
118
+ });
119
+ check("absent retry/timeouts keys keep defaults (create succeeds)", okDefault === null);
120
+
121
+ // Valid integer values are accepted unchanged.
122
+ var okValid = threw(function () {
123
+ b.mail.send.deliver({
124
+ hostname: "m.example",
125
+ retry: { maxAttempts: 3 },
126
+ timeouts: { mxLookupMs: 2000, perHostMs: 30000 },
127
+ audit: false,
128
+ });
129
+ });
130
+ check("valid retry/timeouts values accepted (create succeeds)", okValid === null);
75
131
  }
76
132
 
77
133
  // ---- Submission/smarthost port ----
@@ -288,6 +344,57 @@ async function testTransientDefersPermanentFails() {
288
344
  dsnInvocations[0].dsnHasReport === true);
289
345
  }
290
346
 
347
+ // ---- retry.maxAttempts value flows through to the retry budget ----
348
+
349
+ // A valid maxAttempts must reach the deferred-vs-failed routing, not just
350
+ // pass validation. With maxAttempts:1, the first transient failure exhausts
351
+ // the budget (attempts 1 >= 1) and converts transient → permanent → failed[]
352
+ // rather than landing in deferred[]. The default (5) keeps it deferred.
353
+ async function testMaxAttemptsFlowsThrough() {
354
+ var fakeResolver = {
355
+ queryMx: async function (domain) {
356
+ return [{ exchange: "mx1." + domain, priority: 10 }];
357
+ },
358
+ };
359
+ var transientTransport = function () {
360
+ return {
361
+ send: async function () {
362
+ var err = new Error("temporary failure");
363
+ err.smtpResponse = { code: 451 };
364
+ throw err;
365
+ },
366
+ };
367
+ };
368
+ var envelope = {
369
+ from: "ops@example.com",
370
+ to: ["transient@example.com"],
371
+ rfc822: Buffer.from("hi"),
372
+ };
373
+
374
+ var deliverBudget1 = b.mail.send.deliver({
375
+ hostname: "mta1.example.com",
376
+ resolver: fakeResolver,
377
+ policy: { mtaSts: "off", dane: "off" },
378
+ transportFactory: transientTransport,
379
+ retry: { maxAttempts: 1 },
380
+ audit: false,
381
+ });
382
+ var r1 = await deliverBudget1(envelope);
383
+ check("maxAttempts:1 exhausts budget on first transient → failed",
384
+ r1.failed.length === 1 && r1.deferred.length === 0);
385
+
386
+ var deliverDefault = b.mail.send.deliver({
387
+ hostname: "mta1.example.com",
388
+ resolver: fakeResolver,
389
+ policy: { mtaSts: "off", dane: "off" },
390
+ transportFactory: transientTransport,
391
+ audit: false,
392
+ });
393
+ var r2 = await deliverDefault(envelope);
394
+ check("default maxAttempts keeps a single transient deferred",
395
+ r2.deferred.length === 1 && r2.failed.length === 0);
396
+ }
397
+
291
398
  // ---- No-MX (RFC 7505 null MX) ----
292
399
 
293
400
  async function testNullMx() {
@@ -369,6 +476,7 @@ async function run() {
369
476
  await testNullMx();
370
477
  await testMxFailover();
371
478
  await testPortReachesTransport();
479
+ await testMaxAttemptsFlowsThrough();
372
480
  }
373
481
 
374
482
  module.exports = { run: run };