@blamejs/blamejs-shop 0.4.58 → 0.4.60

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.60 (2026-06-19) — **Free-shipping discount rules now actually waive the shipping charge.** Fixes a checkout defect that made a free_shipping auto-discount rule a complete no-op on the amount charged: the discount engine correctly flagged the order as free-shipping-eligible, but checkout dropped that flag and still billed the customer the full selected shipping rate. A matched free_shipping rule now zeroes the charged shipping in the quote and the confirmed order (the selected rate is still shown so the customer sees what was waived), so the payment is for merchandise and tax only. The rule is also now reserved against its redemption like every other auto-discount, so a free_shipping rule with a usage cap is enforced instead of applying without limit. Subtotal discounts are unaffected — free shipping reduces the shipping line, not the subtotal, and tax (computed on the post-discount subtotal) is unchanged. **Fixed:** *free_shipping auto-discount waives the charged shipping* — A matching free_shipping rule was flagged by the auto-discount engine but never applied to the amount charged — checkout excluded it from the discount (correct, since free shipping is not a subtotal reduction) but then charged the full shipping rate, so the rule saved the customer nothing. The quote and the confirmed order now set the charged shipping to zero when a free_shipping rule matches; the selected shipping service and its rate are still surfaced so the storefront can show the waived amount. The free_shipping rule is included in the order's applied auto-discounts and reserved against its redemption pre-charge, so a capped free-shipping rule is enforced and recorded the same way a percentage or amount-off rule is. A checkout integration test now asserts the charged total, the confirmed order total, and the payment amount all exclude shipping when free shipping applies.
12
+
13
+ - v0.4.59 (2026-06-19) — **Win-back campaign sends now actually run (they were being silently blocked).** Fixes a defect that left win-back campaigns completely inert since they shipped: the scheduled worker tick that drives win-back enrolment and dispatch was being rejected by the application bot-guard before it could reach its own shared-secret gate, so no win-back step ever sent. The worker fires its internal cron ticks machine-to-machine with only a content type and the bridge-secret header — no browser fingerprint — and the bot-guard's missing-Accept-Language heuristic returned 403 for the win-back path. Every other internal cron tick (cart-recovery, stock-alert and low-stock sweeps, wishlist alerts and digests, campaign send, customer-portal expiry, stale-order reap, quote expiry) was already exempt from the bot-guard; the win-back tick was the one omission. It is now exempt as well, so the tick reaches its handler (still gated by the bridge secret), and an operator who has win-back configured now sees the escalating offer sequence actually go out. A new test asserts every internal cron path is exempt from the bot-guard, so a future cron-driven feature can't ship blocked the same way. **Fixed:** *Win-back campaign cron tick was blocked by the bot-guard* — The internal /_/winback-send-tick POST the worker fires on a schedule was missing from the bot-guard exemption list, so the application bot-guard returned 403 on the missing-Accept-Language heuristic before the handler's bridge-secret check ran. Win-back enrolment and step dispatch therefore never executed. The path is now exempt like the other nine internal cron ticks (the bridge-secret gate remains the deciding check, so an unauthenticated caller is still refused). A bot-guard skip-list parity test now asserts every internal cron path is exempt, anchored to the security middleware so it runs in the container image too — closing the gap that let this ship.
14
+
11
15
  - v0.4.58 (2026-06-19) — **Make concurrent state transitions atomic across payouts, key rotation, and credit accounting.** Closes a class of concurrency races where a state transition or ledger write read the current state, checked it in application code, and then wrote — leaving a window in which two requests for the same record could both pass the check and both apply their effect. Each of these now performs the check and the write as a single conditional statement (the update or insert only lands when the record is still in the expected state, or the balance still satisfies the invariant), so exactly one of two racing callers wins and the other is cleanly refused. The affected operations: an affiliate commission can no longer be marked paid (or voided) twice, so a second payout reference can't be issued for one commission; rotating an API key claims the rotation before minting the replacement, so two concurrent rotations can't issue two successor keys; a B2B credit charge, hold release, and payment now evaluate the credit limit and running balance inside the insert, so concurrent charges can't push an account past its limit or corrupt its denormalized balance; fulfilling or cancelling a backorder line debits the per-SKU pending counter only when it wins the line transition, so the counter can't be double-debited; and a click-and-collect pickup advances the parent order exactly once. No API change and no migration — the operations behave identically for a single caller and now stay correct under concurrent ones. **Fixed:** *Affiliate commission payout transitions are atomic* — markCommissionPaid and markCommissionVoided now gate the status change on the row still being pending in the UPDATE itself and refuse (AFFILIATE_COMMISSION_TRANSITION_REFUSED) when they lose the race. Previously the pending check ran in application code before an unconditional write, so two concurrent payouts for the same commission could both proceed and issue two payout references for one commission, or a void could clobber a paid row. · *API-key rotation claims the rotation before issuing the replacement* — rotate now transitions the key from active to rotated with a conditional update and refuses (API_KEY_NOT_ROTATABLE) if the row is no longer active, before minting the successor key. Previously two concurrent rotations of the same key could both pass the active check and both issue a replacement, leaving an unexpected extra valid credential. · *B2B credit charges, releases, and payments enforce their invariant atomically* — chargeOrder, releaseHold, and recordPayment now compute the new running balance and evaluate their invariant (the credit limit for a charge, the outstanding balance for a payment, the order's net exposure for a release) inside a single conditional insert. Previously the balance was read, checked, and written in separate steps, so two concurrent charges could both pass the limit check — pushing the account past its credit limit and stamping the same balance_after on both rows, permanently under-stating outstanding exposure. Refusals carry the same typed codes (CREDIT_LIMIT_EXCEEDED, CREDIT_PAYMENT_EXCEEDS_OUTSTANDING, CREDIT_RELEASE_NOT_FOUND) and the denormalized per-customer balance chain stays correct under concurrent writes. · *Backorder fulfilment and cancellation debit the counter exactly once* — fulfillBackorder and cancelBackorder now claim the line's pending to fulfilled/cancelled transition with a conditional update and only decrement the per-SKU pending_quantity counter when the claim wins. Previously the line status was checked in application code before an unconditional write and counter decrement, so two concurrent calls for one line could both decrement the counter, under-counting pending demand. · *Click-and-collect pickup advances the order once* — markPickedUp now claims the schedule's ready to picked_up transition with a conditional update before driving the parent order to its delivered state, so two concurrent pickups for one order can't both advance the order FSM.
12
16
 
13
17
  - v0.4.57 (2026-06-19) — **Refresh the vendored blamejs framework to 0.15.13.** Refreshes the vendored blamejs framework from 0.15.12 to 0.15.13, which consolidates a set of duplicated, security-sensitive code paths onto shared hardened primitives so the strongest available guard now applies everywhere an operation happens. The shop picks up several concrete fixes by composing the framework: a webhook delivery that fails with a transient transport error (connection reset, timeout, 5xx) now backs off and retries up to the configured limit instead of being recorded as permanently failed and dead-lettered, with the SSRF / blocked-host destination check the only permanent failure; an inline webhook delivery is claim-guarded before the POST so a concurrent retry pass can no longer pick up and re-send the same delivery; the webhook deliveries table now creates on a MySQL backend (the pending-delivery index is dialect-aware); and the best-effort final audit checkpoint fired during database shutdown now binds to the database it measured and refuses to write if that handle has been closed or replaced, so a checkpoint can never anchor one database's chain tip into a freshly opened one. File reads across the framework route through a single TOCTOU-safe reader that binds a file's size, contents, and integrity measurement to the inode an open fd holds, closing the swap-after-stat race on operator-writable paths. The Content-Security-Policy response-header parser now splits on the literal comma in linear time, removing a quadratic stall on a crafted header. Two configuration-validation paths (HTTP client pool options, database stream-limit) now throw the framework's typed error instead of a bare Error; both remain Error subclasses, so existing try/catch handling is unaffected. This refresh carries no shop-facing API change and applies no migration; it keeps the bundled framework current and tightens the security posture without adding any opt-in. **Changed:** *Vendored blamejs refreshed to 0.15.13* — The bundled framework is updated to blamejs 0.15.13. Webhook deliveries that hit a transient transport failure (connection reset, timeout, 5xx) now retry with backoff up to the configured limit instead of being dead-lettered as permanently failed; only the SSRF / blocked-host destination check is a permanent failure. An inline webhook delivery is claimed (marked in-flight before the POST) so a concurrent retry pass cannot double-send it, and a row left in-flight by a crash is reclaimed after the stale-claim window. The webhook deliveries table now creates on MySQL via a dialect-aware index. The audit checkpoint fired at database shutdown binds to the database it read the tip from and fail-closes (writes nothing) if that handle has since been closed or replaced. Every framework file read now uses a TOCTOU-safe reader (open fd, then bind size / contents / integrity to that inode), with symlink refusal, inode-equality, a byte cap, and an integrity hash available to callers. The Content-Security-Policy header parser splits on the literal comma in linear time (removing a quadratic-stall vector on a crafted header). Parsed INI and OpenAPI path maps are null-prototype objects, so a parser gap could only set an own property and never reach Object.prototype. HTTP-client pool-option and database stream-limit configuration errors now throw the module's typed framework error (still an Error subclass). No shop API change; the framework's PQC-first crypto, security middleware, and request lifecycle are carried forward as-is.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.58",
2
+ "version": "0.4.60",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/checkout.js CHANGED
@@ -538,7 +538,7 @@ function create(deps) {
538
538
  // more than the cart is worth.
539
539
  async function _resolveAutoDiscount(lines, subtotalMinor, customerId, codes) {
540
540
  if (!autoDiscount || typeof autoDiscount.evaluate !== "function") {
541
- return { discount_minor: 0, applied: [] };
541
+ return { discount_minor: 0, applied: [], free_shipping: false };
542
542
  }
543
543
  try {
544
544
  var evalLines = [];
@@ -564,28 +564,38 @@ function create(deps) {
564
564
  var appliedRaw = res && Array.isArray(res.applied) ? res.applied : [];
565
565
  var total = 0;
566
566
  var applied = [];
567
+ var freeShipping = false;
567
568
  for (var j = 0; j < appliedRaw.length; j += 1) {
568
569
  var entry = appliedRaw[j];
569
- // A `free_shipping` rule's savings are a shipping reduction,
570
- // not a subtotal discount excluded so the subtotal-level
571
- // `discount_minor` stays a pure subtotal reduction (the
572
- // shipping primitive owns the shipping side).
573
- if (!entry || entry.value_kind === "free_shipping") continue;
570
+ if (!entry) continue;
571
+ // A `free_shipping` rule reduces the charged SHIPPING, not the
572
+ // subtotal — it does NOT feed `discount_minor` (that stays a pure
573
+ // subtotal reduction). It still belongs in `applied` so its
574
+ // redemption is claimed (cap enforcement) and recorded on the
575
+ // order; its savings_minor is patched to the actual waived
576
+ // shipping by _buildQuote, the only layer that knows the rate.
577
+ if (entry.value_kind === "free_shipping") {
578
+ freeShipping = true;
579
+ applied.push({ rule_slug: entry.rule_slug, savings_minor: 0, free_shipping: true });
580
+ continue;
581
+ }
574
582
  var s = entry.savings_minor;
575
583
  if (!Number.isInteger(s) || s <= 0) continue;
576
584
  total += s;
577
585
  applied.push({ rule_slug: entry.rule_slug, savings_minor: s });
578
586
  }
579
- if (!Number.isFinite(total) || total <= 0) {
580
- return { discount_minor: 0, applied: [] };
587
+ if ((!Number.isFinite(total) || total <= 0) && !freeShipping) {
588
+ return { discount_minor: 0, applied: [], free_shipping: false };
581
589
  }
590
+ if (total < 0) total = 0;
582
591
  // Clamp to the subtotal floor: the discount can reduce the
583
- // subtotal to zero but never past it.
592
+ // subtotal to zero but never past it. (total is 0 when only a
593
+ // free_shipping rule matched.)
584
594
  if (total > subtotalMinor) total = subtotalMinor;
585
- return { discount_minor: total, applied: applied };
595
+ return { discount_minor: total, applied: applied, free_shipping: freeShipping };
586
596
  } catch (_e) {
587
597
  // Any engine failure -> no discount, never a 5xx on the buy path.
588
- return { discount_minor: 0, applied: [] };
598
+ return { discount_minor: 0, applied: [], free_shipping: false };
589
599
  }
590
600
  }
591
601
 
@@ -1202,10 +1212,27 @@ function create(deps) {
1202
1212
  }
1203
1213
 
1204
1214
  var shippingMinor = selected ? selected.amount_minor : 0;
1215
+ // A matched free_shipping auto-discount WAIVES the charged shipping.
1216
+ // The selected rate is still surfaced (selected_shipping) so the
1217
+ // customer sees what was waived; the charged amount drops to zero.
1218
+ // Without this the rule was a complete no-op — the engine flagged
1219
+ // free shipping but the buyer was still billed the full rate.
1220
+ var chargedShippingMinor = autoDisc.free_shipping ? 0 : shippingMinor;
1221
+ // The free_shipping rule's recorded savings is the waived rate (the
1222
+ // engine couldn't know it at eval time), so its redemption cap
1223
+ // reserves against the real amount.
1224
+ var appliedOut = autoDisc.applied;
1225
+ if (autoDisc.free_shipping && shippingMinor > 0) {
1226
+ appliedOut = appliedOut.map(function (a) {
1227
+ return a && a.free_shipping
1228
+ ? { rule_slug: a.rule_slug, savings_minor: shippingMinor, free_shipping: true }
1229
+ : a;
1230
+ });
1231
+ }
1205
1232
 
1206
1233
  var totals = pricing.totals(c, lines, {
1207
1234
  tax_minor: taxRow.tax_minor,
1208
- shipping_minor: shippingMinor,
1235
+ shipping_minor: chargedShippingMinor,
1209
1236
  discount_minor: autoDisc.discount_minor,
1210
1237
  });
1211
1238
 
@@ -1217,11 +1244,15 @@ function create(deps) {
1217
1244
  tax_rate_bps: taxRow.rate_bps,
1218
1245
  shipping_rates: ratesRow.services,
1219
1246
  selected_shipping: selected,
1247
+ // Whether a free_shipping rule waived the shipping charge, and the
1248
+ // amount it waived (for the storefront's "Free shipping" line).
1249
+ free_shipping: autoDisc.free_shipping === true,
1250
+ shipping_savings_minor: autoDisc.free_shipping ? shippingMinor : 0,
1220
1251
  totals: totals,
1221
- // Per-rule breakdown that fed `totals.discount_minor`, replayed
1222
- // by confirm()'s post-commit recorder. Empty when no rule
1223
- // applied (or no engine wired).
1224
- auto_discounts: autoDisc.applied,
1252
+ // Per-rule breakdown that fed `totals.discount_minor` (and the
1253
+ // free_shipping waiver), replayed by confirm()'s post-commit
1254
+ // recorder and claimed pre-charge. Empty when no rule applied.
1255
+ auto_discounts: appliedOut,
1225
1256
  };
1226
1257
  }
1227
1258
 
@@ -107,6 +107,7 @@ var INTERNAL_BRIDGE_PATHS = [
107
107
  "/_/wishlist-alerts-sweep",
108
108
  "/_/wishlist-digest-sweep",
109
109
  "/_/campaign-send-tick",
110
+ "/_/winback-send-tick",
110
111
  "/_/customer-portal-expire",
111
112
  "/_/stale-order-reap",
112
113
  "/_/quote-expiry-tick",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.58",
3
+ "version": "0.4.60",
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": {