@blamejs/blamejs-shop 0.4.37 → 0.4.38

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.38 (2026-06-14) — **Close two refund-path money-creation defects: loyalty restoration over-minting across refund passes, and a double-submitted partial refund phantom-crediting gift card and loyalty.** Two confirmed money-creation bugs in the refund path, both surfaced by an adversarial review of the recent cash-first refund accounting. First, restoring redeemed loyalty points could mint more than the customer spent when an order was refunded across more than one pass (a partial slice then the terminal refund): the restore scan re-read the positive reversal rows it writes as if they were fresh redemptions and credited them again, compounding past the original spend. Second, a console partial-refund double-submit — a double-click, a retry, or a refund webhook arriving after a console refund — appended the refund ledger twice under the same payment-provider refund id; the provider moved the money once, but the duplicate ledger row double-counted the refunded total, which drove the cash-first accounting to re-credit gift-card and loyalty value the customer was never actually refunded. Both are fixed and covered by regression tests. No migration to apply. **Fixed:** *Loyalty restoration no longer over-mints across multiple refund passes* — When a refund runs in more than one pass — a partial cash refund followed by a terminal full refund — restoring redeemed points scanned every loyalty row tied to the order, including the positive reversal rows the restore itself writes (which share the same transaction type as a redemption). A later pass re-read a prior pass's reversal as a fresh redemption and credited it again, so the restored points exceeded the original spend. The scan now reads only the genuine burns, so the cumulative restored points converge exactly on the redeemed amount regardless of how many passes run. · *A double-submitted partial refund no longer phantom-credits gift card and loyalty* — Recording a partial refund now dedupes on the payment-provider refund id in a single conditional write, so the existence check and the ledger append are one atomic statement — two concurrent submits of the same refund (a double-click, a retry, or a refund webhook racing the console) can no longer both record. The Stripe refund webhook now stamps that same refund id alongside the cash share it mirrors, so a refund the webhook records before the console write is recognized and the console write becomes a no-op. Previously a duplicate row double-counted the order's refunded total, which on a split-tender order pushed the cumulative past the cash captured and re-credited the full gift-card and loyalty value the customer was never refunded.
12
+
11
13
  - v0.4.37 (2026-06-13) — **Restore the production container — accept the node-owned tmpDir on Cloudflare Containers — and adopt blamejs 0.15.9 on Node 24.16.** The deploy-recovery release that fixes the container startup crash-loop at its root, and moves the framework forward. blamejs 0.15.0 made db.init fail-closed when the encrypted-at-rest working-copy tmpDir is not a recognized tmpfs mount (/dev/shm, /run/shm, /run/user, /tmp), so the decrypted copy can't leak to a persistent disk. The container sets BLAMEJS_TMPDIR=/app/tmp — a node-owned persistent path, used because Cloudflare's Firecracker runtime does not grant the non-root node user write access to /dev/shm — so every 0.15.x boot threw db/tmpdir-not-tmpfs and the container crash-looped, while edge-served pages stayed up. The container's filesystem is ephemeral (destroyed on stop, never snapshotted or replicated), so the disk-residency the guard protects against does not exist here; this release passes the documented opt-out (the db.allowNonTmpfsTmpDir flag on createApp) rather than weakening encryption-at-rest. With the container booting again, the release also adopts the latest blamejs 0.15.9 — SQLite parse-time resource caps on the raw-SQL surface, an atomic-rename retry that rides out transient file locks, and a one-call secure logout that emits Clear-Site-Data — and raises the Node floor to 24.16, the version 0.15.9 requires (the runtime was never implicated in the outage). Account, cart, checkout, and admin respond again. No migration to apply. **Changed:** *Vendored blamejs advanced to 0.15.9; Node floor raised to 24.16* — The vendored framework moves to 0.15.9 — node:sqlite handles construct with SQLITE_LIMIT_* caps (a 1 MiB statement parse-time cap and ATTACH denied on the raw-SQL surface), every final temp-to-destination rename retries through a transient file lock, and b.session.logout destroys a session and emits an RFC 9527 Clear-Site-Data header in one call. 0.15.9's engines floor is Node 24.16, so the package engines, the container base image, .nvmrc, and CI all move to 24.16; the runtime change is independent of and was not the cause of the startup issue fixed above. **Fixed:** *Container boots on Cloudflare Containers under blamejs 0.15.x* — The application now passes the documented db.allowNonTmpfsTmpDir opt-out to createApp, so the encrypted-at-rest tmpDir check accepts the node-owned /app/tmp path the container is forced to use on Cloudflare's Firecracker runtime (where the non-root node user cannot write /dev/shm). Encryption-at-rest stays on; the opt-out is sound because the container filesystem is ephemeral and is never snapshotted or replicated. Without it, blamejs 0.15.0+ fail-closes db.init at boot and the container crash-loops — which is what took account, cart, checkout, and admin offline. Operators running the container on a platform without a writable tmpfs for the app user need this flag; operators with a real tmpfs should point BLAMEJS_TMPDIR at it instead.
12
14
 
13
15
  - v0.4.36 (2026-06-13) — **Pin vendored blamejs to 0.15.6 — the production container crash-loops on 0.15.7 and 0.15.8.** A deploy-recovery release that pins the vendored framework to the last version the production container booted on. The prior release attributed the container startup crash-loop to the Node minimum, but the container stayed down on Node 24.14.1 with vendored blamejs 0.15.8 — isolating the cause to the vendored framework: blamejs 0.15.7 and 0.15.8 crash-loop the deployed container's live cluster + database-bridge startup (the process exits cleanly during boot instead of staying up and listening, so the health check fails and it restarts in a loop). The in-image build smoke runs the test suite, not the production server boot, so it passes on every version and cannot catch this. This release pins the vendored blamejs back to 0.15.6 — the last version the container booted and served on — restoring account, cart, checkout, and admin. The Node minimum stays 24.14.1; it is not implicated (the container failed on 0.15.8 with 24.14.1 too). Re-adopting blamejs 0.15.7 or later is held until the startup regression is fixed. No migration to apply. **Fixed:** *Production container boots and serves again on vendored blamejs 0.15.6* — The vendored framework is pinned back to 0.15.6, the last version the deployed container completed startup on, so account, cart, checkout, and admin respond again. blamejs 0.15.7 and 0.15.8 left the container exiting during the live cluster + database-bridge boot — a path the in-image build smoke (which runs the test suite, not the production server boot) does not exercise — so they passed every build gate yet failed to stay up once deployed. The vendored tree is pinned through the vendor pipeline with integrity hashes re-stamped.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.37",
2
+ "version": "0.4.38",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/checkout.js CHANGED
@@ -427,6 +427,14 @@ function create(deps) {
427
427
  var remaining = grand - refundedSoFar;
428
428
  var delta = cumulative - refundedSoFar;
429
429
  var meta = { stripe_event_id: event.id };
430
+ // Stamp the provider refund id (the charge carries its refunds newest-
431
+ // first) so a console refund recorded AFTER this webhook dedupes against
432
+ // it — recordPartialRefund keys idempotency on the refund id, and without
433
+ // it a webhook-first refund and the console write would both land,
434
+ // double-counting the refunded total on a split-tender order.
435
+ var _stripeRefundId = charge && charge.refunds && Array.isArray(charge.refunds.data) &&
436
+ charge.refunds.data[0] && charge.refunds.data[0].id;
437
+ if (_stripeRefundId) { meta.stripe_refund_id = _stripeRefundId; }
430
438
  if (charge.refunded === true || cumulative >= grand) {
431
439
  // Charge fully refunded — terminal edge. (On a split-tender order the
432
440
  // charge covers only the provider-paid share; a full charge refund
package/lib/loyalty.js CHANGED
@@ -441,9 +441,16 @@ function create(opts) {
441
441
  }
442
442
  var effRefunded = refundedMinor > orderTotalMinor ? orderTotalMinor : refundedMinor;
443
443
 
444
+ // Scan only the GENUINE burns (source = 'redeem'), never the positive
445
+ // 'redeem-reversal' rows this function writes below — those also carry
446
+ // transaction_type 'redeem', so without the source filter a second
447
+ // restore pass (the cash-first refund path runs this once for the
448
+ // partial slice and again on the terminal refund edge) would re-read a
449
+ // prior pass's reversal as a fresh redemption and credit it again,
450
+ // compounding the restore past the original spend (money creation).
444
451
  var rows = (await query(
445
452
  "SELECT id, customer_id, points, restored_points FROM loyalty_transactions " +
446
- "WHERE order_id = ?1 AND transaction_type = 'redeem'",
453
+ "WHERE order_id = ?1 AND transaction_type = 'redeem' AND source = 'redeem'",
447
454
  [oid],
448
455
  )).rows;
449
456
  var restoredTotal = 0;
package/lib/order.js CHANGED
@@ -957,17 +957,45 @@ function create(opts) {
957
957
  var meta = Object.assign({}, opts2.metadata || {});
958
958
  meta.amount_minor = opts2.amount_minor;
959
959
  meta.partial = true;
960
+ // Idempotency by provider refund id, enforced ATOMICALLY. The provider
961
+ // refund id (stripe_refund_id / paypal_refund_id, stamped by the admin
962
+ // console's _refundLedgerMeta and by both the Stripe and PayPal webhook
963
+ // mirrors) is the dedupe identity — the provider moves the money once.
964
+ // A console double-submit, a retry, or a refund webhook racing the
965
+ // console write would otherwise append a SECOND 'refund' row, double-
966
+ // counting refundedTotalMinor and driving the cash-first block below to
967
+ // re-credit gift-card + loyalty value the customer was never refunded
968
+ // (money creation). The append is a single INSERT...SELECT...WHERE NOT
969
+ // EXISTS, so the existence check and the write are ONE statement: two
970
+ // concurrent submits of the same refund id can't both land (the second's
971
+ // guard sees the first's row), closing the read-then-write race a
972
+ // separate SELECT would leave open — with no schema change. rowCount 0
973
+ // means a row for this refund id was already present: this call is a
974
+ // replay, so record nothing and return the order unchanged (the
975
+ // cash-first re-credit below is skipped). Refunds with no provider id
976
+ // (a legacy/manual record) take the plain unconditional append.
960
977
  var ts = _now();
961
- await query(
962
- "INSERT INTO order_transitions (id, order_id, from_state, to_state, on_event, reason, metadata_json, occurred_at) " +
963
- "VALUES (?1, ?2, ?3, ?3, 'refund', ?4, ?5, ?6)",
964
- [
965
- b.uuid.v7(), orderId, current.status,
966
- opts2.reason || null,
967
- JSON.stringify(meta),
968
- ts,
969
- ],
970
- );
978
+ var _refundId = (meta.stripe_refund_id || meta.paypal_refund_id) || null;
979
+ if (_refundId) {
980
+ var _appended = await query(
981
+ "INSERT INTO order_transitions (id, order_id, from_state, to_state, on_event, reason, metadata_json, occurred_at) " +
982
+ "SELECT ?1, ?2, ?3, ?3, 'refund', ?4, ?5, ?6 " +
983
+ "WHERE NOT EXISTS (SELECT 1 FROM order_transitions " +
984
+ "WHERE order_id = ?2 AND on_event = 'refund' " +
985
+ "AND (json_extract(metadata_json, '$.stripe_refund_id') = ?7 " +
986
+ " OR json_extract(metadata_json, '$.paypal_refund_id') = ?7))",
987
+ [b.uuid.v7(), orderId, current.status, opts2.reason || null, JSON.stringify(meta), ts, _refundId],
988
+ );
989
+ if (Number(_appended.rowCount || 0) === 0) {
990
+ return await this.get(orderId); // replay — this refund id already recorded, no-op
991
+ }
992
+ } else {
993
+ await query(
994
+ "INSERT INTO order_transitions (id, order_id, from_state, to_state, on_event, reason, metadata_json, occurred_at) " +
995
+ "VALUES (?1, ?2, ?3, ?3, 'refund', ?4, ?5, ?6)",
996
+ [b.uuid.v7(), orderId, current.status, opts2.reason || null, JSON.stringify(meta), ts],
997
+ );
998
+ }
971
999
  // updated_at is bumped so the order surfaces at the top of the
972
1000
  // operator recent-orders list after a partial refund, matching how a
973
1001
  // real FSM transition touches it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.37",
3
+ "version": "0.4.38",
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": {