@blamejs/blamejs-shop 0.4.32 → 0.4.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.33 (2026-06-13) — **Raise the Node floor to 24.16.0 and refresh the vendored blamejs to 0.15.7.** A runtime and framework modernization. The minimum Node version is now 24.16.0 (the current LTS line) — the container base image, CI, and the package's engines floor all move together, so a deploy now runs on a Node that carries the latest LTS security patches. The vendored blamejs advances 0.15.6 → 0.15.7, a security and correctness release whose hardening reaches the shop by composition rather than new code: the OIDC ID-token verifier now enforces the authorized-party (azp) check, so a multi-audience token minted for a different client that also lists this shop in its audience array no longer verifies — closing a confused-deputy hole on Sign in with Google and Apple; the URL and host canonicalizer folds IPv4-mapped IPv6 addresses to their embedded IPv4 and strips trailing dots, so a dotted-IPv4 allowlist and a host comparison can't be slipped by an encoding trick on the outbound webhook and payment dials; and audited correctness fixes land in the background scheduler (a watchdog-abandoned run can no longer clobber the next run's state), the retention compliance floor (it now inherits the active compliance posture), credential rehashing (a digest shorter than the configured length is now flagged for upgrade), and SD-JWT key binding (audience and nonce now compare in constant time). No migration to apply. **Changed:** *Minimum Node is now 24.16.0* — The package engines floor, the container Dockerfile base image, the .nvmrc, and every CI runner move from Node 24.14.1 to 24.16.0 (LTS). Operators self-hosting the container or running the app directly must be on Node >= 24.16.0; it is a patch-level move within the same LTS line, so an existing 24.x install updates in place. The deployed container already builds on the new base. · *Vendored blamejs advanced 0.15.6 → 0.15.7* — The shop carries blamejs as a vendored, zero-runtime-dependency copy, refreshed through the vendor pipeline (not hand-edited) with per-file integrity hashes re-stamped. The shop's surface is unchanged — every primitive it composes kept its contract — so there is nothing to migrate; the security and correctness fixes below ride along because the shop already composes the affected primitives. **Security:** *Federated sign-in enforces the OIDC authorized-party check* — The OIDC verifier behind Sign in with Google and Apple now rejects a multi-audience ID token that omits an authorized-party (azp) claim, and any token whose azp is not this shop's client id — per OIDC Core 3.1.3.7. This closes a confused-deputy gap where a token minted for a different relying party, but whose audience array also listed this shop, would verify clean. Single-audience tokens (the common case) are unaffected. · *Outbound-host allowlist comparison resists encoding tricks* — The URL and host canonicalizer the shop runs on operator-supplied webhook endpoints (and the payment-provider dials) now folds an IPv4-mapped IPv6 address to its embedded IPv4 and strips trailing dots before any allowlist or SSRF comparison, so a dual-stack peer can no longer present one host form to the allowlist and resolve to another, and host. / host.. no longer evade a host check.
12
+
11
13
  - v0.4.32 (2026-06-13) — **Refresh the vendored blamejs framework to 0.15.6 — sign-in token hardening, SSRF-safe host canonicalization, and background-delivery reliability fixes.** A vendored-framework refresh from blamejs 0.14.22 to 0.15.6, picking up a run of security and correctness fixes in the foundation the shop is built on. The most shop-relevant: federated sign-in (Sign in with Google / Apple) is hardened — a SAML response whose subject confirmation omits an expiry is rejected instead of treated as fresh forever, and the OIDC ID-token verifier no longer lets a normal token skip expiry validation; the SSRF guard now canonicalizes obfuscated host and IP forms to one string before any allowlist comparison, so encoding tricks can't slip a request past the media-upload fetch or an outbound payment dial; and background delivery is more durable — a crashed publisher's in-flight outbox job is reclaimed, a server-sent-event connection caps its outbound buffer and evicts a stalled client instead of growing the heap, and a worker-pool task queued behind one that timed out is no longer dropped. The vendored tree is the single source of truth and was refreshed through the vendor pipeline, not hand-edited; per-file integrity hashes are re-stamped. No migration to apply. **Changed:** *Vendored blamejs advanced 0.14.22 → 0.15.6* — The shop carries blamejs as a vendored, zero-runtime-dependency copy; this release refreshes it across the 0.15 line. The shop's own surface is unchanged — every primitive it composes kept its contract — so there is nothing to migrate. The improvements below ride along in the foundation. **Fixed:** *Background delivery survives a crashed publisher and a slow client* — The outbox now reclaims a job left in-flight by a publisher that crashed mid-delivery, restoring at-least-once delivery for the shop's queued mail and outbound webhooks; server-sent-event connections cap their per-connection outbound buffer and evict a stalled client instead of growing memory without bound; and a background worker-pool task queued behind one that timed out is no longer silently dropped. A membership query against a sealed (encrypted) column now hashes each candidate so it returns results instead of failing, and a retention preview no longer rewrites the whole database file. **Security:** *Federated sign-in rejects unbounded and expiry-skipped assertions* — On the OIDC and SAML paths the shop uses for Sign in with Google and Apple, a SAML response whose Bearer or Holder-of-Key subject confirmation has no NotOnOrAfter is now refused rather than accepted as never-expiring, and the OIDC ID-token verifier restricts the expiry-validation bypass to back-channel-logout tokens bounded by an issued-at floor — a normal ID token can no longer be accepted past its expiry. · *SSRF allowlist comparison canonicalizes host and IP forms first* — Outbound fetches — the operator media-upload-from-URL path and the payment-provider dials — go through an SSRF guard that now collapses obfuscated host and IP encodings to a single canonical form before checking them, closing the gap where an encoding trick could present one string to the allowlist and resolve to another.
12
14
 
13
15
  - v0.4.31 (2026-06-13) — **A partial refund on a split-tender order no longer re-credits the gift card on top of the cash refund.** A refund-accounting fix. When an order was paid partly by gift card or redeemed loyalty and partly by cash, a partial refund of the cash slice returned the cash through the payment provider AND ALSO re-credited a proportional share to the gift card and loyalty balance — handing back more value than the refund. On a $50 order paid with a $20 gift card and $30 cash, a $30 refund returned $30 in cash plus $12 to the card: $42 for a $30 refund, and the over-credit landed on spendable balance. Refund accounting is now cash-first: a partial refund draws against the cash captured at checkout, and the gift-card and loyalty tenders are re-credited only for the portion of the cumulative refund that exceeds that cash. A full refund still returns every tender in full, exactly once. The gift-card and loyalty share each order was paid with is recorded at checkout so the refund path apportions correctly; orders placed before this release carry no recorded split and are treated as cash-only on partial refunds, with the full-refund path unchanged. No migration to apply. **Fixed:** *Partial refunds are cash-first on split-tender orders* — A partial refund returns value to the tender the customer was actually charged: the cash captured by the payment provider. The gift-card and loyalty balances are re-credited only once a refund exceeds the cash captured — so refunding the cash portion of a split-tender order returns just the cash, and the card is restored only by a refund that reaches into the credit-paid share or by a full refund. Previously a partial refund re-minted a proportional slice of the gift card and loyalty on every refund, returning more than the amount refunded; because that credit landed on spendable balance, the excess was real and re-usable. · *Reconcile gift-card balances touched by earlier split-tender partial refunds* — Operators who issued partial refunds on orders paid partly by gift card or loyalty before this release should review the affected gift-card balances and loyalty ledgers: those refunds may have credited value above the amount refunded. Full refunds were unaffected — they return each tender once. New refunds apportion correctly.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Homepage: **https://blamejs.shop**
12
12
 
13
13
  ## Requirements
14
14
 
15
- - Node.js LTS (>= 24.14.1)
15
+ - Node.js LTS (>= 24.16.0)
16
16
  - For a deployable shop: a Cloudflare account (Workers, Containers, D1, R2, KV, Durable Objects). Local development works without it via `node:sqlite`.
17
17
 
18
18
  ## Install
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.32",
2
+ "version": "0.4.33",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.15.6",
7
- "tag": "v0.15.6",
6
+ "version": "0.15.7",
7
+ "tag": "v0.15.7",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -821,6 +821,7 @@
821
821
  "release-notes/v0.15.4.json": "lib/vendor/blamejs/release-notes/v0.15.4.json",
822
822
  "release-notes/v0.15.5.json": "lib/vendor/blamejs/release-notes/v0.15.5.json",
823
823
  "release-notes/v0.15.6.json": "lib/vendor/blamejs/release-notes/v0.15.6.json",
824
+ "release-notes/v0.15.7.json": "lib/vendor/blamejs/release-notes/v0.15.7.json",
824
825
  "release-notes/v0.2.x.json": "lib/vendor/blamejs/release-notes/v0.2.x.json",
825
826
  "release-notes/v0.3.x.json": "lib/vendor/blamejs/release-notes/v0.3.x.json",
826
827
  "release-notes/v0.4.x.json": "lib/vendor/blamejs/release-notes/v0.4.x.json",
@@ -1337,6 +1338,7 @@
1337
1338
  "test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js": "lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js",
1338
1339
  "test/layer-0-primitives/sandbox.test.js": "lib/vendor/blamejs/test/layer-0-primitives/sandbox.test.js",
1339
1340
  "test/layer-0-primitives/scheduler-exactly-once.test.js": "lib/vendor/blamejs/test/layer-0-primitives/scheduler-exactly-once.test.js",
1341
+ "test/layer-0-primitives/scheduler-watchdog-stale-settle.test.js": "lib/vendor/blamejs/test/layer-0-primitives/scheduler-watchdog-stale-settle.test.js",
1340
1342
  "test/layer-0-primitives/scim-server.test.js": "lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js",
1341
1343
  "test/layer-0-primitives/scitt.test.js": "lib/vendor/blamejs/test/layer-0-primitives/scitt.test.js",
1342
1344
  "test/layer-0-primitives/sd-jwt-vc-ecdsa-p1363.test.js": "lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc-ecdsa-p1363.test.js",
@@ -1442,17 +1444,17 @@
1442
1444
  ".npmrc": "sha256:66f104e7d07c496d2d0409988225e8c0e4ceb8d247dbcac3be75b2128d20ce66",
1443
1445
  ".pinact.yaml": "sha256:0213ffda55961dc49b64c0a5dfa3c0567419633b1499d57eaf7c8d842d7da6c7",
1444
1446
  "ARCHITECTURE.md": "sha256:9b1c8d2b1b7a41838eb348b0a008e4b4369718fd72bfe2974b37155f7536d35b",
1445
- "CHANGELOG.md": "sha256:5996fad1d3ec3c5ee3e9ddbbd827ae161daf913e9cf9e97729b9597370e1eade",
1447
+ "CHANGELOG.md": "sha256:420f57ed0c450bc4eb57b07197a4fbec0fe584e847b71411076d5602ba7d28d3",
1446
1448
  "CODE_OF_CONDUCT.md": "sha256:148a281960fff7c2fe6554dab66da572c72245ddeb00b0d14811558397bff386",
1447
1449
  "CONTRIBUTING.md": "sha256:bb4dbdbc8598da31dbce653a8ed322e08ff46560173f2eb67a4d684653948332",
1448
1450
  "GOVERNANCE.md": "sha256:906df6afb1f552b27b9acb50f7f96c47b917a2f1021cd4e987dbf4ee0e0a821b",
1449
1451
  "LICENSE": "sha256:d1b40781c0774cb3b2936beb466709994d164b0f7466000be5279b5ed522af65",
1450
1452
  "LTS-CALENDAR.md": "sha256:8ed8c0051c3d4e14637a24555751b07758fbac2678688d9e1aca2ce312bcf585",
1451
- "MIGRATING.md": "sha256:1d35327ec3cc92df2d967b66a9591209fca3c39db9a64163624cac5456d072b3",
1453
+ "MIGRATING.md": "sha256:3dcc952a3d4a77d53ff60fb67cb5eb5c3a3db2449d7c71f9c4dc7f868097153c",
1452
1454
  "NOTICE": "sha256:f487fa47a11aca0f89e2615cdd3c713e9842abf7a30d8d328eeeae1c864aa774",
1453
1455
  "README.md": "sha256:60752c2d89287fa6843132b7ab293a7ca71b7e9acb59a1b3f974366b193893c3",
1454
- "SECURITY.md": "sha256:be834921a43958cb7514854718c86d5d73f9d873be8fd0891822f1508c465cd5",
1455
- "api-snapshot.json": "sha256:a6727498f1c93ba296b9ef410b888c46436bf5fd005bc0915ede42b0d0410e04",
1456
+ "SECURITY.md": "sha256:1f9ea79942fb0bb4b32f771ffd1712245c2419e754645369b9e895c4a8ccddbd",
1457
+ "api-snapshot.json": "sha256:cee4ee85c3b4d8d60726cfcb9785703e7206135a2fb25e08af9be621f2a1d620",
1456
1458
  "assets/BlameJS_Logo.png": "sha256:3c65699753c771b48ef9ac7f45bb40815ec19a23afcdd0cd30ef4601bbbe293e",
1457
1459
  "assets/BlameJS_Logo.svg": "sha256:dda44f3fb1343d5de9db6b1fcdb75fc649c57e7a99a8e8239fcf852e3841e1a8",
1458
1460
  "bench/README.md": "sha256:74202f2507fd840bfc1ac6c681975d9273cf36cca6e0f72655f138337304033c",
@@ -1710,14 +1712,14 @@
1710
1712
  "lib/auth/bot-challenge.js": "sha256:544cc9060a68170e97ae2039d1220982f525e94eb6606837123f78143e545114",
1711
1713
  "lib/auth/ciba.js": "sha256:cc031a5de93632d830791c406330261fb6d5d9a8ac92adaa5f97a4844a1170bb",
1712
1714
  "lib/auth/dpop.js": "sha256:8d61e8bf0c54abfd0c509daf662f9cccd98e2fe5fcb3713cd5b50600842cb32c",
1713
- "lib/auth/elevation-grant.js": "sha256:c152c403050b08106f687e46ee85b5d80f160f6e346b5c2d278381f039eca143",
1715
+ "lib/auth/elevation-grant.js": "sha256:cf1c498359073a29d0192f390fe767731025d7cd65c5272298ae06ee10e307e7",
1714
1716
  "lib/auth/fal.js": "sha256:aabf6d8095dd41dcda8a2efdb48e00e95bffe70c78991c93fbef827816918692",
1715
1717
  "lib/auth/fido-mds3.js": "sha256:5ddd58557a0331bb39e7606c15cb0f29fbc38fd85f51c99c93d16621db99261e",
1716
1718
  "lib/auth/jar.js": "sha256:f333f25a87b8c60f5f19c51d68aefba8ae8ed304e0f54a957560475359e11f7f",
1717
1719
  "lib/auth/jwt-external.js": "sha256:4fcf0443decf374aed4d0a328ce769df2d321981afe5a9496c57e1ef90938db7",
1718
1720
  "lib/auth/jwt.js": "sha256:032dec9c7a117ef728ded26ac681b846ff4b98112074aa890c499c97902cbec4",
1719
1721
  "lib/auth/lockout.js": "sha256:44afb265e064401fc2bedfb46673f822fb24c1ffae05cdb116949ce0f841a813",
1720
- "lib/auth/oauth.js": "sha256:ee0065e47039f7d3aec6d06dbc3cea04de705b6e14d44fc97ff3cca79122dc40",
1722
+ "lib/auth/oauth.js": "sha256:9329431279faeca8021670b9c9ae9ec05071871dd389fe719e1c6b1932353495",
1721
1723
  "lib/auth/oid4vci.js": "sha256:f8595472abd01beb635ce03fc79e305e288ae7802587ec8f75229d2efa2c8294",
1722
1724
  "lib/auth/oid4vp.js": "sha256:ec2098480638e70d8b7b450242b5d158d52ac3d0f2aea59d6dfc345e8451a29f",
1723
1725
  "lib/auth/openid-federation.js": "sha256:04e44d1ec9c8a813bdcfb815fdc06eb09cb319ebc4bd712c9e8a8d527fa63cd3",
@@ -1727,7 +1729,7 @@
1727
1729
  "lib/auth/sd-jwt-vc-disclosure.js": "sha256:bc1eff5def71d2eedb6f17c8bede650050af9d790145e8697871c75ddc8431ae",
1728
1730
  "lib/auth/sd-jwt-vc-holder.js": "sha256:c24c447bd2ea84976ff31d66526ac90227309da20a1f8ee73e4d4425fb48f2e2",
1729
1731
  "lib/auth/sd-jwt-vc-issuer.js": "sha256:11a2d76bfd5fd6c24fcf9a84178b6af1efdc82bca40e12a874057fc05ed5e27f",
1730
- "lib/auth/sd-jwt-vc.js": "sha256:6dc4193c8ab15bae58ed23811426ed6077f15d994309b48d4441b5f2f8b9b093",
1732
+ "lib/auth/sd-jwt-vc.js": "sha256:30c9b88e43c784e5a8e7375354311d43417947f66b052010a5448286c35fec46",
1731
1733
  "lib/auth/status-list.js": "sha256:354c612e45819359dd15112405d2299220772e7994cfa9f014d61688676b406f",
1732
1734
  "lib/auth/step-up-policy.js": "sha256:dca5810bd13d1e4d279b9d34b3e777cf2455c938502b25b41c773e513d90b379",
1733
1735
  "lib/auth/step-up.js": "sha256:a78833d06c3ee66ba980cb446784273ed505300dc4a84ae2b1b43558987105d0",
@@ -1771,7 +1773,7 @@
1771
1773
  "lib/compliance-sanctions-fetcher.js": "sha256:a059b095c1e3c287339678ab55d7dbcd0f6f3ce813d106746379cd4a07b9293e",
1772
1774
  "lib/compliance-sanctions-fuzzy.js": "sha256:ec6f76fc40a245ff40a36f59a4d81ac8f20c16c2dbf9462e5c78bb58cbe36ba4",
1773
1775
  "lib/compliance-sanctions.js": "sha256:9c924bce4d6fbdd884f6aac385537b5798006569f6370184530f3ef39a64f712",
1774
- "lib/compliance.js": "sha256:1c2d779d1a208c20f2847915f96aa7374b2c21d81fbbfb4af2638cd398f2eeee",
1776
+ "lib/compliance.js": "sha256:c6fe0a6398a511f7d8f607345f603e91c2c1708e841527ba9e4b5747a7eff5bc",
1775
1777
  "lib/config-drift.js": "sha256:94609b873a75c82d21671ebb4c22b2bf4135bdae7b5a355600f68c4caddd02f3",
1776
1778
  "lib/config.js": "sha256:07e20539293e9e365690addc902bc623e213c5ece972dc5b72199b375a17e66d",
1777
1779
  "lib/consent.js": "sha256:7a101c997ad040a2845648670b866425935c3cda96a48dc678723ca2cd20f76d",
@@ -1782,7 +1784,7 @@
1782
1784
  "lib/cose.js": "sha256:8fccd8381b9a4135aeae54b1b951e9639a8df5f9764440122c93c42b748e6e23",
1783
1785
  "lib/cra-report.js": "sha256:cbfba4d6646f4d32e798594dbc8994cb8b49df65c965691122a93ddf7129539a",
1784
1786
  "lib/crdt.js": "sha256:66389cd4aa692b0b6c23404ab3ad1e33de97aebbb4c2c94beb66c09420244fea",
1785
- "lib/credential-hash.js": "sha256:963e2cac21590d42bbe2b56e3d21311396600428589df93e66831f3cc3ce3d2f",
1787
+ "lib/credential-hash.js": "sha256:9f4bf10f3ee86f03fba90e32778d99e0f9eb5966b826cbd1dc83e6c55000400e",
1786
1788
  "lib/crypto-field.js": "sha256:a321452dd8f36da31eca75676c646e00b3d8134963e09478e2060c185f2ae024",
1787
1789
  "lib/crypto-hpke-pq.js": "sha256:f086e23f4f80de9d0713826890bf8bafc0a8ddfa53fe7e87f5a0fed8ffa35caf",
1788
1790
  "lib/crypto-hpke.js": "sha256:e9fb595fc16206237edeb738bfe4b037eeee91de9558e09ffe41e0f1e37558a7",
@@ -2112,7 +2114,7 @@
2112
2114
  "lib/restore-bundle.js": "sha256:ad3b5cf880a38724bb5aa1b1bb5bdf6995a856ff1e8bbb34097c2082d1ce18d5",
2113
2115
  "lib/restore-rollback.js": "sha256:d318a1af8224d0528a76016369b185e06e70967c696c775170af07dbfaa83ab6",
2114
2116
  "lib/restore.js": "sha256:bb2607ac36c2ab4d94115fba14f6ed71bf071bdddf74bd8e6c1be939982d3de4",
2115
- "lib/retention.js": "sha256:c52b7b274a7c38569a6fada92ff5be2a7e950d218c06cc7dcee574aa688beaee",
2117
+ "lib/retention.js": "sha256:c63a61c6145f694a9acdbda68466a80d21e59835f66e8cdfa842941c11d64ea0",
2116
2118
  "lib/retry.js": "sha256:785a4e7bad551354b8b84d5c01092fcfde88b49bf8ff646557b28fbb3d25302e",
2117
2119
  "lib/rfc3339.js": "sha256:b318c45be3834ccbcddfa5d4773d88c6a558cd184e21c15a4021d1b5693c55f6",
2118
2120
  "lib/router.js": "sha256:88c2f3883e2f174a3a0970b2c36a0a01fe6be352a5087ee02924705da054dc6e",
@@ -2137,7 +2139,7 @@
2137
2139
  "lib/safe-vcard.js": "sha256:7dc386eda93567c5ce5bea020268067507a5de0a4655212a5809eeb0ece71c74",
2138
2140
  "lib/sandbox-worker.js": "sha256:d3c1ddc96f1ebee2a31ec9d788dff064bc5a969478f0a434594498013ab50cb0",
2139
2141
  "lib/sandbox.js": "sha256:4a29b5ddb067c0ebbd9680ef0e55b562e1da9472b942c7fbe4915d7936e7c0a5",
2140
- "lib/scheduler.js": "sha256:aa98b96e5ad557834e2d45d6614a60f5010d32797f6c46776aaaa0f9b3678320",
2142
+ "lib/scheduler.js": "sha256:f71e251e085475ee8bedac8379ee05c66d5eb12b7496f58ece9db2c5f18a0041",
2141
2143
  "lib/scitt.js": "sha256:c094cef31630aed5dc7adbf494701ab0a825c79a4e406277cbc757ce54bfec9f",
2142
2144
  "lib/sd-notify.js": "sha256:2ef7395bbdab2ac4eb96083c57d401921c94278545f14427fc88cdd970bdb9eb",
2143
2145
  "lib/sec-cyber.js": "sha256:1af157cc5024f5c0b408e8f921d7b671df56315f9e438415eafc7fb031c4a76c",
@@ -2152,7 +2154,7 @@
2152
2154
  "lib/slug.js": "sha256:bcebb078559528e6bb50a6244633d425ffdd861bb7a708c2b201eae3b3c44b35",
2153
2155
  "lib/sql.js": "sha256:1a2ce0a5ab1b0aef28b667572c3ea655b287f58aa61983ed6fb6e2375c820f59",
2154
2156
  "lib/sse.js": "sha256:bdadec1c0ed962908275716dd635c3f30517d1a9bef19782564cb6ad2ad02b16",
2155
- "lib/ssrf-guard.js": "sha256:6b9043e6b2889e6deb901d627a8b46a7bfe456def4b59c4d5c78a11128192341",
2157
+ "lib/ssrf-guard.js": "sha256:65d3d1bf6841064cdf9b9e7ffb5a0a3ac9358e462943f5de09087640353dfac4",
2156
2158
  "lib/standard-webhooks.js": "sha256:e604534d48202a41f2c9f6954a990731db80d0693794d3a80f371f843490ff57",
2157
2159
  "lib/static.js": "sha256:e9a3d3b3b6d1f67eac9d76b37dfcd14c996f1199453164994e9767dadb066867",
2158
2160
  "lib/storage.js": "sha256:cbafff8732f6220001ab65eb8d6faae932a2515648e2f7cbd6f3ff372b4d2e20",
@@ -2210,7 +2212,7 @@
2210
2212
  "oss-fuzz/projects/blamejs/README.md": "sha256:ae13b7bb79ed8d69b1b3276e5562807a0349fb6e6b7d11cf1f683aad1eafdb4b",
2211
2213
  "oss-fuzz/projects/blamejs/build.sh": "sha256:0ced1cf21782c97be7f8d74faf5e27a308b60b2f858836fb5ca3b8c4e939a8f7",
2212
2214
  "oss-fuzz/projects/blamejs/project.yaml": "sha256:59f2cb83aa622325a175b77416fe155be15b70a9c798bd1a78bba05763b1b03d",
2213
- "package.json": "sha256:b2f31aa548f6a3843f1eb6bae5a6793b5588d200ace97d8220883853c2d94150",
2215
+ "package.json": "sha256:1aa8dd4b196b4fb295cafc9683ee7b9d7e49dc8d395382a092f0b62703f71e64",
2214
2216
  "release-notes/v0.0.x.json": "sha256:7a49819f30068ee119000cad7010194882bb8bfaa12acbdab4dfc066efb7982f",
2215
2217
  "release-notes/v0.1.x.json": "sha256:6742a8c17f947c5cb76f69dead7eea86b942d80621d914b774ba5488e09937e5",
2216
2218
  "release-notes/v0.10.x.json": "sha256:fe498045daf88337bd3d987e5964aa42c99a50e1685b6f09e51f698b8687726f",
@@ -2225,6 +2227,7 @@
2225
2227
  "release-notes/v0.15.4.json": "sha256:6ac7fa0ef1728c27e71b2050d1b07a810f9b4b1440ccddbf28ad56e2f54d8585",
2226
2228
  "release-notes/v0.15.5.json": "sha256:cca1d0edd5d6fc41b512d19d98be224b990dcab41478622c11962f0fcb1bb09a",
2227
2229
  "release-notes/v0.15.6.json": "sha256:0e3b9e5e43b70b61dd258c3003d1b8729cd3c26c62a34dedcca81bbec5d31077",
2230
+ "release-notes/v0.15.7.json": "sha256:b7d153b3528bae9415739cae57202eea18085d0b6214e10dfe69c1486acae783",
2228
2231
  "release-notes/v0.2.x.json": "sha256:985e27ff5de04cfc7869a3986dd0b9f0fcdfcddfb67d3fae3f4ae70856722d7e",
2229
2232
  "release-notes/v0.3.x.json": "sha256:e2db5eae66977b272bb185cad668386afb8fd33998a17c22eb6e411c0f8ca588",
2230
2233
  "release-notes/v0.4.x.json": "sha256:c3d19cb9c50a976432fc0bb612c87741d8728ae37562b501e6f1eccd01dd574d",
@@ -2241,7 +2244,7 @@
2241
2244
  "scripts/check-services.js": "sha256:8c07b049fc899827c2d1ef2c136a3d0c3c43143b1546f28e44739ecec187f777",
2242
2245
  "scripts/check-vendor-currency.js": "sha256:652482b3b228fe933946082646a6cac203a2bae7ba0c45fead11627bbd179c31",
2243
2246
  "scripts/consolidate-release-notes.js": "sha256:620de442c834fa99a7b668b93cf8788384a0aa802ce419953459c88451d8ec73",
2244
- "scripts/gen-migrating.js": "sha256:1e652c28f348dd8b08e19794541824450ff3fe5ce92b8740ae3c97a6f3e92c08",
2247
+ "scripts/gen-migrating.js": "sha256:2177f6b9bf60d6e9c161a0419caa248d2bef9eb7beb5223a10e8cab3ecc555b1",
2245
2248
  "scripts/generate-changelog-entry.js": "sha256:2c0a1a395d2d1b30c1679883c694e27dc6f8e73d950c4deeb118ef5a4f01fd12",
2246
2249
  "scripts/generate-release-signing-key.js": "sha256:3d4cc30a446a8a358b6180a5be7e4e88f2e1722fb1567f4379a335ffc03a50d9",
2247
2250
  "scripts/publish-dep-confusion-placeholder.sh": "sha256:f6ea193c7f79fe08ba86df8533fad093381b4786fcac71aecdfff47fb115c9b6",
@@ -2256,7 +2259,7 @@
2256
2259
  "scripts/vendor-data-gen.js": "sha256:76b627bc6e19b4a122edfca6f514bcb8ca11af02902f0957e641f503337a8a0f",
2257
2260
  "scripts/vendor-data-keygen.js": "sha256:94eaa4d8f832b4aac9ccbcb2a07e6b99cd35cf7b044e1412079cebdefc1f4c0e",
2258
2261
  "scripts/vendor-update.sh": "sha256:c1c879ee620f064a06d776c1d330749b5128a35581352ef385fa8baf4a35f79a",
2259
- "test/00-primitives.js": "sha256:582f2dceeee49a6cbaed860689caa5851c6c6c41ad2d3d50b1a24df992102750",
2262
+ "test/00-primitives.js": "sha256:0a6c6b44c22c692372b072fc109cb734ac9e7812df1d122bbd94a12116c36d35",
2260
2263
  "test/10-state.js": "sha256:0f0cb26460e61b17c747a6a6cb65bd20325e0a4f1af854713e599b2cc9277367",
2261
2264
  "test/20-db.js": "sha256:241ef6b7ef305d077aeafb22ee3bcc75b6b549a8fa9b1a6b5d6d5fba43b48d7d",
2262
2265
  "test/30-chain.js": "sha256:6025201505a4c86ab385180147342d60edc1c5dd5728e2b78fb32b8b04ce7242",
@@ -2443,7 +2446,7 @@
2443
2446
  "test/layer-0-primitives/cluster-storage.test.js": "sha256:5627e621dff001e236b668e04336eb39c9fe08a4a7d45a640e6e7fccce37a022",
2444
2447
  "test/layer-0-primitives/cluster-vault-rotation.test.js": "sha256:3514e9e71d6c39e805248f58ad2f41528d091e196c0f3766a032675677161b2d",
2445
2448
  "test/layer-0-primitives/cms-codec.test.js": "sha256:7e46078ed82be5b69d22c48f22dba37ea5015371c2a8cf5f94fb1a792fb7bb78",
2446
- "test/layer-0-primitives/codebase-patterns.test.js": "sha256:cffb4907580ceceac16af5533321e8b0f001696327a65f8836d5a0620a3c43bb",
2449
+ "test/layer-0-primitives/codebase-patterns.test.js": "sha256:6090ac150971c52767403e77d8b2a3e04301885156deaf33dc479954af12c295",
2447
2450
  "test/layer-0-primitives/compliance-ai-act.test.js": "sha256:5ee4ad05d12233cb3c5457ef10a727833710bbc1ce1318838f9f9ef5d2cb8d4b",
2448
2451
  "test/layer-0-primitives/compliance-cascade.test.js": "sha256:ee02cf14541a837a9d7977c6ea6bf7f9210bed293925d93c976e31f270aebec4",
2449
2452
  "test/layer-0-primitives/compliance-eaa.test.js": "sha256:8afb3fa66f3f9452592995e77f5e0644d8c82de2321c551c6f5be6002b2c27a4",
@@ -2459,7 +2462,7 @@
2459
2462
  "test/layer-0-primitives/cose.test.js": "sha256:70940306703c96d6a9c7f77f35625257b3f578c98a0650af6761cd21916dff97",
2460
2463
  "test/layer-0-primitives/cra-report.test.js": "sha256:204a32da15ed26acb0f6b43b2bf33128cea954885acf29828445f30dfa4fadbe",
2461
2464
  "test/layer-0-primitives/crdt.test.js": "sha256:b9259d8ba12e5e8feb2f982c8357ec7b0254f0f5276754af7e807c427bbe49b0",
2462
- "test/layer-0-primitives/credential-hash.test.js": "sha256:550c645c05cc18a691f276ca7f4dfdd9e9e81c989dfd1623d8fabf90570130b1",
2465
+ "test/layer-0-primitives/credential-hash.test.js": "sha256:cab340489726da55b986f7d92f1e0784da45917387179524e6ce914b125669f8",
2463
2466
  "test/layer-0-primitives/crypto-base64url.test.js": "sha256:7bb8b221b2cbb421c855f0fb3f220a641430cbd6f08ae8e039f0f997d5287cc0",
2464
2467
  "test/layer-0-primitives/crypto-envelope.test.js": "sha256:e9ea0ed1b3d8e9bf0a026901d64ce999541fa53215d405a501b267b841c588fe",
2465
2468
  "test/layer-0-primitives/crypto-field-derived-hash.test.js": "sha256:3d8e29b5fa44fe20f27c1b3678253dac0a16b3af7ec5c1c97a88b38c7a5c7839",
@@ -2713,7 +2716,7 @@
2713
2716
  "test/layer-0-primitives/require-mtls.test.js": "sha256:ba041e00d098090b4ffa578bb8b3f01927043842a5057069502dc69ade2dc23d",
2714
2717
  "test/layer-0-primitives/resource-access-lock.test.js": "sha256:f436bcd187b317229266b4f27d89e7f87cb28c7dc400643594b22d05232dbac4",
2715
2718
  "test/layer-0-primitives/retention-dryrun-no-vacuum.test.js": "sha256:b454fb508b22418097839a20f7b1ff5240a57f8ee37e575768780e7b8cc772b6",
2716
- "test/layer-0-primitives/retention-floor.test.js": "sha256:392f17c8372d14cb265d526771fa4c3d2b1497bd0c64520ea97d73dffeaca08b",
2719
+ "test/layer-0-primitives/retention-floor.test.js": "sha256:c841932447a1ed9be5c1aec5d0819f112e9730305af410aa44d346487e6a3490",
2717
2720
  "test/layer-0-primitives/retry.test.js": "sha256:4e14bf1acfc73d3018e8a7b914c5fc0a9a768f41e81b76ee427fd520b3bbc935",
2718
2721
  "test/layer-0-primitives/router-cross-origin-redirect.test.js": "sha256:716d665c29eed2c5acdfd32a9cac717cb2c70ba6af6e9ab16477b527ee46594d",
2719
2722
  "test/layer-0-primitives/router-tls0rtt.test.js": "sha256:dd44b9358847e6bab34e02f65a4361cddb907ab3acafd7722f2c240c27a46fb7",
@@ -2733,7 +2736,7 @@
2733
2736
  "test/layer-0-primitives/safe-redirect.test.js": "sha256:3435b29c5fed535442e71f9801c24805a5f35aef0f3b4972ed253a9163dd4802",
2734
2737
  "test/layer-0-primitives/safe-sieve.test.js": "sha256:55351060c1cefc55adbba060d5cad75c75a07e5735ce70f94458d7f6deabf49c",
2735
2738
  "test/layer-0-primitives/safe-smtp.test.js": "sha256:c0969bc61e66744e672df89740f9c09ab0d955d946f12648cb936d1bf2a31d70",
2736
- "test/layer-0-primitives/safe-url-canonicalize.test.js": "sha256:399a1856d0ee71536560ffc1b31a94bb4fedec74402dc6a1f33549ca4534e1cc",
2739
+ "test/layer-0-primitives/safe-url-canonicalize.test.js": "sha256:5a85baeec34770f9efef04fe8036fc75f80278188ed6f09adcb97f72d39a0dc8",
2737
2740
  "test/layer-0-primitives/safe-url-idn-homograph.test.js": "sha256:a68b8307a0711270ce865d80937793c3bd48445f0ac6adb5814bd9b948e06193",
2738
2741
  "test/layer-0-primitives/safe-vcard.test.js": "sha256:10a0695050afee64599411352b34382e15b8f9bd9045ed3951cc6bc561918c89",
2739
2742
  "test/layer-0-primitives/safe-xml.test.js": "sha256:dc94bdc968449a87843a2403e4f9d402cd22fb7479cae30ae42e297ffec5a449",
@@ -2741,6 +2744,7 @@
2741
2744
  "test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js": "sha256:fbf0c44b64b102805e6ac76fa39b297cddd44ba6089faf2c99e77930f1bf0628",
2742
2745
  "test/layer-0-primitives/sandbox.test.js": "sha256:498a6f1e79950bfb625d58f009be5f429506c07000b9fdcf86e8400e32fc5ee3",
2743
2746
  "test/layer-0-primitives/scheduler-exactly-once.test.js": "sha256:f269740eba98d12f05f6fa50c7aa4f6ac49a5a69e1dff23898257405a51089fa",
2747
+ "test/layer-0-primitives/scheduler-watchdog-stale-settle.test.js": "sha256:7ee8fc92151f36fcfefb7506e5c274599b6f6df8b83639bbe73738b1f263bb43",
2744
2748
  "test/layer-0-primitives/scim-server.test.js": "sha256:2df544430e780f677491b71e08ac77f88fabaecf0c23d838fb3081d3deb84315",
2745
2749
  "test/layer-0-primitives/scitt.test.js": "sha256:a3030351ec0092516c5eaea4824dae8f466f239133d6a189875d38faf236a232",
2746
2750
  "test/layer-0-primitives/sd-jwt-vc-ecdsa-p1363.test.js": "sha256:d26d4a066458fe37634abf58bf41a66ef913172e5cb601f64751a2c2a03fd8fb",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.7 (2026-06-13) — **Hardens the new URL canonicalizer against an IPv4-mapped allowlist bypass, enforces the OIDC azp authorized-party check, and closes a set of audited correctness gaps in retention, credential rehashing, the scheduler, and SD-JWT key binding.** This release deepens the URL/host canonicalizer shipped in 0.15.6 and clears a batch of audited correctness gaps. The canonicalizer now folds an IPv4-mapped IPv6 address (::ffff:1.2.3.4) to its embedded IPv4 and strips every trailing dot from a host, so a dual-stack peer can no longer slip past an operator's dotted-IPv4 allowlist and host./host.. no longer evade a host comparison. (NAT64 and 6to4 hosts are deliberately kept as IPv6 so canonicalizing then classifying agrees with the SSRF classifier, which treats them as reserved.) On the OIDC side, verifyIdToken now enforces OIDC Core 3.1.3.7: a multi-audience ID token must carry an azp (authorized party) and a present azp must equal the client_id, closing a confused-deputy hole where a token minted for a different client but listing this RP in its audience array verified clean. The rest are audited fixes: retention.complianceFloor now honors the active posture set via applyPosture (the documented inheritance was unimplemented); credentialHash.needsRehash now drives the advertised SHAKE256 length-rotation (raising the output length now triggers a rehash, upgrade-only); the task scheduler no longer lets a run abandoned by its watchdog clobber the next run's state or emit a stale success when its slow promise settles late; and the SD-JWT key-binding JWT compares its audience and nonce in constant time. **Fixed:** *retention.complianceFloor honors the active compliance posture* — `b.retention.complianceFloor` required an explicit posture and never read the active posture set by `applyPosture` (the `b.compliance.set` cascade), so the documented inheritance was unimplemented dead state. It now inherits the active posture when none is passed — `complianceFloor(candidateTtlMs)` uses the active posture, an explicit posture still overrides it — and `applyPosture(null)` now clears the active posture (it was a silent no-op, so `b.compliance.clear` could not reset it). · *credentialHash.needsRehash drives the SHAKE256 length-rotation* — `b.credentialHash.needsRehash` never compared the stored SHAKE256 digest length against the configured length, so raising the output length never triggered a rehash and the advertised length-rotation was a silent no-op. It now flags a digest shorter than the configured/default length for rehash (upgrade-only — a longer-than-target digest is never shortened, matching the Argon2 convention). · *The scheduler no longer lets a watchdog-abandoned run corrupt the next run* — `b.scheduler`'s watchdog force-clears a task's running flag after `maxJobMs` so a hung handler can't lock out future fires, and the next tick re-fires. The original slow promise then settled late and unconditionally overwrote the task's running / lastFinish / lastError state and emitted a `system.scheduler.task.success`|`failure` for a run the watchdog had already given up on — clobbering the new run and double-counting. Each run is now tagged with a generation that the watchdog and every fire bump; a settle whose tag is stale is ignored. **Security:** *The URL canonicalizer folds IPv4-mapped IPv6 addresses to IPv4* — `b.safeUrl.canonicalize` / `b.ssrfGuard.canonicalizeHost` now fold an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`, the `::ffff:0:0/96` block) to its embedded IPv4 dotted form. Previously it canonicalized to an IPv6 string, so a dual-stack peer never unified with an operator's `1.2.3.4` allow/deny entry — an allowlist bypass. Only the IPv4-mapped block folds, because the SSRF classifier maps it to the embedded v4 verdict directly; NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) are deliberately kept as IPv6, since the classifier treats a NAT64 literal as reserved and folding it would turn a blocked verdict into an allowed public IPv4. The host canonicalizer also now strips every trailing dot, so `host`, `host.`, and `host..` collapse to one form. · *verifyIdToken enforces the OIDC azp (authorized party) check* — `b.auth.oauth`'s `verifyIdToken` validated only that its `client_id` was present in the token's `aud`, ignoring `azp`. Per OIDC Core 3.1.3.7, a multi-audience ID token must carry an `azp` and a present `azp` must equal the RP's `client_id`. Without it, a token whose authorized party is a different client — but whose `aud` array also lists this RP — verified clean (a confused-deputy / token-substitution hole). The verifier now rejects a multi-audience token with no `azp` (`auth-oauth/azp-required`) and any token whose `azp` is not the `client_id` (`auth-oauth/azp-mismatch`). A single-audience token with no `azp` (the common case) is unaffected. · *SD-JWT key-binding audience and nonce compare in constant time* — `b.auth.sdJwtVc.verify` compared the key-binding JWT's `aud` and `nonce` with a short-circuiting `!==`, while the adjacent `sd_hash` check already used a constant-time compare. The `nonce` is a verifier-issued replay-defense value, so a non-constant-time compare leaks a matching-prefix timing oracle; both checks now use the constant-time helper.
12
+
11
13
  - v0.15.6 (2026-06-12) — **Closes SAML and OIDC assertion-replay windows, bounds SSE memory under a slow client, restores at-least-once delivery for a crashed outbox publisher, makes sealed-column membership queries work, ships JOSE-conformant SD-JWT signatures, and adds a URL canonicalizer for SSRF-safe comparison.** A security and correctness release. On the identity surface: a SAML Response whose Bearer (or Holder-of-Key) SubjectConfirmation omits NotOnOrAfter is now rejected instead of accepted as fresh-forever, and the OIDC ID-token verifier no longer lets a caller disable expiry validation on a normal token - the exp bypass is restricted to back-channel-logout tokens and bounded by an issued-at freshness floor. SD-JWT-VC ES256/ES384 signatures are now emitted as JOSE raw r||s, so credentials this issuer signs verify in conformant wallets and verifiers. A new b.safeUrl.canonicalize (and b.ssrfGuard.canonicalizeHost) collapses obfuscated host and IP forms to one canonical string so allowlist and SSRF comparisons can't be bypassed by encoding tricks. On the reliability side: server-sent-event channels now cap their per-connection outbound buffer and evict a stalled client instead of growing the heap without bound; the outbox reclaims a job left in-flight by a publisher that crashed mid-delivery; the background worker pool no longer drops a task queued behind one that timed out; a retention preview no longer rewrites the whole database file; an equality / membership query on an encrypted column now hashes each candidate (membership queries previously failed outright); and the on-read re-hash of a legacy lookup digest now runs on Postgres and MySQL handles, not only SQLite. **Fixed:** *SD-JWT-VC ES256 / ES384 signatures are JOSE-conformant* — `b.auth.sdJwtVc` signed and verified ES256 / ES384 credentials with node:crypto's default DER ECDSA encoding instead of the raw r||s (`ieee-p1363`) form JOSE and EUDI wallets require, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The issuer JWT and the holder KB-JWT now both sign and verify with `ieee-p1363`, matching the rest of the framework's JOSE signers. · *Membership queries on an encrypted column now work* — Querying an encrypted (sealed) column with `IN` / `$in` - `b.db.from(table).whereIn("email", [...])` or `b.db.collection(table).find({ email: { $in: [...] } })` - threw, because the sealed-field-to-derived-hash rewrite passed the whole candidate array to the hash lookup as a single value. Each candidate is now hashed individually and matched against both its active keyed digest and its legacy digest, so membership queries on an encrypted column return the right rows, including rows written before the lookup-hash default changed. · *A timing-out background task no longer drops the task queued behind it* — When a `b.workerPool` task timed out or its worker errored, the slot was returned to the idle pool and drained one moment before it was marked for recycling, so a task queued behind it could be dispatched to the worker about to be terminated - and came back as `workerpool/worker-exit` (or hung) instead of running. The slot is now marked recycling before the queue is drained, so the queued task waits for the replacement worker. · *The outbox recovers a job stranded by a crashed publisher* — `b.outbox` claims a row by flipping it to in-flight, but the claim scan only selected pending rows, so a publisher that crashed between claiming a row and recording delivery left that row in-flight forever - the event was silently dropped, breaking at-least-once delivery. The outbox now stamps each claim with a timestamp and, at the start of every poll, returns any in-flight row older than the claim lease (`claimReclaimMs`, default 5 minutes) to the pending pool so it is delivered. An existing outbox table gains the new column automatically. · *A retention preview no longer rewrites the whole database* — Previewing a retention rule with `b.retention` (`run(name, { dryRun: true })` or the `retention preview` command) ran a full database VACUUM for every candidate row under a regulated posture (gdpr / hipaa / and similar), because the per-row erase - which schedules the vacuum - ran before the dry-run check. A preview now computes what it would erase without touching the database; the vacuum runs only on a real erase. · *On-read lookup-digest upgrade runs on Postgres and MySQL* — When `b.cryptoField.unsealRow` re-hashed a legacy lookup digest to the current keyed form and persisted it, the UPDATE was always built for SQLite, so on a Postgres or MySQL handle the durable rewrite quoted identifiers for the wrong dialect and silently no-opped - the legacy digest stayed on disk and the migration never completed off SQLite. The rewrite now uses the handle's own dialect. **Security:** *SAML SubjectConfirmation without NotOnOrAfter is rejected* — `b.auth.saml` SP response verification treated the `NotOnOrAfter` attribute on a Bearer SubjectConfirmationData as optional: a confirmation that omitted it was accepted with no upper bound on the assertion's freshness - a captured assertion replayable indefinitely. SAML 2.0 Web Browser SSO Profile §4.1.4.2 requires Bearer confirmations to carry NotOnOrAfter. `verifyResponse` now rejects a confirmation that is missing NotOnOrAfter, has an unparseable value, or is expired; the Holder-of-Key path (Profile §3.1) is hardened the same way, including the previously-accepted unparseable-value case. · *ID-token expiry can no longer be disabled on a normal token* — `b.auth.oauth`'s `verifyIdToken` honored a `skipExpCheck` option with no constraint, so any caller could verify an expired - or replayed - ID token. That option exists only for OIDC Back-Channel Logout tokens, which carry no `exp`. It is now self-guarding: `verifyIdToken` rejects `skipExpCheck` (`auth-oauth/skip-exp-check-not-allowed`) on any token that lacks the back-channel-logout event claim, and even for a logout token it enforces an `iat` freshness floor (`auth-oauth/logout-token-stale`). The internal logout path is unaffected. · *Server-sent-event channels bound their outbound buffer* — An SSE channel wrote to the response with no regard for backpressure and no cap on buffered bytes, so a single stalled client could make the server buffer events until the heap was exhausted (a memory-exhaustion denial of service). Each channel now tracks its unflushed-byte count and, past a per-channel cap (`maxBufferedBytes`, default 1 MiB), closes the connection and throws `sse/backpressure` - evicting the slow consumer instead of buffering without limit. A client that keeps up is never affected. · *URL and host canonicalizer for SSRF-safe comparison* — New `b.safeUrl.canonicalize(url, opts?)` and `b.ssrfGuard.canonicalizeHost(host)` return the canonical, comparable form of a URL or host: scheme and host lowercased, IDN hosts emitted as their punycode A-label (a confusable / mixed-script host is rejected), every base of an IP literal (decimal, octal, hex, dotted, IPv4-mapped and zero-compressed IPv6) collapsed to one canonical address, default ports stripped, trailing-dot hosts normalized, and path percent-encoding normalized per RFC 3986. Use it to build host allowlists, deduplicate URLs, or compare a fetch target so an allowlist check can't be bypassed by encoding the same address a different way.
12
14
 
13
15
  - v0.15.5 (2026-06-12) — **Legal-hold and subject-restriction PII is sealed at rest, and a guard's compliance-posture forensic and runtime caps are applied on its default gate.** This release closes two data-protection gaps. The legal-hold registry and the subject-restriction flag stored their free-text fields - the legal basis, custodian, ticket citation, and restriction reason that link a data subject to a legal matter - in clear, because those local tables were written through a raw SQL path that bypassed the structured builder's at-rest sealing. Those columns are now sealed on write and unsealed on read, the same way the DSR ticket store already seals subject identifiers. Separately, a content guard built on the standard gate contract and gated with a compliance posture (for example b.guardCidr.gate({ compliancePosture: "hipaa" })) silently dropped that posture's forensic-snapshot cap and the profile's runtime cap, because the default gate passed the caller's raw options straight to the gate builder instead of resolving the profile and posture first - so a regulated-posture refusal carried no forensic evidence. The default gate now resolves the profile and posture before building the gate, matching the hand-written guard gates. **Security:** *Legal-hold and subject-restriction PII is sealed at rest* — `b.legalHold`'s `_blamejs_legal_hold` registry stored the hold reason, custodian, placed-by, and citation in clear, and `b.subject.restrict`'s `_blamejs_subject_restrictions` stored the restriction reason in clear - free text that ties a data subject to a litigation hold or an Art. 18 processing restriction. Those rows were written through a raw `sql.insert` + `prepare().run()` path that bypassed the structured builder's automatic field sealing (the subject-restrictions table even declared the field as sealed, but the raw write never applied it). Both now seal those columns on write and unseal on read through `cryptoField`, so the legal-basis and custodian text is encrypted at rest under the deployment's vault key. Pre-existing plaintext rows continue to read correctly (the unseal path passes through an already-plaintext value). · *A guard's default gate applies its compliance-posture forensic and runtime caps* — A guard built on `b.gateContract.defineGuard` with the standard gate (no bespoke gate) and gated with a compliance posture dropped that posture's `forensicSnippetBytes` cap and the profile's `maxRuntimeMs` cap: the default gate passed the caller's raw options straight to the gate builder, which reads those caps directly, but the values live on the resolved profile and posture, not the raw options. The effect was that a regulated-posture refusal captured no forensic snapshot (the cap defaulted to 0, i.e. disabled) and the check ran without the profile's runtime bound. The default gate now resolves the profile and posture before building the gate - matching the hand-written guard gates - so `gate({ compliancePosture: "hipaa" })` applies the posture's forensic cap and the profile's runtime cap as documented.
@@ -14,6 +14,18 @@ The framework has no `deprecate()`-marked surface awaiting removal.
14
14
 
15
15
  Listed newest-first.
16
16
 
17
+ ### v0.15.7 — `b.auth.oauth verifyIdToken — azp (authorized party) is now enforced`
18
+
19
+ verifyIdToken now applies OIDC Core 3.1.3.7: a multi-audience ID token (aud is an array with more than one entry) MUST carry an azp claim, and a present azp MUST equal the configured client_id. A token whose azp is a different client, or a multi-audience token with no azp, now throws (auth-oauth/azp-mismatch / auth-oauth/azp-required). Previously only `aud contains client_id` was checked, so a token authorized for a different party but also listing this RP verified clean.
20
+
21
+ No change for the common single-audience ID token with no azp. If your IdP issues multi-audience ID tokens, ensure it sets azp to your client_id (it should, per the spec) — otherwise verifyIdToken will now reject them. This is a security fix; a token that fails the new check was authorized for a different client.
22
+
23
+ ### v0.15.7 — `b.safeUrl.canonicalize — IPv4-mapped hosts fold to IPv4`
24
+
25
+ b.safeUrl.canonicalize / b.ssrfGuard.canonicalizeHost now fold an IPv4-mapped IPv6 host (::ffff:1.2.3.4) to its embedded IPv4 dotted form, and strip every trailing dot from a host. In 0.15.6 it canonicalized to an IPv6 string and only one trailing dot was stripped. NAT64 / 6to4 hosts stay IPv6.
26
+
27
+ No code change is needed — this makes a dual-stack / NAT64 peer unify with a dotted-IPv4 allow/deny entry as intended. If you persisted canonical host strings produced by 0.15.6 (e.g. as cache or dedup keys) and compare them against freshly-canonicalized hosts, recompute them: an IPv4-mapped host now yields the dotted IPv4 instead of the bracketed IPv6, and a multi-trailing-dot host yields the bare name.
28
+
17
29
  ### v0.15.6 — `b.auth.sdJwtVc — ES256 / ES384 signatures are now JOSE raw r||s, not DER`
18
30
 
19
31
  `b.auth.sdJwtVc` now signs and verifies ES256 / ES384 with `dsaEncoding: "ieee-p1363"` (raw r||s), the encoding JOSE / JWS and EUDI wallets require. Previously it used node:crypto's default DER ECDSA encoding, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The signature bytes change shape (64 bytes for ES256, 96 for ES384, no leading `0x30` SEQUENCE tag).
@@ -340,7 +340,7 @@ This is the minimum-viable security posture for a production deployment. The fra
340
340
  - [ ] For inbound admin paths reachable on the public network: mount `b.middleware.networkAllowlist({ paths: ["/admin"], allowedCidrs: [...] })` as the in-process CIDR fence above the application-layer auth gate
341
341
  - [ ] For outbound integrations: pin destination hosts via `b.httpClient.request({ allowedHosts: ["api.partner.com", ".internal.example.com"] })` so a compromised process can't reach arbitrary upstreams
342
342
  - [ ] For test suites that mount a mock server on `127.0.0.1` to exercise `b.httpClient` / `b.wsClient` against deterministic fixtures: keep the SSRF gate ON in production code, then in tests inject an explicit `allowInternal: true` (per-call) alongside the mock-server URL. The opt-in is loud and audited, the production posture is unchanged, and operators reviewing the test grep see exactly which call sites talk to internal addresses. Cloud-metadata IPs (`169.254.169.254` etc.) stay hard-deny under any `allowInternal` value
343
- - [ ] Before comparing a URL against an allowlist/denylist, building a cache/dedup key from one, or deciding whether a host is internal: canonicalize it with `b.safeUrl.canonicalize(url)` (or `b.ssrfGuard.canonicalizeHost(host)` for the host alone) first. It collapses obfuscated host + IP spellings — decimal / octal / hex / dotted IPv4, IPv4-mapped and zero-compressed IPv6, IDN → punycode (rejecting confusable hosts), default ports, trailing-dot hosts, path percent-encoding — to one comparable string, so `0177.0.0.1`, `0x7f.1`, `2130706433`, and `127.0.0.1` can't slip past a check that only string-matched the literal. Pair it with the `b.ssrfGuard` DNS-resolving gate, which still runs at fetch time
343
+ - [ ] Before comparing a URL against an allowlist/denylist, building a cache/dedup key from one, or deciding whether a host is internal: canonicalize it with `b.safeUrl.canonicalize(url)` (or `b.ssrfGuard.canonicalizeHost(host)` for the host alone) first. It collapses obfuscated host + IP spellings — decimal / octal / hex / dotted IPv4, zero-compressed IPv6, IDN → punycode (rejecting confusable hosts), default ports, trailing-dot hosts (every count), path percent-encoding — to one comparable string, so `0177.0.0.1`, `0x7f.1`, `2130706433`, and `127.0.0.1` can't slip past a check that only string-matched the literal. An IPv4-mapped IPv6 host (`::ffff:1.2.3.4`) folds to its embedded IPv4 so a dual-stack peer unifies with a dotted-IPv4 entry, and credentials are dropped from the canonical form (NAT64 / 6to4 stay IPv6 so canonicalize-then-classify agrees with the SSRF classifier). Pair it with the `b.ssrfGuard` DNS-resolving gate, which still runs at fetch time
344
344
  - [ ] For file-upload routes: gate on magic bytes via `b.fileType.assertOneOf(buffer, ["image", "application/pdf"])` — never trust the client-supplied `Content-Type` alone
345
345
  - [ ] For routes that emit or accept CSV (operator exports, user-supplied uploads, mail attachments, object-store deliverables): wire `b.guardCsv.gate({ profile: "strict" })` into `b.staticServe.create({ contentSafety: { ".csv": gate } })` and `b.fileUpload.create({ contentSafety: gate })` — strict profile applies the OWASP-recommended `prefix-tab` formula-injection mitigation, the dangerous-function denylist (HYPERLINK / WEBSERVICE / IMAGE / IMPORT* / RTD / DDE / CALL), bidi / homoglyph / control / null-byte / BOM detection, dialect-ambiguity refusal, and CSV-bomb size caps; pick `compliancePosture: "hipaa" | "pci-dss" | "gdpr" | "soc2"` instead of (or layered over) the profile when the workload is regulated
346
346
  - [ ] For routes that accept YAML (config uploads, CI/CD pipelines, infra-as-code, document-import flows — ANY operator-supplied YAML the server parses): `b.guardYaml.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.12. For inbound YAML bodies that don't go through those primitives, wire `b.guardYaml.parse(body, { profile: "strict" })` before passing the parsed structure to operator handlers — strict refuses deserialization-tag RCE (defends CVE-2026-24009 Docling/PyYAML, CVE-2022-1471 SnakeYAML, CVE-2017-18342 PyYAML class), billion-laughs alias recursion (CVE-2026-27807 MarkUs class), Norway-problem implicit booleans, multi-document streams, leading-zero octals, duplicate keys, merge-key anchor-chains, bidi/null/control chars. Unlike JSON, YAML's threat surface includes language-specific deserialization triggers — `!!python/object/new:...` / `!!java.util.HashMap` / `!!ruby/object` etc. — which the source-level scan catches before any downstream parser (PyYAML / SnakeYAML / js-yaml) sees them
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.15.6",
4
- "createdAt": "2026-06-13T07:00:22.197Z",
3
+ "frameworkVersion": "0.15.7",
4
+ "createdAt": "2026-06-13T15:59:24.245Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -19,8 +19,12 @@
19
19
  * iat / exp / jti
20
20
  *
21
21
  * Tokens are revocable: revoke(jti) adds the jti to an in-process
22
- * deny-set checked by verify(). Operators with multi-node clusters
23
- * pass `revokedSet` opt to back the deny-set with their own KV.
22
+ * deny-set checked by verify(). Revocation is in-process only and does
23
+ * NOT propagate across cluster nodes a jti revoked on one node is not
24
+ * denied on another. On a multi-node deployment, keep grant TTLs short
25
+ * (the iat/exp window is the cross-node bound) and route revocation
26
+ * through a shared, externally-checked store of your own; this module
27
+ * does not yet accept a backing revoked-set.
24
28
  *
25
29
  * Token format: base64url(JSON-payload) + "." + base64url(HMAC).
26
30
  *
@@ -1896,6 +1896,19 @@ function create(opts) {
1896
1896
  throw new OAuthError("auth-oauth/aud-mismatch",
1897
1897
  "ID token aud does not contain clientId '" + clientId + "'");
1898
1898
  }
1899
+ // OIDC Core §3.1.3.7: a multi-audience ID token MUST carry an azp
1900
+ // (authorized party), and a present azp MUST equal our client_id.
1901
+ // Without this, a token whose authorized party is a DIFFERENT client but
1902
+ // whose aud array also lists this RP would verify clean — a confused-deputy
1903
+ // / token-substitution hole.
1904
+ if (aud.length > 1 && typeof payload.azp !== "string") {
1905
+ throw new OAuthError("auth-oauth/azp-required",
1906
+ "ID token has multiple audiences but no azp (authorized party) claim");
1907
+ }
1908
+ if (payload.azp !== undefined && payload.azp !== clientId) {
1909
+ throw new OAuthError("auth-oauth/azp-mismatch",
1910
+ "ID token azp '" + payload.azp + "' is not clientId '" + clientId + "'");
1911
+ }
1899
1912
  if (vopts.nonce && !vopts.skipNonceCheck) {
1900
1913
  // Constant-time nonce compare — secret-shaped value matched
1901
1914
  // against attacker-controlled payload.
@@ -638,11 +638,14 @@ async function verify(presentation, opts) {
638
638
  jwtExternal._assertAlgKtyMatch(kbAlg, holderKey);
639
639
  var holderKeyObj = nodeCrypto.createPublicKey({ key: holderKey, format: "jwk" });
640
640
  var kbParsed = _verifyJwt(maybeKbJwt, holderKeyObj, kbAlg);
641
- if (opts.audience && kbParsed.payload.aud !== opts.audience) {
641
+ // Constant-time compares: the nonce is a verifier-issued replay-defense
642
+ // value, so a short-circuiting !== leaks a matching-prefix timing oracle.
643
+ // Matches the sd_hash check below (the framework's hash/token discipline).
644
+ if (opts.audience && !_timingSafeEqStr(kbParsed.payload.aud, opts.audience)) {
642
645
  throw new AuthError("auth-sd-jwt-vc/wrong-audience",
643
646
  "verify: KB-JWT aud mismatch");
644
647
  }
645
- if (opts.nonce && kbParsed.payload.nonce !== opts.nonce) {
648
+ if (opts.nonce && !_timingSafeEqStr(kbParsed.payload.nonce, opts.nonce)) {
646
649
  throw new AuthError("auth-sd-jwt-vc/wrong-nonce",
647
650
  "verify: KB-JWT nonce mismatch (replay defense)");
648
651
  }
@@ -594,6 +594,10 @@ function clear() {
594
594
  }
595
595
  STATE.posture = null;
596
596
  STATE.setAt = null;
597
+ // Cascade the reset the same way set() cascades the posture — otherwise a
598
+ // primitive that inherits the active posture (e.g. retention.complianceFloor)
599
+ // keeps applying the stale floor after the global posture was cleared.
600
+ _applyPostureCascade(null);
597
601
  }
598
602
 
599
603
  function _resetForTest() {
@@ -380,6 +380,15 @@ function needsRehash(envelope, opts) {
380
380
  try { return passwordModule().needsRehash(phc, opts && opts.params); }
381
381
  catch (_e) { return true; }
382
382
  }
383
+ if (decoded.algoId === C.CRED_HASH_IDS.SHAKE256) {
384
+ // Length-rotation: rehash when the stored digest is SHORTER than the
385
+ // configured/default output length. Upgrade-only (`<`, matching the Argon2
386
+ // needsRehash convention) — a longer-than-target digest is not actively
387
+ // shortened. Without this compare, raising the SHAKE256 length never
388
+ // triggered a rehash and the advertised rotation was a silent no-op.
389
+ var targetLength = (opts && opts.params && opts.params.length) || SHAKE256_DEFAULT_LENGTH;
390
+ if (decoded.payload.length < targetLength) return true;
391
+ }
383
392
  return false;
384
393
  }
385
394
 
@@ -614,9 +614,16 @@ var COMPLIANCE_RETENTION_FLOOR_MS = Object.freeze({
614
614
  * // → 220752000000 (Sarbanes-Oxley §802 — 7 years)
615
615
  */
616
616
  function complianceFloor(posture, candidateTtlMs) {
617
+ // Optional posture: omit it to inherit the active posture recorded by
618
+ // applyPosture (the b.compliance.set cascade). A numeric first argument is
619
+ // taken as candidateTtlMs so complianceFloor(ttl) works; an explicit posture
620
+ // always overrides the active one.
621
+ if (typeof posture === "number") { candidateTtlMs = posture; posture = undefined; }
622
+ if (posture === undefined || posture === null) { posture = STATE.activePosture; }
617
623
  if (typeof posture !== "string") {
618
624
  throw new RetentionError("retention/bad-posture",
619
- "complianceFloor: posture must be a string, got " + JSON.stringify(posture));
625
+ "complianceFloor: posture must be a string (pass one, or set the active " +
626
+ "posture via applyPosture / b.compliance.set), got " + JSON.stringify(posture));
620
627
  }
621
628
  var floor = COMPLIANCE_RETENTION_FLOOR_MS[posture];
622
629
  if (floor === undefined) {
@@ -660,7 +667,14 @@ function complianceFloor(posture, candidateTtlMs) {
660
667
  * // → "hipaa"
661
668
  */
662
669
  function applyPosture(posture) {
663
- if (typeof posture !== "string" || posture.length === 0) return null;
670
+ if (typeof posture !== "string" || posture.length === 0) {
671
+ // Clear the active posture (the inverse of a set) so b.compliance.clear
672
+ // and operators can reset the inherited floor; complianceFloor then falls
673
+ // back to requiring an explicit posture again.
674
+ STATE.activePosture = null;
675
+ STATE.activeFloorMs = null;
676
+ return null;
677
+ }
664
678
  var floor = COMPLIANCE_RETENTION_FLOOR_MS[posture];
665
679
  STATE.activePosture = posture;
666
680
  STATE.activeFloorMs = (typeof floor === "number") ? floor : null;
@@ -488,6 +488,10 @@ function create(opts) {
488
488
  lastError: null,
489
489
  running: false,
490
490
  runningSince: 0,
491
+ // Monotonic run tag. The watchdog and each fire bump it, so a run the
492
+ // watchdog abandoned can't clobber state / emit a stale settle event
493
+ // when its slow promise finally resolves.
494
+ runGeneration: 0,
491
495
  fires: 0,
492
496
  misses: 0, // skipped because previous run still in-flight
493
497
  nonLeaderSkips: 0,
@@ -540,6 +544,8 @@ function create(opts) {
540
544
  (maxJobMs / C.TIME.seconds(1)) + "s — forcing reset");
541
545
  } catch (_e) { /* logger best-effort */ }
542
546
  _emit("system.scheduler.task.watchdog", { name: task.name }, "failure");
547
+ // Supersede the abandoned run so its late settle is ignored.
548
+ task.runGeneration++;
543
549
  task.running = false;
544
550
  } else {
545
551
  task.misses++;
@@ -665,6 +671,10 @@ function create(opts) {
665
671
  task.runningSince = Date.now();
666
672
  task.lastRun = new Date().toISOString();
667
673
  var startedAt = Date.now();
674
+ // Tag this run. The settle handlers below only write back if the tag still
675
+ // matches — so a run the watchdog reset (or a newer fire) can't clobber the
676
+ // current run's state or emit a stale success/failure when it settles late.
677
+ var gen = (task.runGeneration = (task.runGeneration || 0) + 1);
668
678
 
669
679
  var promise;
670
680
  try {
@@ -678,6 +688,7 @@ function create(opts) {
678
688
  }
679
689
 
680
690
  Promise.resolve(promise).then(function (_v) {
691
+ if (task.runGeneration !== gen) return; // watchdog/newer fire superseded this run
681
692
  task.running = false;
682
693
  task.runningSince = 0;
683
694
  task.lastFinish = new Date().toISOString();
@@ -689,6 +700,7 @@ function create(opts) {
689
700
  viaJob: !!task.job,
690
701
  });
691
702
  }, function (e) {
703
+ if (task.runGeneration !== gen) return; // watchdog/newer fire superseded this run
692
704
  task.running = false;
693
705
  task.runningSince = 0;
694
706
  task.lastFinish = new Date().toISOString();
@@ -335,14 +335,32 @@ function canonicalizeHost(host) {
335
335
  return bare.toLowerCase();
336
336
  }
337
337
  if (family === 6) {
338
- return _ipv6BytesToString(_ipv6ToBytes(bare));
339
- }
340
- // Not an IP literal DNS name. Lowercase + strip a single trailing dot
341
- // (the root-label dot is DNS-equivalent but breaks string comparison).
342
- var name = bare.toLowerCase();
343
- if (name.length > 1 && name.charAt(name.length - 1) === ".") {
344
- name = name.slice(0, name.length - 1);
338
+ var v6bytes = _ipv6ToBytes(bare);
339
+ // An IPv4-mapped IPv6 address (::ffff:a.b.c.d, the ::ffff:0:0/96 block) IS
340
+ // the IPv4 address a.b.c.d for routing / access control classify() already
341
+ // re-classifies it by the embedded v4, and a dual-stack peer arriving on
342
+ // ::ffff:1.2.3.4 reaches the same host as 1.2.3.4. Fold it to the dotted
343
+ // IPv4 form so a dual-stack peer and an operator's IPv4 allowlist entry
344
+ // canonicalize equal. ONLY the IPv4-mapped block (::ffff:0:0/96) folds,
345
+ // because classify(::ffff:x) === classify(x) — its classify branch returns
346
+ // the embedded-v4 verdict with no reserved fallback, so folding can't change
347
+ // an SSRF verdict. NAT64 (64:ff9b::/96) and 6to4 (2002::/16) are NOT folded:
348
+ // classify treats a NAT64 literal as `classify(v4) || "reserved"`, so a
349
+ // public NAT64 address classifies as "reserved" while its embedded v4 is
350
+ // null — folding would flip a blocked verdict to an allowed public IPv4.
351
+ // classify still reaches the embedded v4 for the deny side; the canonical
352
+ // form keeps NAT64 / 6to4 as IPv6 so canonicalize-then-classify agrees with
353
+ // classify alone.
354
+ if (_ipv6PrefixMatch(IPV6_V4_MAPPED_PREFIX, C.BYTES.bytes(96), v6bytes)) {
355
+ return v6bytes[12] + "." + v6bytes[13] + "." + v6bytes[14] + "." + v6bytes[15];
356
+ }
357
+ return _ipv6BytesToString(v6bytes);
345
358
  }
359
+ // Not an IP literal — DNS name. Lowercase + strip ALL trailing dots: a
360
+ // hostname's trailing-dot count is not significant for identity (the root
361
+ // label is empty), so host / host. / host.. must collapse to one form or a
362
+ // trailing-dot count bypasses a host allow/deny comparison.
363
+ var name = bare.toLowerCase().replace(/\.+$/, "");
346
364
  return name;
347
365
  }
348
366
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.6",
3
+ "version": "0.15.7",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.15.7",
4
+ "date": "2026-06-13",
5
+ "headline": "Hardens the new URL canonicalizer against an IPv4-mapped allowlist bypass, enforces the OIDC azp authorized-party check, and closes a set of audited correctness gaps in retention, credential rehashing, the scheduler, and SD-JWT key binding",
6
+ "summary": "This release deepens the URL/host canonicalizer shipped in 0.15.6 and clears a batch of audited correctness gaps. The canonicalizer now folds an IPv4-mapped IPv6 address (::ffff:1.2.3.4) to its embedded IPv4 and strips every trailing dot from a host, so a dual-stack peer can no longer slip past an operator's dotted-IPv4 allowlist and host./host.. no longer evade a host comparison. (NAT64 and 6to4 hosts are deliberately kept as IPv6 so canonicalizing then classifying agrees with the SSRF classifier, which treats them as reserved.) On the OIDC side, verifyIdToken now enforces OIDC Core 3.1.3.7: a multi-audience ID token must carry an azp (authorized party) and a present azp must equal the client_id, closing a confused-deputy hole where a token minted for a different client but listing this RP in its audience array verified clean. The rest are audited fixes: retention.complianceFloor now honors the active posture set via applyPosture (the documented inheritance was unimplemented); credentialHash.needsRehash now drives the advertised SHAKE256 length-rotation (raising the output length now triggers a rehash, upgrade-only); the task scheduler no longer lets a run abandoned by its watchdog clobber the next run's state or emit a stale success when its slow promise settles late; and the SD-JWT key-binding JWT compares its audience and nonce in constant time.",
7
+ "sections": [
8
+ {
9
+ "heading": "Security",
10
+ "items": [
11
+ {
12
+ "title": "The URL canonicalizer folds IPv4-mapped IPv6 addresses to IPv4",
13
+ "body": "`b.safeUrl.canonicalize` / `b.ssrfGuard.canonicalizeHost` now fold an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`, the `::ffff:0:0/96` block) to its embedded IPv4 dotted form. Previously it canonicalized to an IPv6 string, so a dual-stack peer never unified with an operator's `1.2.3.4` allow/deny entry — an allowlist bypass. Only the IPv4-mapped block folds, because the SSRF classifier maps it to the embedded v4 verdict directly; NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) are deliberately kept as IPv6, since the classifier treats a NAT64 literal as reserved and folding it would turn a blocked verdict into an allowed public IPv4. The host canonicalizer also now strips every trailing dot, so `host`, `host.`, and `host..` collapse to one form."
14
+ },
15
+ {
16
+ "title": "verifyIdToken enforces the OIDC azp (authorized party) check",
17
+ "body": "`b.auth.oauth`'s `verifyIdToken` validated only that its `client_id` was present in the token's `aud`, ignoring `azp`. Per OIDC Core 3.1.3.7, a multi-audience ID token must carry an `azp` and a present `azp` must equal the RP's `client_id`. Without it, a token whose authorized party is a different client — but whose `aud` array also lists this RP — verified clean (a confused-deputy / token-substitution hole). The verifier now rejects a multi-audience token with no `azp` (`auth-oauth/azp-required`) and any token whose `azp` is not the `client_id` (`auth-oauth/azp-mismatch`). A single-audience token with no `azp` (the common case) is unaffected."
18
+ },
19
+ {
20
+ "title": "SD-JWT key-binding audience and nonce compare in constant time",
21
+ "body": "`b.auth.sdJwtVc.verify` compared the key-binding JWT's `aud` and `nonce` with a short-circuiting `!==`, while the adjacent `sd_hash` check already used a constant-time compare. The `nonce` is a verifier-issued replay-defense value, so a non-constant-time compare leaks a matching-prefix timing oracle; both checks now use the constant-time helper."
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "heading": "Fixed",
27
+ "items": [
28
+ {
29
+ "title": "retention.complianceFloor honors the active compliance posture",
30
+ "body": "`b.retention.complianceFloor` required an explicit posture and never read the active posture set by `applyPosture` (the `b.compliance.set` cascade), so the documented inheritance was unimplemented dead state. It now inherits the active posture when none is passed — `complianceFloor(candidateTtlMs)` uses the active posture, an explicit posture still overrides it — and `applyPosture(null)` now clears the active posture (it was a silent no-op, so `b.compliance.clear` could not reset it)."
31
+ },
32
+ {
33
+ "title": "credentialHash.needsRehash drives the SHAKE256 length-rotation",
34
+ "body": "`b.credentialHash.needsRehash` never compared the stored SHAKE256 digest length against the configured length, so raising the output length never triggered a rehash and the advertised length-rotation was a silent no-op. It now flags a digest shorter than the configured/default length for rehash (upgrade-only — a longer-than-target digest is never shortened, matching the Argon2 convention)."
35
+ },
36
+ {
37
+ "title": "The scheduler no longer lets a watchdog-abandoned run corrupt the next run",
38
+ "body": "`b.scheduler`'s watchdog force-clears a task's running flag after `maxJobMs` so a hung handler can't lock out future fires, and the next tick re-fires. The original slow promise then settled late and unconditionally overwrote the task's running / lastFinish / lastError state and emitted a `system.scheduler.task.success`|`failure` for a run the watchdog had already given up on — clobbering the new run and double-counting. Each run is now tagged with a generation that the watchdog and every fire bump; a settle whose tag is stale is ignored."
39
+ }
40
+ ]
41
+ }
42
+ ]
43
+ }
@@ -277,6 +277,22 @@ var OUT_OF_BAND_BREAKS = [
277
277
  "- If you pinned, cached, or asserted on the raw signature bytes of this library's ES256 / ES384 output, update the fixture — the bytes are now `ieee-p1363`. EdDSA / ML-DSA signatures are unchanged.",
278
278
  ].join("\n"),
279
279
  },
280
+ {
281
+ release: "v0.15.7",
282
+ surface: "b.auth.oauth verifyIdToken — azp (authorized party) is now enforced",
283
+ summary: "verifyIdToken now applies OIDC Core 3.1.3.7: a multi-audience ID token (aud is an array with more than one entry) MUST carry an azp claim, and a present azp MUST equal the configured client_id. A token whose azp is a different client, or a multi-audience token with no azp, now throws (auth-oauth/azp-mismatch / auth-oauth/azp-required). Previously only `aud contains client_id` was checked, so a token authorized for a different party but also listing this RP verified clean.",
284
+ migration: [
285
+ "No change for the common single-audience ID token with no azp. If your IdP issues multi-audience ID tokens, ensure it sets azp to your client_id (it should, per the spec) — otherwise verifyIdToken will now reject them. This is a security fix; a token that fails the new check was authorized for a different client.",
286
+ ].join("\n"),
287
+ },
288
+ {
289
+ release: "v0.15.7",
290
+ surface: "b.safeUrl.canonicalize — IPv4-mapped hosts fold to IPv4",
291
+ summary: "b.safeUrl.canonicalize / b.ssrfGuard.canonicalizeHost now fold an IPv4-mapped IPv6 host (::ffff:1.2.3.4) to its embedded IPv4 dotted form, and strip every trailing dot from a host. In 0.15.6 it canonicalized to an IPv6 string and only one trailing dot was stripped. NAT64 / 6to4 hosts stay IPv6.",
292
+ migration: [
293
+ "No code change is needed — this makes a dual-stack / NAT64 peer unify with a dotted-IPv4 allow/deny entry as intended. If you persisted canonical host strings produced by 0.15.6 (e.g. as cache or dedup keys) and compare them against freshly-canonicalized hosts, recompute them: an IPv4-mapped host now yields the dotted IPv4 instead of the bracketed IPv6, and a multi-trailing-dot host yields the bare name.",
294
+ ].join("\n"),
295
+ },
280
296
  {
281
297
  release: "v0.15.6",
282
298
  surface: "b.auth.oauth verifyIdToken — skipExpCheck is restricted to logout tokens",
@@ -7557,6 +7557,57 @@ async function testOAuthVerifyIdTokenRoundTrip() {
7557
7557
  var agedOk = await oa.verifyIdToken(agedLogout, { skipExpCheck: true, skipNonceCheck: true, maxAgeSec: 1200 });
7558
7558
  check("#137 verifyIdToken: the configured maxAgeSec widens the iat freshness window",
7559
7559
  agedOk && agedOk.claims && agedOk.claims.sub === "user-1");
7560
+
7561
+ // #134 — OIDC Core §3.1.3.7: an ID token minted for a DIFFERENT authorized
7562
+ // party that merely lists this RP in a multi-audience array must be
7563
+ // rejected. verifyIdToken checked only that aud contains clientId; without
7564
+ // the azp check that is a confused-deputy hole.
7565
+ var multiAudWrongAzp = _signRs256({
7566
+ iss: issuerUrl, sub: "user-1", aud: [clientId, "other-client"],
7567
+ azp: "other-client", exp: nowSec + 3600, iat: nowSec,
7568
+ }, { kid: "test-kid-1" }, kp.privateKey);
7569
+ threw = null;
7570
+ try { await oa.verifyIdToken(multiAudWrongAzp, { skipNonceCheck: true }); } catch (e) { threw = e; }
7571
+ check("#134 verifyIdToken: multi-aud token with azp for a different client is rejected",
7572
+ threw && threw.code === "auth-oauth/azp-mismatch");
7573
+
7574
+ // Multi-aud token with NO azp at all — §3.1.3.7 requires azp present.
7575
+ var multiAudNoAzp = _signRs256({
7576
+ iss: issuerUrl, sub: "user-1", aud: [clientId, "other-client"],
7577
+ exp: nowSec + 3600, iat: nowSec,
7578
+ }, { kid: "test-kid-1" }, kp.privateKey);
7579
+ threw = null;
7580
+ try { await oa.verifyIdToken(multiAudNoAzp, { skipNonceCheck: true }); } catch (e) { threw = e; }
7581
+ check("#134 verifyIdToken: multi-aud token with no azp is rejected",
7582
+ threw && threw.code === "auth-oauth/azp-required");
7583
+
7584
+ // A correct multi-aud token (azp === our clientId) still verifies.
7585
+ var multiAudOk = _signRs256({
7586
+ iss: issuerUrl, sub: "user-1", aud: [clientId, "other-client"],
7587
+ azp: clientId, exp: nowSec + 3600, iat: nowSec,
7588
+ }, { kid: "test-kid-1" }, kp.privateKey);
7589
+ var okMulti = await oa.verifyIdToken(multiAudOk, { skipNonceCheck: true });
7590
+ check("#134 verifyIdToken: multi-aud token with azp = clientId verifies",
7591
+ okMulti && okMulti.claims && okMulti.claims.sub === "user-1");
7592
+
7593
+ // Single-aud with a mismatched azp present is also rejected (azp, when
7594
+ // present, must equal clientId regardless of aud cardinality).
7595
+ var singleAudWrongAzp = _signRs256({
7596
+ iss: issuerUrl, sub: "user-1", aud: clientId, azp: "other-client",
7597
+ exp: nowSec + 3600, iat: nowSec,
7598
+ }, { kid: "test-kid-1" }, kp.privateKey);
7599
+ threw = null;
7600
+ try { await oa.verifyIdToken(singleAudWrongAzp, { skipNonceCheck: true }); } catch (e) { threw = e; }
7601
+ check("#134 verifyIdToken: single-aud token with a foreign azp is rejected",
7602
+ threw && threw.code === "auth-oauth/azp-mismatch");
7603
+
7604
+ // The common single-aud, no-azp token is unaffected.
7605
+ var plainSingle = _signRs256({
7606
+ iss: issuerUrl, sub: "user-1", aud: clientId, exp: nowSec + 3600, iat: nowSec,
7607
+ }, { kid: "test-kid-1" }, kp.privateKey);
7608
+ var okPlain = await oa.verifyIdToken(plainSingle, { skipNonceCheck: true });
7609
+ check("#134 verifyIdToken: single-aud token with no azp still verifies",
7610
+ okPlain && okPlain.claims && okPlain.claims.sub === "user-1");
7560
7611
  } finally { server.close(); }
7561
7612
  }
7562
7613
 
@@ -7034,6 +7034,82 @@ var KNOWN_ANTIPATTERNS = [
7034
7034
  allowlist: [],
7035
7035
  reason: "#137 — verifyIdToken wrapped its exp validation in `if (!vopts.skipExpCheck) { ... }` so any external caller (verifyIdToken is a public API) could pass skipExpCheck: true and verify an EXPIRED id_token clean — token-replay of expired-but-once-valid credentials. skipExpCheck exists only because OIDC Back-Channel Logout 1.0 §2.4 logout tokens carry no exp; the internal verifyBackchannelLogoutToken passes it. The fix flips the branch to `if (vopts.skipExpCheck) { <require the backchannel-logout events claim> + <iat freshness floor> } else { <exp check> }`, so skipExpCheck is refused (auth-oauth/skip-exp-check-not-allowed) on any token lacking the logout event claim and a stale logout token is refused (auth-oauth/logout-token-stale). The detector anchors on the bare negative gate and requires the new refusal code in-file; after the fix the bare gate is gone and the code is present, so it stays silent.",
7036
7036
  },
7037
+ // #130 — scheduler _runFire settle handlers must guard on the run generation.
7038
+ {
7039
+ id: "scheduler-runfire-settle-no-generation-guard",
7040
+ primitive: "scheduler._runFire's promise settle handlers must ignore a stale settle (if task.runGeneration !== gen return) before writing task state / emitting success|failure — the watchdog force-clears running and re-fires, so an abandoned run's late resolve otherwise clobbers the current run's state and double-emits",
7041
+ scanScope: "lib",
7042
+ regex: /Promise\.resolve\(promise\)\.then\(function \([^)]*\)\s*\{\s*task\.running = false/,
7043
+ allowlist: [],
7044
+ reason: "#130 — _runFire attaches success/failure settle handlers that unconditionally wrote task.running=false / runningSince=0 / lastFinish / lastError and emitted system.scheduler.task.success|failure. But the watchdog (maxJobMs) force-clears task.running on a hung run and _fireOnce re-fires, so the original slow promise settles LATE and clobbers the new run's running flag (disabling the watchdog for it / allowing a third concurrent fire) and emits a stale success for a run the watchdog already reported as a watchdog failure. The fix tags each run (task.runGeneration, bumped by _runFire AND the watchdog) and the settle handlers `return` early when the tag is stale. The detector fires while the success handler sets task.running=false with no intervening generation guard and goes silent once the `if (task.runGeneration !== gen) return;` guard precedes it; behavioral guard is scheduler-watchdog-stale-settle.test.js.",
7045
+ },
7046
+ // #121 — retention.complianceFloor must inherit the active posture.
7047
+ {
7048
+ id: "retention-compliancefloor-ignores-active-posture",
7049
+ primitive: "retention.complianceFloor must fall back to STATE.activePosture (set by applyPosture / the b.compliance.set cascade) when no explicit posture is passed — it hard-required a string posture and never read the active value, so the advertised optional-posture inheritance was unimplemented dead state",
7050
+ scanScope: "lib",
7051
+ regex: /function complianceFloor\s*\([^)]*\)\s*\{(?:(?!STATE\.activePosture)[\s\S]){0,300}?must be a string/,
7052
+ allowlist: [],
7053
+ reason: "#121 — applyPosture() records STATE.activePosture + STATE.activeFloorMs and both its docstring and the STATE comment advertise that complianceFloor callers without an explicit posture inherit the active value. But complianceFloor threw `posture must be a string` immediately and never consulted STATE.activePosture, so the inheritance never worked and activeFloorMs was a dead write. The fix inherits STATE.activePosture when posture is omitted (a numeric first arg is taken as candidateTtlMs so complianceFloor(ttl) works), and applyPosture(null) now clears the state (was a silent no-op, so b.compliance.clear couldn't reset it). The tempered span fires while complianceFloor reaches its `must be a string` throw without first reading STATE.activePosture; the behavioral guard is retention-floor.test.js.",
7054
+ },
7055
+ // #111 — credential-hash needsRehash must drive SHAKE256 length-rotation.
7056
+ {
7057
+ id: "credentialhash-needsrehash-ignores-shake256-length",
7058
+ primitive: "credentialHash.needsRehash must compare the stored SHAKE256 digest length against the configured/default length — without it, raising the SHAKE256 output length never triggers a rehash and the advertised length-rotation is a silent no-op",
7059
+ scanScope: "lib",
7060
+ regex: /function needsRehash\s*\([^)]*\)\s*\{(?=[\s\S]*?CRED_HASH_IDS\.ARGON2ID)(?:(?!CRED_HASH_IDS\.SHAKE256)(?!\n\})[\s\S]){0,1200}\n\}/,
7061
+ allowlist: [],
7062
+ reason: "#111 — needsRehash short-circuited the SHAKE256 case to `return false`: it checked the algorithm id and (for Argon2id) deferred to the password module's parameter-lag check, but never compared decoded.payload.length against the configured target length. So once an operator raised the SHAKE256 output length, every existing shorter digest reported needsRehash === false and the length-rotation the primitive advertises never fired (b.apiKey.verify's rehash-on-verify silently kept the old length). The fix adds, for the SHAKE256 branch, a compare of decoded.payload.length to (opts.params.length || SHAKE256_DEFAULT_LENGTH) → rehash when they differ. The tempered span anchors on needsRehash and fires while its body never references SHAKE256_DEFAULT_LENGTH (the length-target constant); the behavioral guard is credential-hash.test.js.",
7063
+ },
7064
+ // #136 — SD-JWT KB-JWT aud / nonce must compare constant-time.
7065
+ {
7066
+ id: "sdjwt-kbjwt-aud-nonce-non-consttime-compare",
7067
+ primitive: "the SD-JWT KB-JWT audience + nonce checks must compare with the constant-time _timingSafeEqStr helper (as the sd_hash check already does), not a bare !== — the nonce is a verifier-issued replay-defense value and a timing channel leaks a guess oracle; constant-time-ness cannot be asserted behaviorally, so this structural detector is the guard",
7068
+ scanScope: "lib",
7069
+ regex: /kbParsed\.payload\.(?:aud|nonce)\s*!==\s*opts\.(?:audience|nonce)/,
7070
+ allowlist: [],
7071
+ reason: "#136 — verify()'s KB-JWT binding checks compared `kbParsed.payload.aud !== opts.audience` and `kbParsed.payload.nonce !== opts.nonce` with a short-circuiting !==, while the adjacent sd_hash check already used the constant-time _timingSafeEqStr. The nonce is a per-presentation replay-defense value the verifier issued; a non-constant-time compare leaks a matching-prefix timing oracle. The fix routes both through _timingSafeEqStr (the framework's hash/token-compare discipline). A behavioral test can prove the accept/reject correctness but NOT the timing property, so this detector is the primary guard per the test-with-fix rule's structural-drift exception; it fires on the bare !== shape and goes silent once both use _timingSafeEqStr.",
7072
+ },
7073
+ // canonicalizeHost must fold an IPv4-mapped IPv6 address to its IPv4 form.
7074
+ {
7075
+ id: "ssrf-canonicalizehost-v4mapped-not-folded",
7076
+ primitive: "ssrfGuard.canonicalizeHost must fold an IPv4-mapped IPv6 address (::ffff:0:0/96) to its dotted IPv4 form — leaving it as IPv6 means a dual-stack peer on ::ffff:1.2.3.4 never unifies with an operator's 1.2.3.4 allowlist entry (an SSRF allowlist bypass the canonicalizer exists to defend)",
7077
+ scanScope: "lib",
7078
+ regex: /if \(family === 6\)\s*\{(?:(?!IPV6_V4_MAPPED_PREFIX)[\s\S]){0,400}?_ipv6BytesToString/,
7079
+ allowlist: [],
7080
+ reason: "canonicalizeHost's IPv6 branch emitted the RFC 5952 hex string for every IPv6 input, including IPv4-mapped (::ffff:a.b.c.d). But an IPv4-mapped address IS the IPv4 address a.b.c.d for routing/access — classify() already re-classifies it by the embedded v4, and a dual-stack listener reaching ::ffff:1.2.3.4 is the same host as 1.2.3.4. Without folding, canonicalize(::ffff:1.2.3.4) !== canonicalize(1.2.3.4), so an allowlist/dedup/SSRF comparison built on the canonical form is bypassed by presenting the dual-stack spelling. The fix folds the ::ffff:0:0/96 block to dotted IPv4 (only that block — 6to4/NAT64 are translation mechanisms, and a v4 suffix in any other prefix is a distinct address). The tempered span fires while the family-6 branch reaches _ipv6BytesToString with no IPV6_V4_MAPPED_PREFIX check first; the behavioral guard is safe-url-canonicalize.test.js.",
7081
+ },
7082
+ // compliance.clear must cascade the posture-clear to the primitives.
7083
+ {
7084
+ id: "compliance-clear-no-cascade",
7085
+ primitive: "compliance.clear() must cascade the posture reset to the primitives (_applyPostureCascade(null)) just as set() cascades the posture — otherwise a primitive that inherits the active posture (retention.complianceFloor) keeps applying the stale floor after b.compliance.clear()",
7086
+ scanScope: "lib",
7087
+ regex: /_emitAudit\("compliance\.posture\.cleared"/,
7088
+ requires: /_applyPostureCascade\(null\)/,
7089
+ skipCommentLines: true,
7090
+ allowlist: [],
7091
+ reason: "Codex P2 — b.compliance.set(posture) calls _applyPostureCascade(posture), which walks retention/audit/db/cryptoField calling applyPosture(posture); retention records it so complianceFloor() inherits it. b.compliance.clear() nulled only compliance's own STATE.posture and never cascaded, so after set(\"hipaa\") then clear(), compliance.current() is null but retention still inherits the stale HIPAA floor. clear() must call _applyPostureCascade(null) so each primitive's applyPosture(null) resets it (retention.applyPosture(null) clears its active posture). The detector fires while clear() exists with no _applyPostureCascade(null) call anywhere in the file (set() passes the posture, not null) and goes silent once clear() cascades the reset; the behavioral guard is retention-floor.test.js.",
7092
+ },
7093
+ // canonicalizeHost must NOT fold NAT64/6to4 (would flip an SSRF classify verdict).
7094
+ {
7095
+ id: "ssrf-canonicalizehost-folds-nat64",
7096
+ primitive: "ssrfGuard.canonicalizeHost must fold ONLY the IPv4-mapped block (::ffff:0:0/96) to IPv4 — NOT NAT64 (64:ff9b::/96). classify() treats a NAT64 literal as `classify(v4) || \"reserved\"`, so folding a public NAT64 address to its embedded IPv4 turns a blocked verdict into an allowed one (canonicalize-then-classify must agree with classify alone)",
7097
+ scanScope: "lib",
7098
+ regex: /_ipv6PrefixMatch\(\s*IPV6_NAT64_PREFIX\s*,\s*C\.BYTES\.bytes\(96\)\s*,\s*v6bytes\s*\)/,
7099
+ allowlist: [],
7100
+ reason: "canonicalizeHost folds an IPv4-mapped IPv6 host to its embedded IPv4 because classify(::ffff:x) === classify(x) (that branch has no reserved fallback), so the fold can't change an SSRF verdict. NAT64 is different: classify('64:ff9b::8.8.8.8') is `classify('8.8.8.8') || 'reserved'` = 'reserved' (blocked) while classify('8.8.8.8') is null (allowed) — so folding a public NAT64 literal to 8.8.8.8 before an allowlist/classify check flips a blocked address to an allowed public IPv4 (Codex P2). canonicalizeHost must leave NAT64 / 6to4 as IPv6; classify still reaches the embedded v4 for the deny side. The detector anchors on canonicalizeHost's NAT64 prefix-match (it uses the local `v6bytes`, so classify()'s own legitimate NAT64 extraction — which uses `bytes` — is not matched) and goes silent once canonicalizeHost no longer folds NAT64. The behavioral guard is the classify-agreement invariant in safe-url-canonicalize.test.js.",
7101
+ },
7102
+ // #134 — verifyIdToken must check azp on multi-audience ID tokens.
7103
+ {
7104
+ id: "oauth-verifyidtoken-no-azp-check",
7105
+ primitive: "verifyIdToken must verify the azp (authorized party) claim — OIDC Core §3.1.3.7: a multi-audience ID token requires an azp, and a present azp MUST equal the RP's client_id. Checking only that aud contains clientId lets a token minted for a DIFFERENT authorized party (that merely lists this RP in a multi-aud array) verify clean (confused deputy)",
7106
+ scanScope: "lib",
7107
+ regex: /throw new OAuthError\("auth-oauth\/aud-mismatch"/,
7108
+ requires: /auth-oauth\/azp-mismatch/,
7109
+ skipCommentLines: true,
7110
+ allowlist: [],
7111
+ reason: "#134 — verifyIdToken validated only `aud.indexOf(clientId) !== -1` (throwing auth-oauth/aud-mismatch) and ignored azp. OIDC Core §3.1.3.7 requires: if the ID token carries multiple audiences the Client must verify an azp is present, and if azp is present its value must be the Client's client_id. Without it, an IdP-issued token whose authorized party is a DIFFERENT client but whose aud array also lists this RP verifies clean — a confused-deputy / token-substitution hole. The fix adds, right after the aud check: reject when aud.length > 1 and no azp (auth-oauth/azp-required), and reject when azp is present and !== clientId (auth-oauth/azp-mismatch). The detector anchors on verifyIdToken's unique aud-mismatch throw and requires the azp-mismatch code in-file; the single-aud no-azp token (the common case) is unaffected.",
7112
+ },
7037
7113
  // #116 — crypto-field upgrade-on-read rewrite must honor the handle's dialect.
7038
7114
  {
7039
7115
  id: "cryptofield-upgrade-on-read-hardcodes-sqlite-dialect",
@@ -52,6 +52,24 @@ async function testShake256ConfigurableLength() {
52
52
  check("verify 64-byte envelope", (await ch.verify("hi", env64)) === true);
53
53
  check("verify 192-byte envelope", (await ch.verify("hi", env192)) === true);
54
54
  check("64 envelope can't verify against 192", env64 !== env192);
55
+
56
+ // #111 — needsRehash must drive the advertised SHAKE256 length-rotation: a
57
+ // digest stored at the old length must be flagged for rehash when the
58
+ // configured/default length is larger. needsRehash ignored payload length,
59
+ // so raising the output length was a silent no-op.
60
+ check("#111 a 64-byte digest needs rehash under the 128-byte default",
61
+ ch.needsRehash(env64) === true);
62
+ check("#111 a 64-byte digest needs rehash when the target length is raised to 192",
63
+ ch.needsRehash(env64, { params: { length: 192 } }) === true);
64
+ check("#111 a 64-byte digest does NOT need rehash when the target stays 64",
65
+ ch.needsRehash(env64, { params: { length: 64 } }) === false);
66
+ check("#111 a default-length (128) digest does not need rehash at the default",
67
+ ch.needsRehash(await ch.hash("hi")) === false);
68
+ // Upgrade-only, matching the Argon2 needsRehash convention (rehash when the
69
+ // stored strength is BELOW target, never to actively shorten): a 192-byte
70
+ // digest must NOT be rehashed down to a 128-byte target.
71
+ check("#111 a longer (192) digest is NOT rehashed down to a smaller target (128)",
72
+ ch.needsRehash(env192, { params: { length: 128 } }) === false);
55
73
  }
56
74
 
57
75
  async function testShake256BufferSecret() {
@@ -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
+ }
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.33",
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/",