@blamejs/blamejs-shop 0.4.70 → 0.4.72

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.72 (2026-06-21) — **Cycle counting no longer strands a finalized count when a shelf adjustment fails midway.** When a cycle count was finalized with shelf adjustments, it advanced the count to finalized first and then applied each per-location adjustment in an unguarded loop. If one adjustment failed partway — most plausibly a negative (shrinkage) variance that would drive a location below the units already held for paid orders — the lines before it stayed applied, the lines after it never applied, and the count was left stuck finalized: a retry was refused and the header's aggregate totals were never stamped, so a tool whose whole job is to close shelf-vs-record drift instead baked in an unrecoverable partial drift. finalizeCount now mirrors the inventory-audit finalize it diverged from: it computes every variance first, claims the finalize transition AND stamps the aggregate totals in one statement, then applies each shelf adjustment reconciling against what already landed under the count's own reason — so a retry lands only the remaining delta and never doubles — and passes the hold-aware guard so a shrinkage debit can't drop a shelf below its outstanding holds. On any failure the count re-opens to in_progress, so the operator clears the blocker and re-runs to completion. **Fixed:** *A failed shelf adjustment re-opens the count instead of stranding it finalized* — finalizeCount now computes and persists every line's variance before claiming the finalize transition, stamps the aggregate variance totals in the same statement as the status flip (so a finalized count is never left with empty totals), and applies the per-location adjustments in a guarded pass that reconciles each against the deltas already recorded under the count's reason. If an adjustment throws, the count re-opens to in_progress and the error surfaces; a re-run applies only the remaining delta, so the lines that already moved are never double-applied. · *Shrinkage adjustments respect outstanding holds* — A negative cycle-count variance now debits the shelf with the hold-aware guard, matching the inventory-audit and write-off paths, so a count that records less stock than the record believed can no longer drop a location below the units already held for paid orders and strand a later fulfilment.
12
+
13
+ - v0.4.71 (2026-06-21) — **SMS dispatch and shipping-insurance adjudication now claim their state transition atomically.** Two exactly-once state transitions advanced their row with a primary-key UPDATE that was not gated on the expected prior status, so two concurrent callers could both win the transition. The scheduled SMS dispatch tick flipped a queued message to sent without an `AND status = 'queued'` guard, so two overlapping ticks could both hand the same message to the provider send hook — a duplicate text and a duplicate carrier charge. The shipping-insurance claim adjudication flipped a filed claim to approved or denied the same way, so a double-submitted approval (or an approval racing a denial) could record two different payout amounts as THE payout, or let a denial clobber an approval. Both transitions now gate the UPDATE on the prior status and act only on the row the UPDATE actually claimed (rowCount === 1); the loser is skipped or refused, matching the atomic-claim shape already used by the push-notification dispatch tick and the affiliate-commission payout. **Fixed:** *SMS dispatch tick advances each message exactly once under concurrent ticks* — The queued→sent (and the no-provider queued→failed) transition in the SMS dispatch tick now gates its UPDATE on `AND status = 'queued'` and hands a message to the operator's send hook only when the UPDATE actually claimed the row. Two overlapping ticks can no longer both dispatch the same message, so a customer is never double-texted and the carrier is never billed twice for one message. · *Shipping-insurance claim adjudication records exactly one outcome under concurrent callers* — markClaimApproved and markClaimDenied now gate their transition on `AND status = 'filed'` and refuse the caller whose UPDATE claimed no row. A double-submitted approval can no longer record two different payout amounts as the approved payout, and an approval can no longer clobber a denial (or vice-versa) — exactly one adjudication outcome sticks.
14
+
11
15
  - v0.4.70 (2026-06-21) — **Bundled framework refreshed to blamejs 0.15.14, with the proxy-trust hardening wired through.** Refreshes the vendored blamejs to 0.15.14 — a broad security-hardening release that peer-gates every request-IP and protocol trust decision against the connecting socket, fails closed on a set of authentication and signature verifiers, bounds pre-authentication parsers against algorithmic-complexity denial of service, makes the framework's own file writes atomic and symlink-refusing, and adds a JSON serializer safe to embed in an inline script. The one behavior change that touches a deployment behind a TLS-terminating edge is how the client protocol is trusted: a bare trust-the-forwarded-header flag is no longer accepted for the HSTS and CSRF decisions, because a caller able to reach the origin socket could forge it. The storefront resolves the forwarded protocol through one owned model that the HSTS header, the CSRF origin check, and both session-cookie Secure decisions now share — so HSTS on container responses and authenticated forms behind the Cloudflare edge keep working with no operator action. **Changed:** *Bundled blamejs refreshed to 0.15.14* — Picks up the upstream security-hardening release: client-IP and protocol trust resolved by a peer-gated walk anchored at the connecting socket; SAML, electronic-signature, SD-JWT, DPoP, CIBA, mdoc, and COSE verifiers that could accept a malformed or unsigned credential now refuse it; pre-authentication parsers (CBOR, JSONPath, tar, gzip, YAML, ASN.1 DER) bounded against quadratic-time and frame-desync denial of service; allowlist and posture lookups keyed by untrusted input can no longer reach the prototype chain; the framework's predictable-temp and symlink-following writes go through atomic, symlink-refusing primitives; and several lost-update races (login lockout, bot-challenge, inline webhook delivery) are serialized. · *HSTS and CSRF protocol trust resolved through one owned model behind the edge* — The framework retired the bare forwarded-header trust flag for the decisions that depend on the client protocol (whether to emit HSTS, whether to set the Secure cookie attribute, and the CSRF origin pre-check), since a caller that can reach the origin socket could forge the header. The storefront now resolves the protocol through a single function shared by the security-headers middleware, the CSRF gate, and both the storefront and admin session-cookie paths. Behind the Cloudflare edge the resolver honours the worker-forwarded scheme; a direct connection with no forwarded header resolves to the socket scheme. HSTS on container-served responses and authenticated forms behind the CDN are unchanged; no configuration is required.
12
16
 
13
17
  - v0.4.69 (2026-06-20) — **A gift-card redemption that can't record its audit row no longer loses the balance.** Redeeming a gift card debits the card's balance in one atomic conditional update, then writes a redemption record in a separate statement. Because the bridge runs each statement on its own, a transient failure writing that record left the balance already debited with no redemption row — and the reversal path keys on that row, so the spent balance could never be returned. Redemption is now all-or-nothing: if the record can't be written, the card is re-credited for the amount (and moved back to active if the debit had zeroed it and closed it) before the error is surfaced, so a failed redemption never silently consumes a customer's balance. No API change. **Fixed:** *Redemption re-credits the card if its audit row can't be written* — After the atomic balance debit, the redemption record insert is wrapped so a transient write failure re-credits the card by the redeemed amount and restores it to active if that debit had zeroed and closed it. The customer keeps the balance and the card stays usable, instead of losing the spend to a redemption that left no row for the reversal path to act on.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.70",
2
+ "version": "0.4.72",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -576,29 +576,13 @@ function create(opts) {
576
576
  throw new TypeError("cycle-counting.finalizeCount: count is " + header.status +
577
577
  ", only scheduled or in_progress counts can be finalized");
578
578
  }
579
- // Claim the (scheduled|in_progress) -> finalized transition atomically
580
- // BEFORE computing variances or applying adjustments. Two concurrent
581
- // finalizes both pass the read above, but only one UPDATE matches the
582
- // expected status; the loser refuses, so the per-shelf adjustments are
583
- // applied exactly once. (Without this guard both calls would write the
584
- // variance adjustments through inventoryLocations.adjustStock, double-
585
- // applying.) The final aggregate counts are stamped below once the loop
586
- // has run.
587
- var ts = _now();
588
- var claim = await query(
589
- "UPDATE cycle_counts SET status = 'finalized', finalized_at = ?1 " +
590
- "WHERE slug = ?2 AND status IN ('scheduled', 'in_progress')",
591
- [ts, input.slug],
592
- );
593
- if (Number(claim.rowCount || 0) !== 1) {
594
- throw new TypeError("cycle-counting.finalizeCount: count " +
595
- JSON.stringify(input.slug) + " is no longer scheduled or in_progress " +
596
- "(finalized by a concurrent call)");
597
- }
579
+ // PASS 1 compute each line's variance, persist it (idempotent),
580
+ // and collect the shelf adjustments to apply. No shelf is touched
581
+ // yet: the atomic claim below decides who, if anyone, applies them.
598
582
  var lines = await _getLines(input.slug);
599
583
  var varianceCount = 0;
600
584
  var varianceValueMinor = 0;
601
- var adjustmentsWritten = 0;
585
+ var adjustable = [];
602
586
  for (var i = 0; i < lines.length; i += 1) {
603
587
  var line = lines[i];
604
588
  // NULL actual_quantity = operator didn't count this line.
@@ -615,33 +599,94 @@ function create(opts) {
615
599
  varianceCount += 1;
616
600
  var unitValue = await _unitValueMinor(line.sku);
617
601
  varianceValueMinor += Math.abs(variance) * unitValue;
618
- if (applyAdjustments) {
619
- // Adjustments only land on lines that carry a location_code.
620
- // A global count (no location_code on any line) computes
621
- // variance for the operator's reconciliation report but
622
- // does NOT write to inventory_locations — the operator's
623
- // catalog-side correction (catalog.inventory.restock /
624
- // release / setStock) is the right verb for the single-
625
- // bucket inventory.
626
- if (line.location_code) {
627
- await inventoryLocations.adjustStock({
628
- sku: line.sku,
629
- location_code: line.location_code,
630
- delta: variance,
631
- reason: "cycle-count:" + input.slug,
632
- });
633
- adjustmentsWritten += 1;
634
- }
602
+ // Adjustments only land on lines that carry a location_code.
603
+ // A global count (no location_code on any line) computes
604
+ // variance for the operator's reconciliation report but does
605
+ // NOT write to inventory_locations — the operator's catalog-
606
+ // side correction (catalog.inventory.restock / release /
607
+ // setStock) is the right verb for the single-bucket inventory.
608
+ if (applyAdjustments && line.location_code) {
609
+ adjustable.push({ sku: line.sku, location_code: line.location_code, variance: variance });
635
610
  }
636
611
  }
637
612
  }
638
- // Status + finalized_at were already claimed above; stamp the computed
639
- // aggregate variance totals onto the (now-finalized) header.
640
- await query(
641
- "UPDATE cycle_counts SET variance_count = ?1, variance_value_minor = ?2 " +
642
- "WHERE slug = ?3",
643
- [varianceCount, varianceValueMinor, input.slug],
613
+
614
+ var ts = _now();
615
+ // ATOMIC CLAIM — advance the count out of scheduled/in_progress AND
616
+ // stamp the computed aggregate totals in a SINGLE statement BEFORE any
617
+ // shelf moves. On the D1 bridge there are no interactive transactions,
618
+ // so this UPDATE is the serialization point: two concurrent finalizes
619
+ // both reach here, but only one matches a row. The loser changes zero
620
+ // rows, applies NOTHING, and returns the deterministic totals — so a
621
+ // shelf can never be double-adjusted by overlapping finalizes. Stamping
622
+ // the aggregates in the SAME statement means a finalized count is never
623
+ // left with a NULL variance_count if PASS 2 below aborts midway.
624
+ var claim = await query(
625
+ "UPDATE cycle_counts SET status = 'finalized', variance_count = ?1, " +
626
+ "variance_value_minor = ?2, finalized_at = ?3 " +
627
+ "WHERE slug = ?4 AND status IN ('scheduled', 'in_progress')",
628
+ [varianceCount, varianceValueMinor, ts, input.slug],
644
629
  );
630
+ if (Number(claim.rowCount || 0) !== 1) {
631
+ // Lost the claim — a concurrent (or prior) finalize already advanced
632
+ // this count and owns the adjustment writes. Touch no shelves; return
633
+ // the totals computed from the now-frozen lines.
634
+ return {
635
+ variance_count: varianceCount,
636
+ variance_value_minor: varianceValueMinor,
637
+ adjustments_written: adjustable.length,
638
+ };
639
+ }
640
+
641
+ // PASS 2 (winner only) — apply each shelf adjustment, reconciling
642
+ // against what already landed under this count's reason. Applying only
643
+ // `variance - applied` makes a retry land exactly the remaining delta
644
+ // and never a double, and self-corrects if a recount changed the
645
+ // variance before the retry. The common abort is a respect_holds floor
646
+ // refusal on a negative variance; the catch re-opens the count so the
647
+ // operator can clear the blocker and retry — and because PASS 2 is
648
+ // reconciling, the retry never double-applies the lines that already
649
+ // landed.
650
+ var adjustmentsWritten = 0;
651
+ var adjReason = "cycle-count:" + input.slug;
652
+ try {
653
+ for (var j = 0; j < adjustable.length; j += 1) {
654
+ var adj = adjustable[j];
655
+ var appliedRow = await query(
656
+ "SELECT COALESCE(SUM(delta), 0) AS applied FROM inventory_adjustments " +
657
+ "WHERE sku = ?1 AND location_code = ?2 AND reason = ?3",
658
+ [adj.sku, adj.location_code, adjReason],
659
+ );
660
+ var alreadyApplied = Number((appliedRow.rows[0] || {}).applied || 0);
661
+ var needed = adj.variance - alreadyApplied;
662
+ if (needed !== 0) {
663
+ await inventoryLocations.adjustStock({
664
+ sku: adj.sku,
665
+ location_code: adj.location_code,
666
+ delta: needed,
667
+ reason: adjReason,
668
+ // A negative variance shrinks the shelf — guard it from
669
+ // dropping below the outstanding paid holds for the SKU so a
670
+ // later commitHold can't be stranded. Positive variances
671
+ // (credits) ignore the flag. Mirrors inventory-audits and
672
+ // inventory-writeoffs, the other operator-shrinkage writers.
673
+ respect_holds: true,
674
+ });
675
+ }
676
+ adjustmentsWritten += 1;
677
+ }
678
+ } catch (applyErr) {
679
+ // Re-open the count so the operator can clear the blocker and retry.
680
+ // The reconciling apply above guarantees the retry lands only the
681
+ // remaining delta, so nothing double-counts.
682
+ await query(
683
+ "UPDATE cycle_counts SET status = 'in_progress', finalized_at = NULL " +
684
+ "WHERE slug = ?1 AND status = 'finalized'",
685
+ [input.slug],
686
+ );
687
+ throw applyErr;
688
+ }
689
+
645
690
  return {
646
691
  variance_count: varianceCount,
647
692
  variance_value_minor: varianceValueMinor,
@@ -603,11 +603,26 @@ function create(opts) {
603
603
  ins.declared_value_minor + " on the parent insurance");
604
604
  }
605
605
  var now = _now();
606
- await query(
606
+ // Atomic claim: the `AND status = 'filed'` predicate is the
607
+ // serialization point. The JS check above only refuses a SEQUENTIAL
608
+ // re-adjudication; two concurrent approve calls (a double-clicked
609
+ // operator action, a retried insurer callback) would both read
610
+ // 'filed' and both stamp a payout last-write-wins — two different
611
+ // payout_minor amounts could each be recorded as THE approved payout,
612
+ // or an approve could clobber a deny. Gating the UPDATE on the status
613
+ // means exactly one caller transitions the row; the loser sees zero
614
+ // rows and is refused, so one adjudication outcome sticks.
615
+ var approvedUpd = await query(
607
616
  "UPDATE insurance_claims SET status = 'approved', payout_minor = ?1, " +
608
- "resolved_at = ?2, updated_at = ?2 WHERE id = ?3",
617
+ "resolved_at = ?2, updated_at = ?2 WHERE id = ?3 AND status = 'filed'",
609
618
  [input.payout_minor, now, claimId],
610
619
  );
620
+ if (Number(approvedUpd.rowCount || 0) !== 1) {
621
+ var racedApprove = await _getClaimRow(claimId);
622
+ throw new TypeError("shippingInsurance.markClaimApproved: claim " + claimId +
623
+ " is " + (racedApprove ? racedApprove.status : "gone") +
624
+ ", only filed claims can move to approved");
625
+ }
611
626
  return _hydrateClaim(await _getClaimRow(claimId));
612
627
  }
613
628
 
@@ -630,11 +645,21 @@ function create(opts) {
630
645
  " is " + claim.status + ", only filed claims can move to denied");
631
646
  }
632
647
  var now = _now();
633
- await query(
648
+ // Atomic claim — same race, same guard as markClaimApproved: the
649
+ // `AND status = 'filed'` predicate serializes the two concurrent
650
+ // callers so exactly one adjudication outcome (approve XOR deny)
651
+ // transitions the row; the loser is refused.
652
+ var deniedUpd = await query(
634
653
  "UPDATE insurance_claims SET status = 'denied', denial_reason = ?1, " +
635
- "resolved_at = ?2, updated_at = ?2 WHERE id = ?3",
654
+ "resolved_at = ?2, updated_at = ?2 WHERE id = ?3 AND status = 'filed'",
636
655
  [denialReason, now, claimId],
637
656
  );
657
+ if (Number(deniedUpd.rowCount || 0) !== 1) {
658
+ var racedDeny = await _getClaimRow(claimId);
659
+ throw new TypeError("shippingInsurance.markClaimDenied: claim " + claimId +
660
+ " is " + (racedDeny ? racedDeny.status : "gone") +
661
+ ", only filed claims can move to denied");
662
+ }
638
663
  return _hydrateClaim(await _getClaimRow(claimId));
639
664
  }
640
665
 
@@ -867,19 +867,32 @@ function create(opts) {
867
867
  if (resolved) providerSlug = resolved.slug;
868
868
  }
869
869
  if (!providerSlug) {
870
- await query(
870
+ // Atomic claim. The snapshot SELECT above is read without a
871
+ // lock, so two concurrent ticks can both pull this same
872
+ // `queued` row. The `AND status = 'queued'` guard makes the
873
+ // transition the claim: SQLite/D1 serialises writers, so
874
+ // exactly one tick's UPDATE matches the row (rowCount === 1)
875
+ // and the other sees rowCount === 0. Only the winner hands the
876
+ // row to the operator's send hook (via `advanced`), so the
877
+ // terminal failure is recorded — and surfaced — exactly once.
878
+ var lostFail = await query(
871
879
  "UPDATE sms_messages SET status = 'failed', failure_reason = ?1, " +
872
- "failed_at = ?2 WHERE id = ?3",
880
+ "failed_at = ?2 WHERE id = ?3 AND status = 'queued'",
873
881
  ["no_provider_at_dispatch", now, row.id],
874
882
  );
883
+ if (Number(lostFail.rowCount || 0) !== 1) continue;
875
884
  advanced.push(await _getMessage(row.id));
876
885
  continue;
877
886
  }
878
- await query(
887
+ // Atomic claim on the advance to `sent` — same race, same guard.
888
+ // The loser skips so the operator's send hook (the real provider
889
+ // POST) fires once per row, never twice for one message.
890
+ var claimed = await query(
879
891
  "UPDATE sms_messages SET status = 'sent', provider_slug = ?1, " +
880
- "sent_at = ?2 WHERE id = ?3",
892
+ "sent_at = ?2 WHERE id = ?3 AND status = 'queued'",
881
893
  [providerSlug, now, row.id],
882
894
  );
895
+ if (Number(claimed.rowCount || 0) !== 1) continue;
883
896
  advanced.push(await _getMessage(row.id));
884
897
  }
885
898
  return advanced;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.70",
3
+ "version": "0.4.72",
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": {