@blamejs/blamejs-shop 0.4.70 → 0.4.71
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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/shipping-insurance.js +29 -4
- package/lib/sms-dispatcher.js +17 -4
- package/package.json +1 -1
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.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.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/sms-dispatcher.js
CHANGED
|
@@ -867,19 +867,32 @@ function create(opts) {
|
|
|
867
867
|
if (resolved) providerSlug = resolved.slug;
|
|
868
868
|
}
|
|
869
869
|
if (!providerSlug) {
|
|
870
|
-
|
|
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
|
-
|
|
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