@blamejs/blamejs-shop 0.4.77 → 0.4.78

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.78 (2026-06-21) — **Voiding a gift card no longer clobbers a concurrent redemption, and a credit order can't be charged twice.** Two money-safety fixes that make terminal transitions exactly-once. Voiding a gift card is now a single atomic transition guarded on the card still being active, so a void that races a redemption can no longer overwrite the redemption's terminal state and silently discard the spent balance — whichever transition lands first wins and the other is refused. Separately, charging a credit-line order is now idempotent per order: the charge guard refuses a second charge for an order that already has one (and a retry returns the original charge rather than erroring), so the same order can't be charged twice against the customer's credit line. No configuration changes; upgrade to pick them up. **Fixed:** *Voiding a gift card is an atomic, fenced transition* — Gift-card void is now a single guarded UPDATE that only matches a card still in the active state, mirroring redeem. A void that races a redemption (or an expiry) can no longer overwrite the card's terminal state — the prior read-decide-write could void a card a concurrent redemption had just spent, discarding the remaining-balance debit. A void that loses the race is refused (or returns idempotently if the card was already voided). · *A credit-line order is charged at most once* — creditLimits.chargeOrder now refuses a second charge for an order that already carries one, enforced in the same atomic statement as the credit-cap check. A retried charge for an already-charged order returns the original charge instead of erroring or charging the line a second time, so a duplicate capture or retry can't double-draw the customer's credit.
12
+
11
13
  - v0.4.77 (2026-06-21) — **Exactly-once hardening for payment capture, auto-replenishment, gift redemption, and carrier-credential rotation.** Four correctness fixes that make money- and inventory-moving state transitions happen exactly once under concurrency or delayed settlement. A PayPal order is no longer marked paid when the capture itself is still pending — settlement is now read from the capture status, not the order envelope, so a delayed or under-review payment leaves the order pending until funds actually settle. Overlapping auto-replenishment ticks can no longer both fire the same policy: the cadence advance is now an atomic claim, so a vendor purchase order is composed and submitted once per window instead of being double-cut. Redeeming one gift-subscription link twice — two clicks racing within milliseconds — now mints exactly one subscription: the redemption claims the gift before creating the subscription, and rolls the claim back if the mint fails. And two carrier-credential rotations firing at once can no longer both commit; the rotation now compare-and-swaps on the observed key, so the loser is refused instead of clobbering the winner's fresh key and stranding the previous-key grace window. No configuration changes; upgrade to pick them up. **Fixed:** *A PayPal order is marked paid only when the capture itself completes* — Capturing a PayPal order now gates the paid transition on the per-capture status rather than the order envelope. The order-level status can read COMPLETED while the capture is still PENDING (a delayed-settlement or under-review payment), which previously marked the order paid before the money settled. Any non-completed capture now leaves the order pending and reports the capture as unhandled so the checkout surfaces the unsettled state. · *Overlapping auto-replenishment ticks no longer double-submit a purchase order* — The replenishment scheduler advances a policy's run cadence with a single conditional claim that is the serialization point: two ticks firing at once contend on the policy row, only one matches the due-window predicate, and the vendor purchase order is composed and submitted exactly once per window. Previously the cadence was an in-memory check followed by an unconditional stamp, so two overlapping ticks both passed the check and cut duplicate POs. · *Redeeming a gift-subscription link twice creates exactly one subscription* — Gift redemption now claims the gift's pending→redeemed transition before creating the recipient's subscription, and only the winner of that atomic claim mints. A leaked or double-clicked redemption link presented twice within milliseconds therefore creates one subscription, not two; if the subscription mint then fails, the claim is rolled back so the gift can be redeemed again rather than being stranded as redeemed-with-no-subscription. · *Concurrent carrier-credential rotations no longer over-issue or strand credentials* — Rotating a shipping-carrier account's API credentials now compare-and-swaps on the observed key and account state, so two rotations firing at once can't both commit. The loser is refused with a rotation-conflict error instead of overwriting the winner's freshly minted key and leaving the previous-key grace window pointing at a key no deployed worker holds.
12
14
 
13
15
  - v0.4.76 (2026-06-21) — **Account security hardening — revoking a passkey ends the account's other sessions, and registration no longer creates an account before the security key is enrolled.** Four hardenings on the customer account surface. Revoking a passkey now ends every other live session for the account — including pending magic-link sign-in tokens — while keeping the device that performed the revoke signed in; previously the credential was deleted but a sealed session cookie a lost or compromised authenticator had already established stayed valid for its full 14-day life. Signing out everywhere now also revokes any unredeemed magic-link token, so a link mailed before sign-out can no longer sign the account back in. Passkey registration no longer creates the customer record until the security-key ceremony actually completes: the pending sign-up is carried in the sealed challenge cookie and the account is created only on a verified finish, so an abandoned or scripted registration can no longer reserve an email address or leave empty accounts behind. And passkey sign-in performs the same credential lookup whether or not the address has an account, removing a response-time difference that could be used to test which emails are registered. No configuration changes; upgrade to pick them up. **Security:** *Revoking a passkey ends the account's other sessions and pending sign-in links* — Revoking a passkey is a response to a lost or compromised authenticator, so it now moves the account's server-side session-revocation boundary forward — invalidating every sealed auth cookie issued before the revoke, including one the compromised authenticator established — and revokes any pending customer-portal (magic-link) token. The browser that performed the revoke is re-issued a fresh cookie so it stays signed in; every other device is signed out on its next request. Previously only the credential row was deleted, leaving already-issued session cookies valid for their full 14-day life. · *Signing out everywhere also revokes pending magic-link tokens* — POST /account/logout already moved the session-revocation boundary forward to kill live sealed cookies; it now also revokes any unredeemed magic-link / customer-portal token for the account. An emailed sign-in link issued before the sign-out can no longer be redeemed to sign the account back in. · *Passkey registration creates the account only after the security key is enrolled* — The public passkey register-begin endpoint no longer writes a customer record before the WebAuthn ceremony completes. The pending sign-up (email + display name + challenge) is sealed into the tamper-proof challenge cookie, and the account is created only on a verified register-finish, where the duplicate-email check runs atomically to settle a begin/finish race. An abandoned or scripted register-begin can no longer reserve (squat) an email address or leave behind empty accounts. · *Passkey sign-in does not reveal whether an email is registered via timing* — Passkey login-begin now runs the same credential lookup on both paths — against the real account for a registered address, against a fixed non-matching id otherwise — so a registered address no longer takes measurably longer to respond. The challenge response shape was already identical on both paths; this closes the remaining response-time signal that could enumerate registered emails.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.77",
2
+ "version": "0.4.78",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -506,22 +506,61 @@ function create(opts) {
506
506
  var requested = _epochMs(input.occurred_at, "occurred_at");
507
507
  if (requested == null) requested = _now();
508
508
 
509
+ // Per-order idempotency is independent of current account status: a
510
+ // retry of an order that ALREADY has a charge returns the original
511
+ // charge even if the account was later suspended or closed — a retried
512
+ // capture must be a stable no-op, never a double-charge, a spurious
513
+ // NOT_ACTIVE, or a misleading limit error. Checked up front (BEFORE the
514
+ // active-account gate) and again on the atomic guard's refusal (to catch
515
+ // a charge that landed concurrently between the two reads).
516
+ function _idempotentReturn(prior) {
517
+ return {
518
+ id: prior.id,
519
+ customer_id: customerId,
520
+ kind: "charge",
521
+ order_id: orderId,
522
+ amount_minor: Number(prior.amount_minor),
523
+ balance_after_minor: Number(prior.balance_after_minor),
524
+ occurred_at: Number(prior.occurred_at),
525
+ idempotent: true,
526
+ };
527
+ }
528
+ async function _priorCharge() {
529
+ return (await query(
530
+ "SELECT id, amount_minor, balance_after_minor, occurred_at FROM credit_transactions " +
531
+ "WHERE customer_id = ?1 AND order_id = ?2 AND kind = 'charge' ORDER BY occurred_at ASC, id ASC LIMIT 1",
532
+ [customerId, orderId],
533
+ )).rows[0];
534
+ }
535
+ var existingCharge = await _priorCharge();
536
+ if (existingCharge) return _idempotentReturn(existingCharge);
537
+
509
538
  // Fast pre-check for a clean NOT_FOUND / NOT_ACTIVE error; the
510
- // credit-limit invariant itself is enforced atomically in the
511
- // INSERT guard below (re-checking status = 'active' there too),
512
- // so a charge can't slip past the limit by racing another charge.
539
+ // credit-limit invariant + per-order dedup are enforced atomically in
540
+ // the INSERT guard below (re-checking status = 'active' there too), so a
541
+ // charge can't slip past the limit or double-charge by racing another.
513
542
  await _requireActive(customerId, "chargeOrder");
514
543
  var written = await _appendTxnAtomic(
515
544
  customerId, "charge", orderId, null, requested,
516
545
  "?7", // amount_minor: the charge
517
546
  _BAL_SUB + " + ?7", // balance_after = outstanding + amount
547
+ // Cap check AND per-order dedup in one atomic guard: refuse a second
548
+ // charge for the same order so the same order_id can't be charged
549
+ // twice against the line (the bridge serializes statements, so the
550
+ // NOT EXISTS sees a prior committed charge).
518
551
  "(" + _BAL_SUB + " + ?7) <= COALESCE((SELECT credit_limit_minor " +
519
- "FROM credit_accounts WHERE customer_id = ?2 AND status = 'active'), -1)",
552
+ "FROM credit_accounts WHERE customer_id = ?2 AND status = 'active'), -1) " +
553
+ "AND NOT EXISTS (SELECT 1 FROM credit_transactions " +
554
+ "WHERE customer_id = ?2 AND order_id = ?4 AND kind = 'charge')",
520
555
  [amount],
521
556
  );
522
557
  if (!written) {
523
- // Guard refused. Re-read to report precisely: a status change
524
- // since the pre-check (rare race with suspend/close) vs the
558
+ // A charge for this order landed concurrently between the up-front
559
+ // check and the insert return it idempotently.
560
+ var raced = await _priorCharge();
561
+ if (raced) return _idempotentReturn(raced);
562
+ // Guard refused for the cap. Re-read to report precisely: a status
563
+ // change since the pre-check (rare race with suspend/close) vs the
525
564
  // limit being exceeded.
526
565
  var acctNow = await _readAccount(customerId);
527
566
  if (!acctNow || acctNow.status !== "active") {
package/lib/giftcards.js CHANGED
@@ -577,34 +577,39 @@ function create(opts) {
577
577
  "void": async function (id, opts2) {
578
578
  opts2 = opts2 || {};
579
579
  _uuid(id, "giftcard id");
580
- var r = await query(
581
- "SELECT id, status FROM giftcards WHERE id = ?1",
582
- [id],
580
+ var ts = _now();
581
+ // Atomic guarded transition active -> voided. The status='active' fence
582
+ // lives in SQL (mirroring redeem()), so a concurrent redeem or expire
583
+ // that already moved the card to a terminal state is NOT silently
584
+ // overwritten — the prior read-decide-write clobbered a racing
585
+ // redemption (discarding its remaining-balance debit) because the
586
+ // UPDATE omitted the status fence.
587
+ var upd = await query(
588
+ "UPDATE giftcards SET status = 'voided', updated_at = ?1 WHERE id = ?2 AND status = 'active'",
589
+ [ts, id],
583
590
  );
591
+ // `opts2.reason` is operator-supplied free-form; it's not persisted on
592
+ // the row (no schema column) but accepted so a future audit-log
593
+ // primitive can ride alongside without a surface change.
594
+ void opts2.reason;
595
+ if (Number(upd.rowCount || 0) === 1) {
596
+ var after = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
597
+ return after.rows[0] || null;
598
+ }
599
+ // Zero rows: the card is missing or already in a terminal state.
600
+ // Re-read to disambiguate and preserve the prior call's contract.
601
+ var r = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
584
602
  if (!r.rows.length) return null;
585
603
  var row = r.rows[0];
604
+ if (row.status === "voided") return row; // idempotent — already voided
586
605
  if (row.status === "redeemed") {
587
606
  var already = new Error("giftcards.void: card is fully redeemed");
588
607
  already.code = "GIFTCARD_ALREADY_REDEEMED";
589
608
  throw already;
590
609
  }
591
- if (row.status === "voided") {
592
- // Idempotent already voided; return the row as-is.
593
- var existing = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
594
- return existing.rows[0] || null;
595
- }
596
- var ts = _now();
597
- await query(
598
- "UPDATE giftcards SET status = 'voided', updated_at = ?1 WHERE id = ?2",
599
- [ts, id],
600
- );
601
- // `opts2.reason` is operator-supplied free-form; it's not
602
- // persisted on the row (no schema column) but accepted so a
603
- // future audit-log primitive can ride alongside without a
604
- // surface change.
605
- void opts2.reason;
606
- var after = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
607
- return after.rows[0] || null;
610
+ // Any other terminal state (e.g. expired) — return the row as-is rather
611
+ // than forcing it to voided over a terminal transition.
612
+ return row;
608
613
  },
609
614
 
610
615
  // Operator-facing list of issued cards — drives the admin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.77",
3
+ "version": "0.4.78",
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": {