@blamejs/blamejs-shop 0.4.32 → 0.4.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/vendor/MANIFEST.json +72 -52
  5. package/lib/vendor/blamejs/.github/workflows/ci.yml +12 -12
  6. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +37 -5
  7. package/lib/vendor/blamejs/.github/workflows/release-container.yml +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  9. package/lib/vendor/blamejs/MIGRATING.md +12 -0
  10. package/lib/vendor/blamejs/README.md +5 -2
  11. package/lib/vendor/blamejs/SECURITY.md +4 -2
  12. package/lib/vendor/blamejs/api-snapshot.json +137 -2
  13. package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +1 -0
  14. package/lib/vendor/blamejs/index.js +4 -0
  15. package/lib/vendor/blamejs/lib/archive-read.js +2 -1
  16. package/lib/vendor/blamejs/lib/archive-tar-read.js +2 -1
  17. package/lib/vendor/blamejs/lib/atomic-file.js +5 -0
  18. package/lib/vendor/blamejs/lib/audit.js +2 -0
  19. package/lib/vendor/blamejs/lib/auth/elevation-grant.js +6 -2
  20. package/lib/vendor/blamejs/lib/auth/oauth.js +13 -0
  21. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +5 -2
  22. package/lib/vendor/blamejs/lib/cli.js +8 -1
  23. package/lib/vendor/blamejs/lib/compliance.js +4 -0
  24. package/lib/vendor/blamejs/lib/config-drift.js +2 -1
  25. package/lib/vendor/blamejs/lib/credential-hash.js +9 -0
  26. package/lib/vendor/blamejs/lib/db.js +15 -2
  27. package/lib/vendor/blamejs/lib/dsa.js +482 -0
  28. package/lib/vendor/blamejs/lib/framework-error.js +14 -0
  29. package/lib/vendor/blamejs/lib/http-client.js +5 -2
  30. package/lib/vendor/blamejs/lib/local-db-thin.js +3 -2
  31. package/lib/vendor/blamejs/lib/log-stream-local.js +1 -1
  32. package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +9 -2
  33. package/lib/vendor/blamejs/lib/log-stream-otlp.js +16 -7
  34. package/lib/vendor/blamejs/lib/middleware/clear-site-data.js +36 -11
  35. package/lib/vendor/blamejs/lib/mtls-ca.js +2 -2
  36. package/lib/vendor/blamejs/lib/observability.js +3 -2
  37. package/lib/vendor/blamejs/lib/pipl-cn.js +377 -0
  38. package/lib/vendor/blamejs/lib/restore-rollback.js +5 -5
  39. package/lib/vendor/blamejs/lib/retention.js +16 -2
  40. package/lib/vendor/blamejs/lib/scheduler.js +12 -0
  41. package/lib/vendor/blamejs/lib/self-update.js +1 -1
  42. package/lib/vendor/blamejs/lib/session.js +64 -0
  43. package/lib/vendor/blamejs/lib/ssrf-guard.js +25 -7
  44. package/lib/vendor/blamejs/lib/vault/passphrase-ops.js +3 -3
  45. package/lib/vendor/blamejs/lib/watcher.js +8 -0
  46. package/lib/vendor/blamejs/package.json +2 -2
  47. package/lib/vendor/blamejs/release-notes/v0.15.7.json +43 -0
  48. package/lib/vendor/blamejs/release-notes/v0.15.8.json +48 -0
  49. package/lib/vendor/blamejs/release-notes/v0.15.9.json +58 -0
  50. package/lib/vendor/blamejs/scripts/gen-migrating.js +16 -0
  51. package/lib/vendor/blamejs/scripts/generate-ssdf-attestation.js +338 -0
  52. package/lib/vendor/blamejs/test/00-primitives.js +51 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-rename-retry.test.js +70 -0
  54. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +250 -3
  55. package/lib/vendor/blamejs/test/layer-0-primitives/credential-hash.test.js +18 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/db-init-extensions.test.js +32 -0
  57. package/lib/vendor/blamejs/test/layer-0-primitives/dsa.test.js +169 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +40 -1
  59. package/lib/vendor/blamejs/test/layer-0-primitives/pipl-cn.test.js +172 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/retention-floor.test.js +59 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/safe-url-canonicalize.test.js +64 -11
  62. package/lib/vendor/blamejs/test/layer-0-primitives/scheduler-watchdog-stale-settle.test.js +71 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +57 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/watcher.test.js +7 -3
  65. package/package.json +2 -2
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ // b.pipl.sccFilingAssessment + b.pipl.securityAssessmentCertificate —
3
+ // China PIPL Art. 38/40/55 cross-border transfer record-builders (pure;
4
+ // no DB). Drives the real b.pipl.* consumer path, asserts frozen records +
5
+ // audit emission via a captured injected sink, and the config-time throws.
6
+
7
+ var helpers = require("../helpers");
8
+ var b = helpers.b;
9
+ var check = helpers.check;
10
+
11
+ // A b.audit-shaped capture sink — the builder prefers an injected
12
+ // opts.audit object over the global b.audit (drop-silent without a DB
13
+ // handler), so this is how the test asserts emission.
14
+ function _captureAudit() {
15
+ var events = [];
16
+ return { events: events, safeEmit: function (ev) { events.push(ev); } };
17
+ }
18
+
19
+ function expectCode(label, fn, code) {
20
+ var threw = null;
21
+ try { fn(); } catch (e) { threw = e; }
22
+ check(label, threw && (threw.code || "") === code);
23
+ }
24
+
25
+ function expectThrows(label, fn) {
26
+ var threw = null;
27
+ try { fn(); } catch (e) { threw = e; }
28
+ check(label, threw instanceof Error);
29
+ }
30
+
31
+ function run() {
32
+ check("sccFilingAssessment is a function", typeof b.pipl.sccFilingAssessment === "function");
33
+ check("securityAssessmentCertificate is a function", typeof b.pipl.securityAssessmentCertificate === "function");
34
+ check("PiplError exposed on b.frameworkError", typeof b.frameworkError.PiplError === "function");
35
+ check("b.pipl.PiplError is the same constructor", b.pipl.PiplError === b.frameworkError.PiplError);
36
+ check("LEGAL_BASES is the Art. 38(1) triad",
37
+ b.pipl.LEGAL_BASES.length === 3 &&
38
+ b.pipl.LEGAL_BASES.indexOf("standard-contract") !== -1 &&
39
+ b.pipl.LEGAL_BASES.indexOf("security-assessment") !== -1 &&
40
+ b.pipl.LEGAL_BASES.indexOf("certification") !== -1);
41
+ check("pipl-cn is a cross-border-regulated posture",
42
+ b.compliance.isCrossBorderRegulated("pipl-cn") === true);
43
+
44
+ var recordedAt = 1700000000000;
45
+
46
+ // ---- sccFilingAssessment: below-threshold standard contract ----
47
+ var sink1 = _captureAudit();
48
+ var scc = b.pipl.sccFilingAssessment({
49
+ assessmentId: "xfer-1", transferType: "processor", recipientJurisdiction: "US",
50
+ dataCategories: ["contact", "billing"], legalBasis: "standard-contract",
51
+ volume: 5000, sensitivePI: false, recordedAt: recordedAt, audit: sink1,
52
+ });
53
+ check("scc: honors standard-contract below thresholds", scc.mechanismRequired === "standard-contract");
54
+ check("scc: securityAssessmentRequired false", scc.securityAssessmentRequired === false);
55
+ check("scc: record frozen", Object.isFrozen(scc));
56
+ check("scc: dataCategories frozen", Object.isFrozen(scc.dataCategories));
57
+ check("scc: nextReviewDueBy = recordedAt + 1 year",
58
+ scc.nextReviewDueBy === recordedAt + b.constants.TIME.days(365));
59
+ check("scc: emitted pipl.transfer.assessed",
60
+ sink1.events.length === 1 && sink1.events[0].action === "pipl.transfer.assessed");
61
+ check("scc: audit carries mechanismRequired",
62
+ sink1.events[0].metadata.mechanismRequired === "standard-contract");
63
+
64
+ // ---- CIIO forces security assessment over declared basis ----
65
+ var ciio = b.pipl.sccFilingAssessment({
66
+ assessmentId: "xfer-2", transferType: "controller-to-controller", recipientJurisdiction: "EU",
67
+ dataCategories: ["health"], legalBasis: "standard-contract",
68
+ volume: 100, sensitivePI: true, ciio: true, recordedAt: recordedAt,
69
+ });
70
+ check("scc: CIIO forces security-assessment", ciio.mechanismRequired === "security-assessment");
71
+ check("scc: CIIO sets securityAssessmentRequired", ciio.securityAssessmentRequired === true);
72
+ check("scc: CIIO trigger named", ciio.securityAssessmentTriggers.indexOf("ciio") !== -1);
73
+ check("scc: mandated assessment carries 3-year clock",
74
+ ciio.nextReviewDueBy === recordedAt + b.constants.TIME.days(365 * 3));
75
+
76
+ // ---- >1M non-sensitive PI forces it ----
77
+ var bigVol = b.pipl.sccFilingAssessment({
78
+ assessmentId: "xfer-3", transferType: "processor", recipientJurisdiction: "SG",
79
+ dataCategories: ["contact"], legalBasis: "certification",
80
+ volume: 1000001, sensitivePI: false, recordedAt: recordedAt,
81
+ });
82
+ check("scc: >1M non-sensitive volume forces security-assessment",
83
+ bigVol.mechanismRequired === "security-assessment" &&
84
+ bigVol.securityAssessmentTriggers.indexOf("non-sensitive-pi-volume") !== -1);
85
+
86
+ // ---- 100k-1M non-sensitive band is SCC, NOT security-assessment (the
87
+ // 100k cumulative threshold is the standard-contract tier per the CAC
88
+ // 2024 Provisions; a 200k non-sensitive transfer must not over-classify) ----
89
+ var midBand = b.pipl.sccFilingAssessment({
90
+ assessmentId: "xfer-3b", transferType: "processor", recipientJurisdiction: "US",
91
+ dataCategories: ["contact"], legalBasis: "standard-contract",
92
+ volume: 200000, sensitivePI: false, recordedAt: recordedAt,
93
+ });
94
+ check("scc: 200k non-sensitive stays standard-contract (no over-classify)",
95
+ midBand.mechanismRequired === "standard-contract" &&
96
+ midBand.securityAssessmentRequired === false);
97
+
98
+ // ---- THIS transfer's volume counts toward the cumulative sensitive
99
+ // threshold: a first transfer of 10,001 sensitive subjects forces it
100
+ // even with cumulativeSensitivePI omitted (defaults 0) ----
101
+ var firstSens = b.pipl.sccFilingAssessment({
102
+ assessmentId: "xfer-3c", transferType: "processor", recipientJurisdiction: "US",
103
+ dataCategories: ["biometric"], legalBasis: "standard-contract",
104
+ volume: 10001, sensitivePI: true, recordedAt: recordedAt,
105
+ });
106
+ check("scc: first 10,001 sensitive-PI transfer forces it (own volume counts)",
107
+ firstSens.securityAssessmentRequired === true &&
108
+ firstSens.securityAssessmentTriggers.indexOf("sensitive-pi-volume") !== -1);
109
+
110
+ // ---- cumulative sensitive-PI threshold (this transfer + prior cumulative) ----
111
+ var cumSens = b.pipl.sccFilingAssessment({
112
+ assessmentId: "xfer-4", transferType: "processor", recipientJurisdiction: "US",
113
+ dataCategories: ["biometric"], legalBasis: "standard-contract",
114
+ volume: 200, sensitivePI: true, cumulativeSensitivePI: 10001, recordedAt: recordedAt,
115
+ });
116
+ check("scc: >10k cumulative sensitive-PI forces it",
117
+ cumSens.securityAssessmentRequired === true &&
118
+ cumSens.securityAssessmentTriggers.indexOf("sensitive-pi-volume") !== -1);
119
+
120
+ // ---- securityAssessmentCertificate: happy path ----
121
+ var sink3 = _captureAudit();
122
+ var cert = b.pipl.securityAssessmentCertificate({
123
+ certId: "sa-1", assessmentScope: "CRM outbound replication",
124
+ dataExporter: "Acme (Shanghai) Co., Ltd.", overseasRecipient: "Acme Inc.",
125
+ riskRating: "medium", safeguards: ["XChaCha20 at rest", "standard contractual clauses"],
126
+ filingRef: "CAC-2026-0042", recordedAt: recordedAt, audit: sink3,
127
+ });
128
+ check("cert: record frozen", Object.isFrozen(cert));
129
+ check("cert: safeguards frozen", Object.isFrozen(cert.safeguards));
130
+ check("cert: validUntil = recordedAt + 3 years",
131
+ cert.validUntil === recordedAt + b.constants.TIME.days(365 * 3));
132
+ check("cert: filingRef carried", cert.filingRef === "CAC-2026-0042");
133
+ check("cert: emitted pipl.security_assessment.recorded",
134
+ sink3.events.length === 1 && sink3.events[0].action === "pipl.security_assessment.recorded");
135
+ check("cert: filingRef omitted defaults null",
136
+ b.pipl.securityAssessmentCertificate({
137
+ certId: "sa-2", assessmentScope: "s", dataExporter: "e", overseasRecipient: "r",
138
+ riskRating: "low", safeguards: ["x"], recordedAt: recordedAt,
139
+ }).filingRef === null);
140
+
141
+ // ---- Config-time throws ----
142
+ expectCode("scc: non-object opts throws",
143
+ function () { b.pipl.sccFilingAssessment("nope"); }, "pipl/bad-opts");
144
+ expectThrows("scc: unknown opt key throws",
145
+ function () { b.pipl.sccFilingAssessment({ assessmentId: "x", bogusKey: 1 }); });
146
+ expectCode("scc: missing assessmentId throws",
147
+ function () { b.pipl.sccFilingAssessment({ transferType: "p", recipientJurisdiction: "US", dataCategories: ["c"], legalBasis: "standard-contract", volume: 1, sensitivePI: false, recordedAt: recordedAt }); }, "pipl/bad-assessment-id");
148
+ expectCode("scc: empty dataCategories throws",
149
+ function () { b.pipl.sccFilingAssessment({ assessmentId: "x", transferType: "p", recipientJurisdiction: "US", dataCategories: [], legalBasis: "standard-contract", volume: 1, sensitivePI: false, recordedAt: recordedAt }); }, "pipl/bad-data-categories");
150
+ expectCode("scc: bad legalBasis throws",
151
+ function () { b.pipl.sccFilingAssessment({ assessmentId: "x", transferType: "p", recipientJurisdiction: "US", dataCategories: ["c"], legalBasis: "bogus", volume: 1, sensitivePI: false, recordedAt: recordedAt }); }, "pipl/bad-legal-basis");
152
+ expectCode("scc: non-boolean sensitivePI throws",
153
+ function () { b.pipl.sccFilingAssessment({ assessmentId: "x", transferType: "p", recipientJurisdiction: "US", dataCategories: ["c"], legalBasis: "standard-contract", volume: 1, sensitivePI: "yes", recordedAt: recordedAt }); }, "pipl/bad-sensitive-pi");
154
+ expectCode("scc: missing recordedAt throws",
155
+ function () { b.pipl.sccFilingAssessment({ assessmentId: "x", transferType: "p", recipientJurisdiction: "US", dataCategories: ["c"], legalBasis: "standard-contract", volume: 1, sensitivePI: false }); }, "pipl/bad-recorded-at");
156
+
157
+ expectCode("cert: missing certId throws",
158
+ function () { b.pipl.securityAssessmentCertificate({ assessmentScope: "s", dataExporter: "e", overseasRecipient: "r", riskRating: "low", safeguards: ["x"], recordedAt: recordedAt }); }, "pipl/bad-cert-id");
159
+ expectCode("cert: bad riskRating throws",
160
+ function () { b.pipl.securityAssessmentCertificate({ certId: "c", assessmentScope: "s", dataExporter: "e", overseasRecipient: "r", riskRating: "critical", safeguards: ["x"], recordedAt: recordedAt }); }, "pipl/bad-risk-rating");
161
+ expectCode("cert: empty safeguards throws",
162
+ function () { b.pipl.securityAssessmentCertificate({ certId: "c", assessmentScope: "s", dataExporter: "e", overseasRecipient: "r", riskRating: "low", safeguards: [], recordedAt: recordedAt }); }, "pipl/bad-safeguards");
163
+ expectCode("cert: bad audit sink shape throws",
164
+ function () { b.pipl.securityAssessmentCertificate({ certId: "c", assessmentScope: "s", dataExporter: "e", overseasRecipient: "r", riskRating: "low", safeguards: ["x"], recordedAt: recordedAt, audit: { nope: 1 } }); }, "pipl/bad-audit");
165
+ }
166
+
167
+ module.exports = { run: run };
168
+
169
+ if (require.main === module) {
170
+ try { run(); console.log("[pipl-cn] OK"); }
171
+ catch (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
172
+ }
@@ -51,12 +51,71 @@ function testUnknownPostureThrows() {
51
51
  threw && /unknown-posture/.test(threw.code || threw.message || ""));
52
52
  }
53
53
 
54
+ function testOptionalPostureInheritance() {
55
+ // #121 — applyPosture(posture) records an active posture that
56
+ // complianceFloor() callers without an explicit posture inherit (the
57
+ // advertised cascade behavior). complianceFloor hard-required a string and
58
+ // never read STATE.activePosture, so the inheritance was unimplemented dead
59
+ // state; applyPosture(null) now also clears it (was a no-op).
60
+ var r = b.retention;
61
+ var prior = r.activePosture();
62
+ try {
63
+ r.applyPosture(null);
64
+ check("#121 applyPosture(null) clears the active posture",
65
+ r.activePosture() === null);
66
+ var threwNoActive = null;
67
+ try { r.complianceFloor(b.constants.TIME.days(30)); } catch (e) { threwNoActive = e; }
68
+ check("#121 no active posture + omitted posture → throws clearly",
69
+ threwNoActive !== null);
70
+
71
+ r.applyPosture("hipaa");
72
+ check("#121 activePosture reflects the set value", r.activePosture() === "hipaa");
73
+ check("#121 complianceFloor(ttl) inherits the active posture (single numeric arg)",
74
+ r.complianceFloor(b.constants.TIME.days(30)) === b.constants.TIME.days(365 * 6));
75
+ check("#121 complianceFloor(undefined, ttl) inherits the active posture",
76
+ r.complianceFloor(undefined, b.constants.TIME.days(30)) === b.constants.TIME.days(365 * 6));
77
+ check("#121 a candidate longer than the inherited floor still wins",
78
+ r.complianceFloor(b.constants.TIME.days(365 * 10)) === b.constants.TIME.days(365 * 10));
79
+ check("#121 an explicit posture still overrides the active one",
80
+ r.complianceFloor("pci-dss", 0) === b.constants.TIME.days(365));
81
+ } finally {
82
+ if (typeof prior === "string") r.applyPosture(prior); else r.applyPosture(null);
83
+ }
84
+ }
85
+
86
+ function testComplianceClearCascadesToRetention() {
87
+ // b.compliance.set cascades the posture into retention (via applyPosture), so
88
+ // b.compliance.clear must cascade the clear too — otherwise complianceFloor
89
+ // keeps inheriting the stale posture after the global posture was cleared.
90
+ if (!b.compliance || typeof b.compliance.set !== "function") return;
91
+ var r = b.retention;
92
+ try {
93
+ if (b.compliance.current()) b.compliance.clear();
94
+ r.applyPosture(null);
95
+ b.compliance.set("hipaa");
96
+ check("compliance.set cascades the posture into retention",
97
+ r.activePosture() === "hipaa");
98
+ b.compliance.clear();
99
+ check("compliance.clear cascades the clear into retention (no stale inheritance)",
100
+ r.activePosture() === null);
101
+ var threw = null;
102
+ try { r.complianceFloor(b.constants.TIME.days(30)); } catch (e) { threw = e; }
103
+ check("after clear, complianceFloor with no explicit posture throws (not the stale floor)",
104
+ threw !== null);
105
+ } finally {
106
+ try { if (b.compliance.current()) b.compliance.clear(); } catch (_e) { /* best-effort restore */ }
107
+ try { r.applyPosture(null); } catch (_e) { /* best-effort restore */ }
108
+ }
109
+ }
110
+
54
111
  async function run() {
55
112
  testSurface();
56
113
  testKnownPostures();
57
114
  testCandidateGreaterThanFloor();
58
115
  testCandidateShorterThanFloor();
59
116
  testUnknownPostureThrows();
117
+ testOptionalPostureInheritance();
118
+ testComplianceClearCascadesToRetention();
60
119
  }
61
120
 
62
121
  module.exports = { run: run };
@@ -51,23 +51,75 @@ function testIpv4LoopbackEquivalenceClass() {
51
51
  }
52
52
 
53
53
  function testIpv6MappedEquivalenceClass() {
54
- // IPv4-mapped IPv6 in dotted, hex, and fully-expanded spellings — all the
55
- // same 16 bytes, all collapse to one bracketed RFC 5952 form.
56
- var forms = [
54
+ // An IPv4-mapped IPv6 address (::ffff:a.b.c.d, the ::ffff:0:0/96 block) IS
55
+ // the IPv4 address a.b.c.d for routing / access-control: a dual-stack peer
56
+ // arriving on ::ffff:1.2.3.4 reaches the same host as 1.2.3.4, and the SSRF
57
+ // classifier already re-classifies it by the embedded v4. So the canonical
58
+ // form must FOLD it to the IPv4 dotted form — otherwise a dual-stack peer
59
+ // never unifies with an operator's IPv4 allowlist entry (the exact bypass).
60
+ var mappedForms = [
57
61
  "http://[::ffff:127.0.0.1]/",
58
62
  "http://[::ffff:7f00:1]/",
59
63
  "http://[0:0:0:0:0:ffff:7f00:1]/",
60
64
  "http://[0:0:0:0:0:FFFF:7F00:1]/", // mixed-case hex
61
65
  ];
62
- var first = b.safeUrl.canonicalize(forms[0]);
63
- check("ipv4-mapped IPv6 canonical is bracketed RFC 5952",
64
- first === "http://[::ffff:7f00:1]/");
65
- for (var i = 1; i < forms.length; i += 1) {
66
- check("ipv4-mapped form '" + forms[i] + "' === first canonical",
67
- b.safeUrl.canonicalize(forms[i]) === first);
68
- check("raw '" + forms[i] + "' !== raw '" + forms[0] + "' (old-world unequal)",
69
- forms[i] !== forms[0]);
66
+ var bareV4 = b.safeUrl.canonicalize("http://127.0.0.1/");
67
+ check("plain IPv4 canonical is the dotted form", bareV4 === "http://127.0.0.1/");
68
+ for (var i = 0; i < mappedForms.length; i += 1) {
69
+ check("ipv4-mapped '" + mappedForms[i] + "' folds to the bare IPv4 form",
70
+ b.safeUrl.canonicalize(mappedForms[i]) === bareV4);
71
+ check("raw '" + mappedForms[i] + "' !== 'http://127.0.0.1/' (old-world unequal)",
72
+ mappedForms[i] !== "http://127.0.0.1/");
70
73
  }
74
+ // canonicalizeHost folds the host-only form too (used for host allowlists).
75
+ check("canonicalizeHost folds ::ffff:1.2.3.4 to 1.2.3.4",
76
+ b.ssrfGuard.canonicalizeHost("::ffff:1.2.3.4") === "1.2.3.4");
77
+ check("canonicalizeHost folds the all-hex mapped spelling too",
78
+ b.ssrfGuard.canonicalizeHost("::ffff:102:304") === "1.2.3.4");
79
+ // A non-mapped IPv6 (no ::ffff:0:0/96 prefix) stays IPv6 — only the
80
+ // v4-mapped block is an IPv4 alias; an embedded-v4 in a documentation
81
+ // prefix is a distinct address.
82
+ check("a non-mapped IPv6 stays IPv6 (::1)",
83
+ b.ssrfGuard.canonicalizeHost("::1") === "::1");
84
+ check("2001:db8::1.2.3.4 (v4 suffix, NOT v4-mapped) stays IPv6",
85
+ b.ssrfGuard.canonicalizeHost("2001:db8::1.2.3.4").indexOf(".") === -1);
86
+ }
87
+
88
+ function testEmbeddedV4AndTrailingDotUnification() {
89
+ // The canonical form must never flip an SSRF classify() verdict from blocked
90
+ // to allowed. Only the IPv4-mapped block (::ffff:0:0/96) folds, because
91
+ // classify(::ffff:x) === classify(x) — its branch returns classify(mappedV4)
92
+ // with NO reserved fallback. NAT64 (64:ff9b::/96) and 6to4 (2002::/16) are
93
+ // NOT folded: classify treats a NAT64 literal as `classify(v4) || "reserved"`,
94
+ // so classify("64:ff9b::8.8.8.8") is "reserved" while classify("8.8.8.8") is
95
+ // null — folding would turn a blocked NAT64 address into an allowed public
96
+ // IPv4 verdict. The invariant below pins that: canonicalizing then classifying
97
+ // must agree with classifying the original.
98
+ var classify = b.ssrfGuard.classify;
99
+ function classifyAgrees(host) {
100
+ return classify(b.ssrfGuard.canonicalizeHost(host)) === classify(host);
101
+ }
102
+ check("NAT64 stays IPv6 (a public NAT64 literal must not become an allowed IPv4)",
103
+ b.ssrfGuard.canonicalizeHost("64:ff9b::8.8.8.8").indexOf(".") === -1);
104
+ check("canonicalize agrees with classify on a public NAT64 literal",
105
+ classifyAgrees("64:ff9b::8.8.8.8"));
106
+ check("canonicalize agrees with classify on a NAT64 loopback literal",
107
+ classifyAgrees("64:ff9b::127.0.0.1"));
108
+ check("canonicalize agrees with classify on a public IPv4-mapped literal",
109
+ classifyAgrees("::ffff:8.8.8.8"));
110
+ // 6to4 (2002::/16) is a /48 PREFIX, not a 1:1 alias — it must stay IPv6
111
+ // (folding it would collapse a whole subnet onto one IPv4).
112
+ check("6to4 2002:7f00:1:: stays IPv6 (not a 1:1 v4 alias)",
113
+ b.ssrfGuard.canonicalizeHost("2002:7f00:1::").indexOf(".") === -1);
114
+
115
+ // Trailing dots are not significant for host identity — every count must
116
+ // collapse to the bare name so host / host. / host.. all unify.
117
+ check("single trailing dot strips to the bare name",
118
+ b.ssrfGuard.canonicalizeHost("example.com.") === "example.com");
119
+ check("multiple trailing dots all strip to the bare name",
120
+ b.ssrfGuard.canonicalizeHost("example.com..") === "example.com");
121
+ check("canonicalize unifies a trailing-dot URL host with the bare host",
122
+ b.safeUrl.canonicalize("http://example.com./p") === b.safeUrl.canonicalize("http://example.com/p"));
71
123
  }
72
124
 
73
125
  function testIpv6ZeroCompressionEquivalenceClass() {
@@ -284,6 +336,7 @@ function testUserinfoDroppedFromCanonicalForm() {
284
336
 
285
337
  async function run() {
286
338
  testUserinfoDroppedFromCanonicalForm();
339
+ testEmbeddedV4AndTrailingDotUnification();
287
340
  testIpv4LoopbackEquivalenceClass();
288
341
  testIpv6MappedEquivalenceClass();
289
342
  testIpv6ZeroCompressionEquivalenceClass();
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ // #130: the scheduler watchdog force-clears task.running after maxJobMs and
3
+ // lets the next tick re-fire. The ORIGINAL (slow) run's promise then settles
4
+ // late and, before the fix, unconditionally wrote back task.running/lastFinish/
5
+ // lastError AND emitted system.scheduler.task.success|failure — clobbering the
6
+ // state the watchdog (and the new run) had moved on from, and double-counting.
7
+ // The fix tags each run with a generation the watchdog + each fire bump; a
8
+ // settle whose tag is stale is ignored.
9
+ //
10
+ // Driven through the public scheduler with a run() whose promise the test
11
+ // controls. RED on the buggy tree: resolving the watchdog-abandoned run emits
12
+ // a stale success. GREEN: it emits nothing; only the current run settles.
13
+
14
+ var helpers = require("../helpers");
15
+ var b = helpers.b;
16
+ var check = helpers.check;
17
+ var auditMod = require("../../lib/audit");
18
+
19
+ var TASK = "wd-stale-settle-test";
20
+
21
+ async function run() {
22
+ var realEmit = auditMod.safeEmit;
23
+ var successes = 0;
24
+ auditMod.safeEmit = function (ev) {
25
+ if (ev && ev.action === "system.scheduler.task.success" &&
26
+ ev.metadata && ev.metadata.name === TASK) {
27
+ successes += 1;
28
+ }
29
+ return realEmit.call(auditMod, ev);
30
+ };
31
+
32
+ var resolvers = []; // one resolve fn per fire — the test settles them by hand
33
+ var sched = b.scheduler.create({ maxJobMs: 1, audit: true }); // 1ms watchdog → any stuck run is reaped next tick
34
+ try {
35
+ sched.schedule({
36
+ name: TASK,
37
+ every: 1000, // builder floor is 1000ms; fire 1 ≈ 1s, watchdog re-fire ≈ 2s
38
+ run: function () { return new Promise(function (resolve) { resolvers.push(resolve); }); },
39
+ });
40
+ sched.start();
41
+
42
+ // Wait until the watchdog has reaped fire 1 (still pending) and re-fired,
43
+ // so two runs exist: resolvers[0] (abandoned) + resolvers[1] (current).
44
+ await helpers.waitUntil(function () { return resolvers.length >= 2; },
45
+ { timeoutMs: 9000, label: "#130: watchdog re-fired the task after a stuck run" });
46
+
47
+ var before = successes;
48
+ // Settle the FIRST run — the one the watchdog abandoned. Its late resolve
49
+ // must NOT emit a success (its generation is stale).
50
+ resolvers[0]();
51
+ await helpers.passiveObserve(400, "#130: stale-settle window for the abandoned run");
52
+ check("#130 a watchdog-abandoned run's late resolve emits NO stale success",
53
+ successes === before);
54
+
55
+ // The current run still settles normally → exactly one success.
56
+ resolvers[1]();
57
+ await helpers.waitUntil(function () { return successes === before + 1; },
58
+ { timeoutMs: 5000, label: "#130: the current run records its success" });
59
+ check("#130 the current (post-watchdog) run still records its success",
60
+ successes === before + 1);
61
+ } finally {
62
+ sched.stop();
63
+ auditMod.safeEmit = realEmit;
64
+ }
65
+ }
66
+
67
+ module.exports = { run: run };
68
+ if (require.main === module) {
69
+ run().then(function () { process.exit(0); })
70
+ .catch(function (err) { process.stderr.write(String(err && err.stack || err) + "\n"); process.exit(1); });
71
+ }
@@ -317,7 +317,64 @@ async function testRotateRekeysFingerprint() {
317
317
  }
318
318
  }
319
319
 
320
+ async function testLogoutEmitsClearSiteData() {
321
+ var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ses-logout-"));
322
+ try {
323
+ await setupTestDb(tmpDir);
324
+ var s = await b.session.create({ userId: "u-logout" });
325
+ check("session created", typeof s.token === "string");
326
+
327
+ var headers = {};
328
+ var res = {
329
+ setHeader: function (k, v) { headers[k] = v; },
330
+ };
331
+ var destroyed = await b.session.logout(res, s.token);
332
+
333
+ check("logout returns true (session destroyed)", destroyed === true);
334
+ check("logout emits Clear-Site-Data header",
335
+ typeof headers["Clear-Site-Data"] === "string" &&
336
+ headers["Clear-Site-Data"].indexOf('"cookies"') !== -1 &&
337
+ headers["Clear-Site-Data"].indexOf('"storage"') !== -1);
338
+ check("logout expires the session cookie",
339
+ typeof headers["Set-Cookie"] === "string" &&
340
+ /(^|;)\s*Max-Age=0/.test(headers["Set-Cookie"]) &&
341
+ headers["Set-Cookie"].indexOf("sid=;") === 0);
342
+ check("logout cookie is Secure + HttpOnly",
343
+ /HttpOnly/.test(headers["Set-Cookie"]) && /Secure/.test(headers["Set-Cookie"]));
344
+
345
+ // The session is gone cluster-wide.
346
+ var after = await b.session.verify(s.token);
347
+ check("logout destroyed the session (verify returns null)", after === null);
348
+
349
+ // Custom cookie name + an unknown Clear-Site-Data directive throws.
350
+ var s2 = await b.session.create({ userId: "u-logout-2" });
351
+ var h2 = {}; var res2 = { setHeader: function (k, v) { h2[k] = v; } };
352
+ await b.session.logout(res2, s2.token, { cookieName: "__Host-sid" });
353
+ check("logout honors custom cookieName", h2["Set-Cookie"].indexOf("__Host-sid=;") === 0);
354
+
355
+ // An unknown directive throws BEFORE any side effect — the session is NOT
356
+ // destroyed and no client-wipe headers are queued (validate-before-revoke).
357
+ var s3 = await b.session.create({ userId: "u-logout-3" });
358
+ var h3 = {}; var res3 = { setHeader: function (k, v) { h3[k] = v; } };
359
+ var threw = null;
360
+ try { await b.session.logout(res3, s3.token, { types: ["bogus"] }); }
361
+ catch (e) { threw = e; }
362
+ check("logout rejects an unknown Clear-Site-Data directive", threw !== null);
363
+ check("logout did NOT queue headers on the bad-directive throw",
364
+ h3["Clear-Site-Data"] === undefined && h3["Set-Cookie"] === undefined);
365
+ check("logout did NOT destroy the session on the bad-directive throw",
366
+ (await b.session.verify(s3.token)) !== null);
367
+
368
+ var badRes = null;
369
+ try { await b.session.logout({}, "x"); } catch (e) { badRes = e; }
370
+ check("logout rejects a res without setHeader", badRes && badRes.code === "session/bad-res");
371
+ } finally {
372
+ await teardownTestDb(tmpDir);
373
+ }
374
+ }
375
+
320
376
  async function run() {
377
+ await testLogoutEmitsClearSiteData();
321
378
  await testSealedCookieDefault();
322
379
  await testSealedCookieRotateAndDestroy();
323
380
  await testClientIpPrefixV4();
@@ -81,8 +81,11 @@ async function run() {
81
81
  audit: false,
82
82
  });
83
83
 
84
- check("watcher.create: returns stop + root",
85
- typeof w.stop === "function" && w.root === path.resolve(tmpDir));
84
+ // .root is canonicalized (realpathSync.native) so a Windows 8.3 short-name
85
+ // or a macOS /var -> /private/var symlink resolves — fs.watch event paths
86
+ // then prefix-match the watched root.
87
+ check("watcher.create: returns stop + canonical root",
88
+ typeof w.stop === "function" && w.root === fs.realpathSync.native(path.resolve(tmpDir)));
86
89
 
87
90
  // Drop the legacy "prime the watcher with a 200ms sleep" step —
88
91
  // helpers.waitForWatcher (15s default budget) absorbs fs.watch's
@@ -151,7 +154,8 @@ async function run() {
151
154
  var shape = changes.find(function (e) { return e.relativePath === "shape.txt"; });
152
155
  check("watcher.create: onChange has type/relativePath/fullPath/size/mtime",
153
156
  shape && shape.type === "file" && shape.size === 4 &&
154
- shape.fullPath === path.join(tmpDir, "shape.txt") &&
157
+ // fullPath is rooted at the canonical (realpath'd) watcher root.
158
+ shape.fullPath === path.join(w.root, "shape.txt") &&
155
159
  shape.mtime instanceof Date);
156
160
 
157
161
  // Symlinks must be skipped on the post-event lstat path. Skip on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.32",
3
+ "version": "0.4.37",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -12,7 +12,7 @@
12
12
  "release": "node scripts/release.js"
13
13
  },
14
14
  "engines": {
15
- "node": ">=24.14.1"
15
+ "node": ">=24.16.0"
16
16
  },
17
17
  "files": [
18
18
  "lib/",