@blamejs/blamejs-shop 0.4.38 → 0.4.39

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.39 (2026-06-13) — **Account erasure severs sign-in first and no longer strands a half-erased account when one data domain fails.** Hardening the right-to-erasure flow, which fans a deletion out across every customer-keyed table. Two gaps are closed. The sign-in revocation — passkeys, OAuth links, the email-hash lookup key, and live portal sessions — ran last in the fan-out, so a failure partway through left the supposedly-erased account still able to authenticate. And any single domain handler that threw aborted the entire erasure, leaving every remaining domain untouched with no record of what failed. Now the access-cut runs first, so the account can no longer sign in even if a later step fails; each domain is isolated, so one failure no longer strands the rest; the run reports which domains failed and stays open for retry instead of reporting completion; and re-running converges once the transient clears, since every domain handler is idempotent. No migration to apply. **Fixed:** *Erasure cuts off account sign-in before deleting data* — An erasure request now revokes every sign-in path — passkeys, OAuth identities, the email-hash lookup key, and live portal sessions — as the first step of the fan-out, ahead of the per-table deletions. Previously this revocation ran last, so an erasure that failed partway through left the account's credentials intact and the customer still able to sign in to the anonymized profile. Severing access is erasure's first obligation; data removal follows. · *A single failing domain no longer aborts the whole erasure* — Each customer-keyed domain is now deleted in isolation: a handler that throws is recorded as a failure and the erasure continues to the remaining domains, instead of aborting and stranding everything after it half-erased. The request is marked fulfilled only when every domain succeeds — a partial run stays open and reports exactly which domains failed, so re-running retries just the failures and converges to a clean, complete erasure. The erasure console reflects an incomplete run as needing a re-run rather than reporting success, and every domain handler is idempotent, so the retry removes nothing twice.
12
+
11
13
  - 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
14
 
13
15
  - 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.
package/lib/admin.js CHANGED
@@ -5617,7 +5617,11 @@ function mount(router, deps) {
5617
5617
  shop_name: deps.shop_name, nav_available: navAvailable,
5618
5618
  request: row, history: history, preview: preview,
5619
5619
  moved: url && url.searchParams.get("moved"),
5620
- notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this request." : null,
5620
+ notice: (url && url.searchParams.get("err"))
5621
+ ? "That action couldn't be completed for this request."
5622
+ : ((url && url.searchParams.get("partial"))
5623
+ ? "Erasure incomplete — some data could not be removed. The request stays open; re-run the erasure to finish it."
5624
+ : null),
5621
5625
  }));
5622
5626
  },
5623
5627
  ));
@@ -5733,11 +5737,27 @@ function mount(router, deps) {
5733
5737
  }));
5734
5738
  }
5735
5739
  // Confirmed — run it for real, then PRG to the detail.
5736
- try { await dsr.processDeletion({ request_id: id, dry_run: false }); }
5740
+ var result;
5741
+ try { result = await dsr.processDeletion({ request_id: id, dry_run: false }); }
5737
5742
  catch (e) {
5738
5743
  if (e instanceof TypeError) return _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?err=1");
5739
5744
  throw e;
5740
5745
  }
5746
+ // A partial erasure (one or more domains threw) leaves the request
5747
+ // open for retry — it is NOT a success. Record the incomplete outcome
5748
+ // with the failed domains and send the operator back with ?partial=1
5749
+ // so the detail says the erasure must be re-run, rather than reporting
5750
+ // a clean deletion that did not happen.
5751
+ if (result && result.complete === false) {
5752
+ b.audit.safeEmit({
5753
+ action: AUDIT_NAMESPACE + ".dsr.delete", outcome: "partial",
5754
+ metadata: {
5755
+ id: id,
5756
+ failed_domains: (result.failures || []).map(function (f) { return f.domain; }),
5757
+ },
5758
+ });
5759
+ return _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?partial=1");
5760
+ }
5741
5761
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".dsr.delete", outcome: "success", metadata: { id: id } });
5742
5762
  _redirect(res, "/admin/dsr/" + encodeURIComponent(id) + "?moved=1");
5743
5763
  },
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.38",
2
+ "version": "0.4.39",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -641,16 +641,24 @@ function create(opts) {
641
641
  );
642
642
  }
643
643
 
644
+ // Access-cut FIRST. The `customers` handler revokes every sign-in path —
645
+ // passkeys, OAuth links, the email-hash lookup key, and live portal
646
+ // sessions — so it LEADS the order. Erasure's first duty is to sever the
647
+ // account's ability to authenticate; data removal follows. Leaving it last
648
+ // meant a handler that threw partway left the erased account still able to
649
+ // sign in until a retry reached the tail of the list.
644
650
  var domainOrder = [
651
+ "customers",
645
652
  "recentlyViewed", "wishlist", "saveForLater", "stockAlerts",
646
653
  "suggestionBox", "surveys", "reviews", "orderRatings", "productQa",
647
654
  "quotes", "customerNotes", "consentLedger",
648
655
  "supportTickets", "orderNotes", "order", "guestOrderReconciliations",
649
656
  "subscriptions", "paymentMethods", "loyalty", "storeCredit",
650
- "giftcards", "referrals", "addresses", "customers",
657
+ "giftcards", "referrals", "addresses",
651
658
  ];
652
659
  var perDomain = [];
653
660
  var domainsAbsent = [];
661
+ var failures = [];
654
662
 
655
663
  for (var i = 0; i < domainOrder.length; i += 1) {
656
664
  var name = domainOrder[i];
@@ -659,19 +667,35 @@ function create(opts) {
659
667
  domainsAbsent.push(name);
660
668
  continue;
661
669
  }
662
- var effect = await reader.forCustomerDeletion(row.customer_id, { dry_run: dryRun });
663
- if (!effect || typeof effect !== "object") {
664
- throw new TypeError("complianceExport.processDeletion: reader " + JSON.stringify(name) +
665
- ".forCustomerDeletion returned non-object must return { table, deleted }");
670
+ // Each domain is isolated. A handler that throws (or returns a malformed
671
+ // effect) is recorded as a failure and the erasure CONTINUES to the next
672
+ // domain, instead of aborting and stranding every domain after it
673
+ // half-erased. The request is marked fulfilled only when no domain
674
+ // failed, so an incomplete run stays `processing` and a re-invoke retries
675
+ // the failed domains — every handler is idempotent (re-running on an
676
+ // already-erased row removes nothing more), so the retry is safe.
677
+ try {
678
+ var effect = await reader.forCustomerDeletion(row.customer_id, { dry_run: dryRun });
679
+ if (!effect || typeof effect !== "object") {
680
+ throw new TypeError("reader " + JSON.stringify(name) +
681
+ ".forCustomerDeletion returned non-object — must return { table, deleted }");
682
+ }
683
+ perDomain.push({
684
+ domain: name,
685
+ table: effect.table == null ? name : effect.table,
686
+ deleted: effect.deleted == null ? 0 : Number(effect.deleted),
687
+ });
688
+ } catch (e) {
689
+ failures.push({ domain: name, error: (e && e.message) ? e.message : String(e) });
666
690
  }
667
- perDomain.push({
668
- domain: name,
669
- table: effect.table == null ? name : effect.table,
670
- deleted: effect.deleted == null ? 0 : Number(effect.deleted),
671
- });
672
691
  }
673
692
 
674
- if (!dryRun) {
693
+ var complete = failures.length === 0;
694
+ // Only converge the lifecycle row to `fulfilled` when every domain
695
+ // succeeded. A partial run stays `processing` (the status guard above
696
+ // re-admits it) so the operator can retry until clean. dry-runs never
697
+ // touch the lifecycle row.
698
+ if (!dryRun && complete) {
675
699
  var ts = _now();
676
700
  await query(
677
701
  "UPDATE compliance_requests SET status = 'fulfilled', fulfilled_at = ?1 WHERE id = ?2",
@@ -683,8 +707,10 @@ function create(opts) {
683
707
  request_id: requestId,
684
708
  customer_id: row.customer_id,
685
709
  dry_run: dryRun,
710
+ complete: complete,
686
711
  domains: perDomain,
687
712
  domains_absent: domainsAbsent,
713
+ failures: failures,
688
714
  total_affected: perDomain.reduce(function (acc, d) { return acc + d.deleted; }, 0),
689
715
  };
690
716
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.38",
3
+ "version": "0.4.39",
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": {