@blamejs/blamejs-shop 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/checkout.js +125 -1
  4. package/lib/storefront.js +1 -1
  5. package/lib/vendor/MANIFEST.json +2 -2
  6. package/lib/vendor/blamejs/.github/workflows/ci.yml +27 -0
  7. package/lib/vendor/blamejs/.github/workflows/codeql.yml +2 -2
  8. package/lib/vendor/blamejs/.github/workflows/release-container.yml +4 -4
  9. package/lib/vendor/blamejs/.github/workflows/scorecard.yml +1 -1
  10. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  11. package/lib/vendor/blamejs/README.md +1 -0
  12. package/lib/vendor/blamejs/SECURITY.md +2 -0
  13. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  14. package/lib/vendor/blamejs/lib/cra-report.js +3 -3
  15. package/lib/vendor/blamejs/lib/middleware/age-gate.js +20 -7
  16. package/lib/vendor/blamejs/lib/middleware/bearer-auth.js +36 -35
  17. package/lib/vendor/blamejs/lib/middleware/bot-guard.js +17 -5
  18. package/lib/vendor/blamejs/lib/middleware/cors.js +28 -12
  19. package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +22 -14
  20. package/lib/vendor/blamejs/lib/middleware/daily-byte-quota.js +27 -13
  21. package/lib/vendor/blamejs/lib/middleware/deny-response.js +140 -0
  22. package/lib/vendor/blamejs/lib/middleware/dpop.js +32 -19
  23. package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +21 -12
  24. package/lib/vendor/blamejs/lib/middleware/host-allowlist.js +19 -8
  25. package/lib/vendor/blamejs/lib/middleware/index.js +3 -0
  26. package/lib/vendor/blamejs/lib/middleware/network-allowlist.js +24 -10
  27. package/lib/vendor/blamejs/lib/middleware/rate-limit.js +22 -5
  28. package/lib/vendor/blamejs/lib/middleware/require-aal.js +25 -10
  29. package/lib/vendor/blamejs/lib/middleware/require-auth.js +32 -16
  30. package/lib/vendor/blamejs/lib/middleware/require-bound-key.js +49 -18
  31. package/lib/vendor/blamejs/lib/middleware/require-content-type.js +19 -8
  32. package/lib/vendor/blamejs/lib/middleware/require-methods.js +17 -7
  33. package/lib/vendor/blamejs/lib/middleware/require-mtls.js +27 -14
  34. package/lib/vendor/blamejs/lib/network.js +4 -4
  35. package/lib/vendor/blamejs/package.json +1 -1
  36. package/lib/vendor/blamejs/release-notes/v0.14.6.json +60 -0
  37. package/lib/vendor/blamejs/scripts/check-actions-currency.js +256 -0
  38. package/lib/vendor/blamejs/scripts/release.js +11 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +138 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +228 -0
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.9 (2026-05-30) — **Automatic discounts now apply at checkout.** The automatic-discount rules you define in the admin console — an amount or percentage off a cart that meets a threshold — now reduce the order total at checkout. When a rule matches, the discount is applied (capped so it never exceeds the order subtotal) and shown in the quote the shopper pays; when no rule matches, or none are defined, the total is exactly as before, so a store that has not set up rules is unaffected. The admin screen for managing rules already shipped; this wires it through to the checkout quote and the charge. **Added:** *Automatic cart discounts at checkout* — When a cart matches a configured automatic-discount rule, the checkout reduces the order total by the rule's saving — capped to the order subtotal so the total is never negative — and the reduced amount is what the shopper is charged. With no matching rule, or no rules defined, the order total is unchanged, so the change stays invisible until you set rules up; any rule-engine error degrades to no discount rather than blocking checkout.
12
+
13
+ - v0.3.8 (2026-05-30) — **Update the vendored blamejs runtime to v0.14.6.** Refreshes the vendored blamejs runtime from v0.14.5 to v0.14.6, a maintenance update. The createApp request-lifecycle security stack the shop composes — CSRF, fetch-metadata, body-parser, cookies, rate limiting — is unchanged in default behavior. v0.14.6 adds opt-in RFC 9457 problem+json and onDeny response hooks to the access-refusal middleware (defaults unchanged, so nothing the shop relies on shifts) and corrects a few internal export and bearer-scope paths. No operator action is required. **Changed:** *Vendored blamejs runtime updated to v0.14.6* — The pinned blamejs runtime moves from v0.14.5 to v0.14.6. The middleware the storefront and admin compose keeps its current default behavior; the release adds opt-in problem-details / onDeny response hooks to the refusal middleware and fixes internal export and bearer-scope paths. No behavior change for the shop.
14
+
11
15
  - v0.3.7 (2026-05-30) — **Shipping zones now apply at checkout.** The shipping zones you define in the admin console — per-region rates by destination, parcel weight, and order value — now drive the shipping options shown at checkout. When a cart's destination matches a zone, the shopper sees that zone's rates; when no zone matches, or none are defined, checkout falls back to the existing flat-rate table exactly as before, so a store that has not configured zones is entirely unaffected. A fully digital order is never quoted physical shipping. The admin screen for managing zones already shipped; this wires it through to the checkout quote. **Added:** *Zone-based shipping rates at checkout* — When a cart's destination matches a configured shipping zone, the checkout shipping quote uses that zone's rates — resolved by destination country and region, parcel weight, and order subtotal — instead of the flat-rate table. With no matching zone, or no zones defined, checkout returns the existing flat rates unchanged, so the change stays invisible until you set zones up; any lookup error degrades to the same flat fallback. A fully digital order (no shipping-requiring item) is never quoted physical shipping.
12
16
 
13
17
  - v0.3.6 (2026-05-30) — **Update the vendored blamejs runtime to v0.14.5.** Refreshes the vendored blamejs runtime from v0.13.46 to v0.14.5, a maintenance update. The request-lifecycle security stack the storefront and admin depend on — CSRF, fetch-metadata, body-parser, cookies, and rate limiting — is unchanged across this range. The runtime's breaking changes in this window (stricter OAuth JAR request-object typing and a PGP signature return type) are in surfaces this shop does not use, so there is no behavior change and no operator action is required. **Changed:** *Vendored blamejs runtime updated to v0.14.5* — The pinned blamejs runtime moves from v0.13.46 to v0.14.5, picking up the framework's maintenance and hardening work across that range. The createApp request-lifecycle middleware the shop composes (CSRF, fetch-metadata, body-parser, cookie, and rate-limit layers) is unchanged, so this refresh carries no behavior change for the storefront or admin console.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.7",
2
+ "version": "0.3.9",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/checkout.js CHANGED
@@ -146,6 +146,17 @@ function create(deps) {
146
146
  // server-authoritative computation the cart page renders, so the
147
147
  // "review your order" subtotal and the charged amount agree.
148
148
  var quantityDiscounts = deps.quantityDiscounts || null;
149
+ // Optional automatic-discount engine. When wired, the quote consults
150
+ // every operator-authored auto-discount rule against the resolved
151
+ // cart and reduces the subtotal by the matched savings (clamped to
152
+ // [0, subtotal] so a rule can never drive the order negative or
153
+ // charge more than the cart is worth). Disabled when absent — the
154
+ // discount is zero and every total is identical to the un-wired
155
+ // flow, so production stays unchanged until a rule is defined. Only
156
+ // subtotal-reducing savings feed `discount_minor`; a `free_shipping`
157
+ // rule's savings are a shipping concern and are left to the shipping
158
+ // primitive, not folded into the subtotal discount here.
159
+ var autoDiscount = deps.autoDiscount || null;
149
160
 
150
161
  // Reprice a list of cart lines through the quantity-discount engine.
151
162
  // Returns a shallow copy with `unit_amount_minor` overwritten by the
@@ -179,6 +190,72 @@ function create(deps) {
179
190
  return out;
180
191
  }
181
192
 
193
+ // Resolve the automatic-discount effect for a priced cart. Builds
194
+ // the engine's cart shape from the resolved lines + subtotal, asks
195
+ // the engine which operator-authored rules apply, and returns the
196
+ // subtotal-reducing savings (clamped to [0, subtotal]) plus the
197
+ // per-rule breakdown the post-commit recorder replays.
198
+ //
199
+ // FALLBACK (zero regression): no engine wired, a non-finite or
200
+ // negative aggregate, an empty result, or ANY throw collapses to
201
+ // `{ discount_minor: 0, applied: [] }` — byte-identical to the
202
+ // un-wired flow. The engine is consulted but never trusted to keep
203
+ // the buy path alive.
204
+ //
205
+ // PAYMENT SAFETY: the returned discount is clamped to
206
+ // [0, subtotalMinor] so it can never be negative and never exceeds
207
+ // the subtotal — the order total can therefore never go negative,
208
+ // and a customer can never be charged less than zero or credited
209
+ // more than the cart is worth.
210
+ async function _resolveAutoDiscount(lines, subtotalMinor, customerId) {
211
+ if (!autoDiscount || typeof autoDiscount.evaluate !== "function") {
212
+ return { discount_minor: 0, applied: [] };
213
+ }
214
+ try {
215
+ var evalLines = [];
216
+ for (var i = 0; i < lines.length; i += 1) {
217
+ var l = lines[i];
218
+ evalLines.push({
219
+ sku: l.sku,
220
+ quantity: l.qty,
221
+ unit_price_minor: l.unit_amount_minor,
222
+ });
223
+ }
224
+ var res = await autoDiscount.evaluate({
225
+ cart: {
226
+ subtotal_minor: subtotalMinor,
227
+ lines: evalLines,
228
+ },
229
+ customer_id: customerId || undefined,
230
+ });
231
+ var appliedRaw = res && Array.isArray(res.applied) ? res.applied : [];
232
+ var total = 0;
233
+ var applied = [];
234
+ for (var j = 0; j < appliedRaw.length; j += 1) {
235
+ var entry = appliedRaw[j];
236
+ // A `free_shipping` rule's savings are a shipping reduction,
237
+ // not a subtotal discount — excluded so the subtotal-level
238
+ // `discount_minor` stays a pure subtotal reduction (the
239
+ // shipping primitive owns the shipping side).
240
+ if (!entry || entry.value_kind === "free_shipping") continue;
241
+ var s = entry.savings_minor;
242
+ if (!Number.isInteger(s) || s <= 0) continue;
243
+ total += s;
244
+ applied.push({ rule_slug: entry.rule_slug, savings_minor: s });
245
+ }
246
+ if (!Number.isFinite(total) || total <= 0) {
247
+ return { discount_minor: 0, applied: [] };
248
+ }
249
+ // Clamp to the subtotal floor: the discount can reduce the
250
+ // subtotal to zero but never past it.
251
+ if (total > subtotalMinor) total = subtotalMinor;
252
+ return { discount_minor: total, applied: applied };
253
+ } catch (_e) {
254
+ // Any engine failure -> no discount, never a 5xx on the buy path.
255
+ return { discount_minor: 0, applied: [] };
256
+ }
257
+ }
258
+
182
259
  // Validate a gift-card code against a priced quote: the card exists,
183
260
  // is active, not expired, and matches the order currency. Returns
184
261
  // the credit to apply (capped at the grand total so an order total
@@ -336,6 +413,33 @@ function create(deps) {
336
413
  });
337
414
  }
338
415
 
416
+ // Record each applied auto-discount rule against a committed order
417
+ // so the engine's redemption counter + event log reflect the use.
418
+ // Best-effort / drop-silent: the customer has already been charged
419
+ // (or the order is otherwise placed) by the time this runs, so a
420
+ // recording failure must NEVER fail or reverse the order. Each
421
+ // record is isolated in its own try/catch so one bad row can't stop
422
+ // the rest. No-op when the engine isn't wired or nothing applied.
423
+ async function _recordAutoDiscounts(quote, orderId, customerId) {
424
+ if (!autoDiscount || typeof autoDiscount.recordApplication !== "function") return;
425
+ var applied = quote && Array.isArray(quote.auto_discounts) ? quote.auto_discounts : [];
426
+ for (var i = 0; i < applied.length; i += 1) {
427
+ var a = applied[i];
428
+ if (!a || !a.rule_slug) continue;
429
+ try {
430
+ await autoDiscount.recordApplication({
431
+ rule_slug: a.rule_slug,
432
+ order_id: orderId,
433
+ savings_minor: a.savings_minor,
434
+ customer_id: customerId || undefined,
435
+ });
436
+ } catch (_e) {
437
+ // drop-silent — by design: the order is already placed; a
438
+ // failed redemption record must not surface to the buyer.
439
+ }
440
+ }
441
+ }
442
+
339
443
  // Compose a quote from a cart + ship-to + (optional) selected
340
444
  // shipping service. Pure read — no DB writes.
341
445
  async function _buildQuote(input) {
@@ -390,10 +494,18 @@ function create(deps) {
390
494
  }
391
495
 
392
496
  var shippingMinor = selected ? selected.amount_minor : 0;
497
+
498
+ // Automatic discounts: consult the operator-authored rule engine
499
+ // (when wired) for the subtotal reduction this cart earns. The
500
+ // resolver clamps the result to [0, subtotal] and falls back to 0
501
+ // on any failure, so the total math below is identical to the
502
+ // un-wired flow whenever no rule applies.
503
+ var autoDisc = await _resolveAutoDiscount(lines, sub.amount_minor, c.customer_id || null);
504
+
393
505
  var totals = pricing.totals(c, lines, {
394
506
  tax_minor: taxRow.tax_minor,
395
507
  shipping_minor: shippingMinor,
396
- discount_minor: 0,
508
+ discount_minor: autoDisc.discount_minor,
397
509
  });
398
510
 
399
511
  return {
@@ -405,6 +517,10 @@ function create(deps) {
405
517
  shipping_rates: ratesRow.services,
406
518
  selected_shipping: selected,
407
519
  totals: totals,
520
+ // Per-rule breakdown that fed `totals.discount_minor`, replayed
521
+ // by confirm()'s post-commit recorder. Empty when no rule
522
+ // applied (or no engine wired).
523
+ auto_discounts: autoDisc.applied,
408
524
  };
409
525
  }
410
526
 
@@ -499,6 +615,10 @@ function create(deps) {
499
615
  });
500
616
  if (gc) await _redeemGiftCard(gc, paidOrder.id);
501
617
  if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id);
618
+ // Best-effort: record the auto-discount redemptions against the
619
+ // placed order. Runs post-commit so a recording failure can't
620
+ // reverse an order that's already paid.
621
+ await _recordAutoDiscounts(quote, paidOrder.id, cartRow.customer_id || null);
502
622
  await cart.setStatus(quote.cart_id, "converted");
503
623
  var settled = await order.transition(paidOrder.id, "mark_paid", {
504
624
  reason: loy && !gc ? "loyalty:full" : "gift_card:full",
@@ -552,6 +672,10 @@ function create(deps) {
552
672
  // guard.
553
673
  if (gc) await _redeemGiftCard(gc, createdOrder.id);
554
674
  if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id);
675
+ // Best-effort: record the auto-discount redemptions against the
676
+ // placed order. Runs post-commit so a recording failure can't
677
+ // reverse or fail an order whose PaymentIntent already exists.
678
+ await _recordAutoDiscounts(quote, createdOrder.id, cartRow.customer_id || null);
555
679
 
556
680
  // Mark the cart converted so a refresh of the storefront
557
681
  // doesn't accidentally re-quote the same cart.
package/lib/storefront.js CHANGED
@@ -7139,7 +7139,7 @@ function mount(router, deps) {
7139
7139
 
7140
7140
  // Compute the cart/checkout totals the shopper sees BEFORE paying.
7141
7141
  // Composes the SAME tax + shipping primitives the charge runs through
7142
- // (via checkout.quote, which prices tax against the post-discount
7142
+ // (via checkout.quote, which prices tax against the pre-discount
7143
7143
  // subtotal + picks shipping rates for the destination), so the
7144
7144
  // displayed grand total agrees with what Stripe is later asked to
7145
7145
  // charge for that destination. Returns:
@@ -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.14.5",
7
- "tag": "v0.14.5",
6
+ "version": "0.14.6",
7
+ "tag": "v0.14.6",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -169,6 +169,33 @@ jobs:
169
169
  # so a transient registry hang doesn't block unrelated releases.
170
170
  run: node scripts/check-vendor-currency.js
171
171
 
172
+ actions-currency:
173
+ # Asserts every SHA-pinned GitHub Action in .github/workflows
174
+ # matches the latest upstream release. A stale action becomes a
175
+ # release blocker HERE, with a ready-to-paste pin line in the
176
+ # report — so it's fixed in the release rather than surfaced
177
+ # after-the-fact by a Dependabot PR.
178
+ name: Actions currency (pinned actions vs upstream latest)
179
+ runs-on: ubuntu-latest
180
+ steps:
181
+ - name: Checkout
182
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
183
+ with:
184
+ persist-credentials: false
185
+ - name: Set up Node
186
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
187
+ with:
188
+ node-version: 24.14.1
189
+ - name: Run actions-currency check
190
+ # Resolves each pinned `uses: owner/repo@<sha> # vX.Y.Z` against
191
+ # the GitHub API (releases/latest, falling back to highest
192
+ # semver tag). Fails on any "stale" finding; API errors stay
193
+ # advisory unless BLAMEJS_ACTIONS_CURRENCY_STRICT=1 so a
194
+ # transient API hang doesn't block unrelated releases.
195
+ env:
196
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
197
+ run: node scripts/check-actions-currency.js
198
+
172
199
  secret-scan:
173
200
  name: Secret scan (gitleaks)
174
201
  runs-on: ubuntu-latest
@@ -32,7 +32,7 @@ jobs:
32
32
  # hop); the language ID picks up the .js files under lib/ +
33
33
  # scripts/ regardless.
34
34
  - name: Initialize CodeQL
35
- uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
35
+ uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
36
36
  with:
37
37
  languages: javascript-typescript
38
38
  # security-extended adds higher-precision queries beyond the
@@ -45,6 +45,6 @@ jobs:
45
45
  # Auto-build is skipped because CommonJS files don't need a build
46
46
  # step; CodeQL extracts straight from source.
47
47
  - name: Perform CodeQL Analysis
48
- uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
48
+ uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
49
49
  with:
50
50
  category: "/language:javascript-typescript"
@@ -128,14 +128,14 @@ jobs:
128
128
  persist-credentials: false
129
129
 
130
130
  - name: Set up QEMU (for ARM64 cross-build)
131
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
131
+ uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
132
132
 
133
133
  - name: Set up Docker Buildx
134
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
134
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
135
135
 
136
136
  - name: Log in to GHCR
137
137
  if: ${{ !inputs.dry_run }}
138
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
138
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
139
139
  with:
140
140
  registry: ghcr.io
141
141
  username: ${{ github.actor }}
@@ -317,7 +317,7 @@ jobs:
317
317
  # against a public package, so this is the right shape regardless
318
318
  # of visibility.
319
319
  - name: Log in to GHCR
320
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
320
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
321
321
  with:
322
322
  registry: ghcr.io
323
323
  username: ${{ github.actor }}
@@ -40,7 +40,7 @@ jobs:
40
40
  retention-days: 5
41
41
 
42
42
  - name: Upload SARIF to GitHub Security tab
43
- uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
43
+ uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
44
44
  with:
45
45
  sarif_file: results.sarif
46
46
 
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.14.x
10
10
 
11
+ - v0.14.6 (2026-05-30) — **Access-refusal middleware can return RFC 9457 problem+json or a custom response, and several documented-but-uncallable APIs are now reachable.** Every access-refusal middleware — the auth gates (bearer, DPoP, mTLS, AAL, bound-key), CSRF, CORS, rate-limit, bot-guard, age-gate, the host and network allowlists, and the method and content-type gates — now accepts two uniform options: `problemDetails: true` returns an RFC 9457 `application/problem+json` body, and `onDeny(req, res, info)` hands the response to the caller. With neither set the refusal is byte-for-byte what it was, so this is a drop-in change that lets a service standardize one error envelope across its API instead of working around each middleware's hardcoded body. Alongside that: `b.middleware.requireBoundKey` is now exported (it was documented and tested but never wired into the middleware surface), `b.middleware.bearerAuth` accepts `requiredScopes` (previously rejected at construction, which made its scope-enforcement path unreachable), API-key refusals send the RFC 6750 challenge code that matches the failure, two documented call paths that named a missing namespace segment are corrected, and the release flow now flags stale GitHub Actions and vendored bundles — with a ready-to-paste pin — before a dependency PR is needed. **Added:** *Uniform `onDeny` and `problemDetails` options on every access-refusal middleware* — Each request-lifecycle middleware that refuses a request now takes `problemDetails: true` to emit an RFC 9457 `application/problem+json` body (composing `b.problemDetails`) and `onDeny(req, res, info)` to take over the response entirely; `info` carries the status, a machine reason, and the middleware-specific fields. The deny-path response headers (`Allow`, `WWW-Authenticate`, `Retry-After`, `Accept`) survive every mode. When neither option is set the response is unchanged. Covers `requireAuth`, `requireAal`, `requireMethods`, `requireContentType`, `requireMtls`, `requireBoundKey`, `bearerAuth`, `dpop`, `csrfProtect`, `fetchMetadata`, `botGuard`, `ageGate`, `hostAllowlist`, `networkAllowlist`, `cors`, `rateLimit`, and `dailyByteQuota` (whose existing `onExceeded` keeps working as an alias of `onDeny`). **Fixed:** *`b.middleware.requireBoundKey` is now callable* — The Bearer-API-key middleware was documented (with examples and tests) but never exported on `b.middleware`, so `b.middleware.requireBoundKey(...)` threw `undefined is not a function`. It is now wired into the middleware surface. · *`b.middleware.bearerAuth` accepts `requiredScopes`* — The RFC 6750 scope-enforcement path read `opts.requiredScopes`, but the option was rejected at construction with `unknown option`, making the 403 `insufficient_scope` behavior unreachable. `requiredScopes` is now an accepted option. · *RFC 6750 challenge codes on API-key refusals* — `b.middleware.requireBoundKey` now sends the `WWW-Authenticate` error code that matches the failure: `insufficient_scope` on a 403 missing-scope, `invalid_token` on an unknown or revoked token, and no error code on a 401 that presented no credentials (RFC 6750 §3). It previously sent `invalid_request` for every refusal. · *Corrected two documented call paths* — The compliance and network references named a path that dropped a namespace segment: the conformity-assessment scaffold is at `b.cra.report.conformityAssessment` (not `b.cra.conformityAssessment`), and the per-socket tuning helper is at `b.network.socket.applyToSocket` (not `b.network.applyToSocket`). The documented signatures now match the callable paths. · *GitHub Actions pins refreshed* — `github/codeql-action` 4.35.5 to 4.36.0, and `docker/login-action`, `docker/setup-buildx-action`, and `docker/setup-qemu-action` to their latest releases. **Detectors:** *`@primitive` reachability gate* — A new check resolves every documented `b.X.Y` primitive against the actual public surface and fails the build when a documented path is not callable (factory-instance shorthands excluded). This is the gate that would have caught the `requireBoundKey` and call-path issues above. · *Deny-path composition gate* — A new check requires every access-refusal middleware to route its refusal through the shared deny-response writer, so a future middleware cannot reintroduce a hardcoded body that locks callers out of `onDeny` / `problemDetails`. · *Actions and vendor currency in the release flow* — The release flow now fails the cut when a SHA-pinned GitHub Action or a vendored bundle is behind its latest upstream release. The actions report prints a ready-to-paste `owner/repo@<sha> # vX.Y.Z` pin and every file and line that uses it, so the bump is copy-paste rather than an after-the-fact dependency PR. Transient registry or API errors stay advisory so a flaky network response does not block an unrelated release.
12
+
11
13
  - v0.14.5 (2026-05-30) — **Finished cleaning up the mislabeled byte-literal lint suppressions, with no API or behavior changes.** A follow-up to the byte-literal lint tightening. The remaining suppression comments that named the byte-literal check on values that are not byte sizes — JSON-RPC error codes, HTTP status codes, octet ranges, day-in-milliseconds constants — are removed, keeping their explanatory text and any correctly-named companion suppression. Every byte-literal suppression that remains is now on genuine 1024-scale byte arithmetic. Source-comment hygiene only. **Changed:** *Remaining mislabeled byte-literal suppressions removed* — The byte-literal lint was previously a check on any multiple-of-8 integer, so suppression comments naming it were scattered across non-byte values. The last of those (in a handful of files, in mixed comment formats) are now removed — their explanatory text is retained as plain comments, and any correctly-named companion suppression is kept. The only byte-literal suppressions that remain are on genuine 1024-scale byte arithmetic. No change to any exported API, error code, wire format, or runtime behavior.
12
14
 
13
15
  - v0.14.4 (2026-05-30) — **Removed three pieces of dead code from the SAML, TLS, and JMAP surfaces; no API or behavior changes.** Cleanup of unreachable code. A reverse signature-algorithm lookup in the SAML verifier was never called — the actual verification path resolves the algorithm through the supported-signature table — so it is removed and a stale comment that referenced it is corrected. A leftover no-op placeholder in the TLS certificate re-encode path (a zero-length slice that was assigned and discarded) is removed, leaving the verbatim extension re-encode it sat next to. An unused JMAP well-known-path constant that existed only to be discarded is removed. None of this changes any exported API, error code, wire format, or runtime behavior. **Removed:** *Unreachable code in SAML, TLS, and JMAP* — Removed `_sigAlgFromUri` from the SAML module (a reverse alg lookup that was never called — the embedded XML-DSig verifier resolves the algorithm via the supported-signature table, and the redirect-binding path uses the forward `_sigAlgUrn`), a discarded zero-length-slice placeholder in the TLS certificate extension re-encode path, and an unused well-known-path constant in the JMAP server. Internal cleanup only — no change to any exported API, error code, wire format, or runtime behavior.
@@ -129,6 +129,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
129
129
  - CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips Authorization-header / cookieless requests, which are not CSRF-able (`b.middleware.csrfProtect`)
130
130
  - CORS (W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt) and rate-limit are wired when configured via `middleware.cors` / `middleware.rateLimit`
131
131
  - `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
132
+ - Every access-refusal layer takes a uniform `problemDetails: true` for an RFC 9457 `application/problem+json` body or `onDeny(req, res, info)` to render the refusal itself — so a service can standardize one error envelope across its API without working around hardcoded bodies (`b.problemDetails`)
132
133
  - **Additional middleware** to mount in your `routes` callback: compression, SSE, request logging, request-time DB role binding (`b.middleware.dbRoleFor`), in-process CIDR fence (`b.middleware.networkAllowlist`)
133
134
  - **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
134
135
  - **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; TSIG transaction signatures (RFC 8945 — `b.network.dns.tsig.sign` / `verify`) for shared-key HMAC authentication of zone transfers, dynamic updates, and query/response pairs, with constant-time MAC compare + fudge-window check (verified against dnspython); outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
@@ -260,6 +260,8 @@ Every tagged release writes a public-transparency-log entry through [Sigstore Re
260
260
 
261
261
  Both flows publish the repository's identity (`blamejs/blamejs`), the workflow path, and the release commit SHA into the public Rekor instance. `github.com/blamejs/blamejs` is intentionally public — see the [repository's settings](https://github.com/blamejs/blamejs/settings) — so the transparency-log entry doesn't disclose anything an attacker couldn't already observe from the public repo, and the auditability gain is the operator-desired property.
262
262
 
263
+ **Currency is a release gate, not a periodic chore.** Both the SHA-pinned GitHub Actions in `.github/workflows` and the vendored bundles in `lib/vendor/MANIFEST.json` are checked against their latest upstream release on every PR (`scripts/check-actions-currency.js`, `scripts/check-vendor-currency.js`) and again in the release flow. A pin that has fallen behind fails the cut — a stale, potentially-vulnerable dependency cannot ship simply because no one noticed the upstream bump. An actually-newer upstream version is a hard failure; a transient registry/API error is advisory so a flaky network response doesn't block an unrelated release.
264
+
263
265
  The release workflow passes `private-repository: true` to the SLSA reusable workflow as an explicit acknowledgement that the transparency-log write is intentional. Without the override, SLSA's internal privacy detection (which inspects workflow permissions rather than repository visibility and so reports false positives for permissive-permission workflows even on public repos) would halt the attest step to avoid leaking the repository name.
264
266
 
265
267
  **Downstream forks operating from a private mirror should flip the input.** If you fork blamejs into a private GitHub organization or a non-public namespace and want releases NOT to write Rekor entries, set `private-repository: false` in `.github/workflows/npm-publish.yml`'s `provenance` job. The SLSA workflow will then halt the attest step and the release pipeline will fail; you'll either accept the loss of SLSA provenance or migrate to a private-Sigstore deployment (the SLSA framework supports this via `sigstore-go` configuration outside the scope of this document).
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.14.5",
4
- "createdAt": "2026-05-30T14:33:33.657Z",
3
+ "frameworkVersion": "0.14.6",
4
+ "createdAt": "2026-05-30T16:00:48.364Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -41124,6 +41124,10 @@
41124
41124
  "type": "function",
41125
41125
  "arity": 1
41126
41126
  },
41127
+ "requireBoundKey": {
41128
+ "type": "function",
41129
+ "arity": 1
41130
+ },
41127
41131
  "requireContentType": {
41128
41132
  "type": "function",
41129
41133
  "arity": 2
@@ -190,8 +190,8 @@ function create(opts) {
190
190
  }
191
191
 
192
192
  /**
193
- * @primitive b.cra.conformityAssessment
194
- * @signature b.cra.conformityAssessment(opts)
193
+ * @primitive b.cra.report.conformityAssessment
194
+ * @signature b.cra.report.conformityAssessment(opts)
195
195
  * @since 0.8.77
196
196
  *
197
197
  * EU Cyber Resilience Act (Regulation 2024/2847) — Annex VIII
@@ -223,7 +223,7 @@ function create(opts) {
223
223
  * }
224
224
  *
225
225
  * @example
226
- * var dossier = b.cra.conformityAssessment({
226
+ * var dossier = b.cra.report.conformityAssessment({
227
227
  * manufacturer: { name: "Acme Inc.", address: "1 St", contact: "ce@acme.example" },
228
228
  * product: { name: "Widget Pro", identifier: "WID-001", version: "1.0", description: "..." },
229
229
  * classification: "default",
@@ -38,6 +38,7 @@
38
38
  var defineClass = require("../framework-error").defineClass;
39
39
  var lazyRequire = require("../lazy-require");
40
40
  var validateOpts = require("../validate-opts");
41
+ var denyResponse = require("./deny-response").denyResponse;
41
42
 
42
43
  var audit = lazyRequire(function () { return require("../audit"); });
43
44
 
@@ -72,6 +73,8 @@ var AgeGateError = defineClass("AgeGateError", { alwaysPermanent: true });
72
73
  * errorMessage: string,
73
74
  * privacyPostureHeader: string, // default "X-Privacy-Posture"; null/false to suppress
74
75
  * audit: boolean, // default true
76
+ * onDeny: function(req, res, info): void, // own the 451; info = { status, reason, age, classification, requireAge }
77
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
75
78
  * }
76
79
  *
77
80
  * @example
@@ -89,6 +92,7 @@ function create(opts) {
89
92
  validateOpts(opts, [
90
93
  "audit", "getAge", "requireAge", "consentRequired",
91
94
  "hasParentalConsent", "skipPaths", "errorMessage", "privacyPostureHeader",
95
+ "onDeny", "problemDetails",
92
96
  ], "middleware.ageGate");
93
97
 
94
98
  if (typeof opts.getAge !== "function") {
@@ -105,6 +109,8 @@ function create(opts) {
105
109
  var auditOn = opts.audit !== false;
106
110
  var errorMessage = typeof opts.errorMessage === "string" && opts.errorMessage.length > 0
107
111
  ? opts.errorMessage : "service unavailable without parental consent";
112
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
113
+ var problemMode = opts.problemDetails === true;
108
114
  // privacyPostureHeader (default "X-Privacy-Posture") names the response
109
115
  // header carrying the below-threshold classification. Pass null/false to
110
116
  // suppress it, or a string to rename it for a downstream convention.
@@ -168,13 +174,20 @@ function create(opts) {
168
174
  var hasConsent = hasParentalConsent ? !!hasParentalConsent(req) : false;
169
175
  if (!hasConsent) {
170
176
  _emitAudit("refused", "denied", { age: age, classification: classification, requireAge: requireAge });
171
- if (!res.writableEnded && typeof res.writeHead === "function") {
172
- res.writeHead(451, { // HTTP 451 Unavailable For Legal Reasons
173
- "Content-Type": "application/json; charset=utf-8",
174
- "Cache-Control": "no-store, private",
175
- });
176
- res.end(JSON.stringify({ error: errorMessage, requireAge: requireAge, parentalConsent: false }));
177
- }
177
+ denyResponse(req, res, {
178
+ onDeny: onDeny,
179
+ problem: problemMode,
180
+ status: 451, // HTTP 451 Unavailable For Legal Reasons
181
+ info: { status: 451, reason: "parental-consent-required",
182
+ age: age, classification: classification, requireAge: requireAge },
183
+ problemCode: "parental-consent-required",
184
+ problemTitle: "Unavailable For Legal Reasons",
185
+ problemDetail: errorMessage,
186
+ problemExt: { requireAge: requireAge, parentalConsent: false },
187
+ headers: { "Cache-Control": "no-store, private" },
188
+ contentType: "application/json; charset=utf-8",
189
+ body: JSON.stringify({ error: errorMessage, requireAge: requireAge, parentalConsent: false }),
190
+ });
178
191
  return;
179
192
  }
180
193
  }
@@ -37,21 +37,34 @@
37
37
  var lazyRequire = require("../lazy-require");
38
38
  var requestHelpers = require("../request-helpers");
39
39
  var validateOpts = require("../validate-opts");
40
+ var denyResponse = require("./deny-response").denyResponse;
40
41
  var { AuthError } = require("../framework-error");
41
42
 
42
43
  var audit = lazyRequire(function () { return require("../audit"); });
43
44
  var observability = lazyRequire(function () { return require("../observability"); });
44
45
 
45
- function _writeUnauthorized(res, scheme, message, realm) {
46
- if (res.headersSent) return;
47
- var body = JSON.stringify({ error: message });
48
- var challenge = scheme + (realm ? ' realm="' + realm + '"' : "");
49
- res.writeHead(401, { // HTTP 401 status
50
- "Content-Type": "application/json; charset=utf-8",
51
- "Content-Length": Buffer.byteLength(body),
52
- "WWW-Authenticate": challenge,
46
+ // Shared 401/403 refusal writer for every bearer-auth deny path —
47
+ // routes through denyResponse so a consumer can override via onDeny
48
+ // or emit RFC 9457 application/problem+json via problemDetails.
49
+ function _refuse(req, res, status, challenge, bodyObj, reason, problemExt, onDeny, problemMode) {
50
+ denyResponse(req, res, {
51
+ onDeny: onDeny,
52
+ problem: problemMode,
53
+ status: status,
54
+ info: Object.assign({ status: status, reason: reason }, problemExt || {}),
55
+ problemCode: "bearer-" + reason,
56
+ problemTitle: status === 403 ? "Forbidden" : "Unauthorized",
57
+ problemDetail: typeof bodyObj.error === "string" ? bodyObj.error : ("bearer authentication failed: " + reason),
58
+ problemExt: problemExt || null,
59
+ headers: { "WWW-Authenticate": challenge },
60
+ contentType: "application/json; charset=utf-8",
61
+ body: JSON.stringify(bodyObj),
53
62
  });
54
- res.end(body);
63
+ }
64
+
65
+ function _writeUnauthorized(req, res, scheme, message, realm, onDeny, problemMode) {
66
+ var challenge = scheme + (realm ? ' realm="' + realm + '"' : "");
67
+ _refuse(req, res, 401, challenge, { error: message }, "unauthorized", null, onDeny, problemMode);
55
68
  }
56
69
 
57
70
  // Three-state extractor: { state: "absent" } when no Authorization
@@ -102,10 +115,13 @@ function _extractToken(req, scheme) {
102
115
  * verify: async function(token): user|null, // required
103
116
  * scheme: string, // default "Bearer"; some ops use "Token"
104
117
  * realm: string,
118
+ * requiredScopes: string[], // RFC 6750 §3 — refuse 403 insufficient_scope when the verified token lacks one
105
119
  * errorMessage: string,
106
120
  * tokenAttachKey: string,
107
121
  * userAttachKey: string,
108
122
  * audit: boolean, // default true
123
+ * onDeny: function(req, res, info): void, // own the 401/403; info = { status, reason, ... }
124
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
109
125
  * }
110
126
  *
111
127
  * @example
@@ -122,7 +138,7 @@ function create(opts) {
122
138
  opts = opts || {};
123
139
  validateOpts(opts, [
124
140
  "verify", "audit", "scheme", "errorMessage", "realm",
125
- "tokenAttachKey", "userAttachKey",
141
+ "tokenAttachKey", "userAttachKey", "requiredScopes", "onDeny", "problemDetails",
126
142
  ], "middleware.bearerAuth");
127
143
 
128
144
  if (typeof opts.verify !== "function") {
@@ -131,6 +147,8 @@ function create(opts) {
131
147
  "the verification path (b.apiKey.verify / b.auth.jwt.verifyExternal / custom)");
132
148
  }
133
149
  var auditOn = opts.audit !== false;
150
+ var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
151
+ var problemMode = opts.problemDetails === true;
134
152
  var scheme = opts.scheme || "Bearer";
135
153
  var errorMessage = opts.errorMessage || "Bearer token required.";
136
154
  var realm = opts.realm || null;
@@ -197,13 +215,8 @@ function create(opts) {
197
215
  if (!res.headersSent) {
198
216
  var malformedChallenge = scheme + ' error="invalid_request"' +
199
217
  (realm ? ', realm="' + realm + '"' : "");
200
- var malformedBody = JSON.stringify({ error: errorMessage });
201
- res.writeHead(401, { // HTTP 401 status
202
- "Content-Type": "application/json; charset=utf-8",
203
- "Content-Length": Buffer.byteLength(malformedBody),
204
- "WWW-Authenticate": malformedChallenge,
205
- });
206
- res.end(malformedBody);
218
+ _refuse(req, res, 401, malformedChallenge, { error: errorMessage }, // HTTP 401 status
219
+ "malformed-authorization", null, onDeny, problemMode);
207
220
  }
208
221
  return;
209
222
  }
@@ -221,13 +234,8 @@ function create(opts) {
221
234
  var challenge = scheme + ' error="invalid_token"' +
222
235
  (realm ? ', realm="' + realm + '"' : "");
223
236
  if (!res.headersSent) {
224
- var body = JSON.stringify({ error: errorMessage });
225
- res.writeHead(401, { // HTTP 401 status
226
- "Content-Type": "application/json; charset=utf-8",
227
- "Content-Length": Buffer.byteLength(body),
228
- "WWW-Authenticate": challenge,
229
- });
230
- res.end(body);
237
+ _refuse(req, res, 401, challenge, { error: errorMessage }, // HTTP 401 status
238
+ "invalid-token", null, onDeny, problemMode);
231
239
  }
232
240
  return;
233
241
  }
@@ -235,7 +243,7 @@ function create(opts) {
235
243
  if (!user) {
236
244
  _emitAudit("auth.bearer.failure", "failure", req, "verifier-returned-null");
237
245
  _emitObs("auth.bearer.rejected", 1, { reason: "verifier-null" });
238
- _writeUnauthorized(res, scheme, errorMessage, realm);
246
+ _writeUnauthorized(req, res, scheme, errorMessage, realm, onDeny, problemMode);
239
247
  return;
240
248
  }
241
249
 
@@ -260,16 +268,9 @@ function create(opts) {
260
268
  var scopeChallenge = scheme + ' error="insufficient_scope"' +
261
269
  ', scope="' + opts.requiredScopes.join(" ") + '"' +
262
270
  (realm ? ', realm="' + realm + '"' : "");
263
- var scopeBody = JSON.stringify({
264
- error: "insufficient_scope",
265
- required: opts.requiredScopes.slice(),
266
- });
267
- res.writeHead(403, { // HTTP 403 status
268
- "Content-Type": "application/json; charset=utf-8",
269
- "Content-Length": Buffer.byteLength(scopeBody),
270
- "WWW-Authenticate": scopeChallenge,
271
- });
272
- res.end(scopeBody);
271
+ _refuse(req, res, 403, scopeChallenge, // HTTP 403 status
272
+ { error: "insufficient_scope", required: opts.requiredScopes.slice() },
273
+ "insufficient-scope", { required: opts.requiredScopes.slice() }, onDeny, problemMode);
273
274
  }
274
275
  return;
275
276
  }