@blamejs/blamejs-shop 0.4.76 → 0.4.77

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.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
+
11
13
  - 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.
12
14
 
13
15
  - v0.4.75 (2026-06-21) — **Bundled framework refreshed to blamejs 0.15.15 — more authentication checks fail closed and a blocked-traffic memory leak is fixed.** Refreshes the vendored framework the store is built on from blamejs 0.15.14 to 0.15.15, a correctness-and-hardening release. The protections the store already composes now refuse more malformed or unbound credentials, and a request-pipeline leak under sustained blocked traffic is fixed. The most directly felt change for an operator: when the bot guard, rate limiter, or CSRF check halts a request by writing its response without continuing the chain, the pipeline now settles instead of holding the request closure open forever — under a flood of blocked requests that previously grew memory without bound. On the sign-in surface, the passkey, OAuth, and any token verifiers the store routes through inherit stricter checks: an OAuth ID-token nonce is no longer skipped when empty, an authenticator's current certification status is honored rather than the highest it ever held, a JWT issuer presented as an array no longer satisfies a single-issuer expectation, and a malformed JOSE header raises a typed error instead of crashing the verifier. The content-security-policy builder now refuses a source value containing whitespace or a semicolon (so a malformed provider host can't smuggle a second directive), the SQL builder refuses an always-false equality against NULL and validates every element of an IN-list, and the WebSocket client bounds a fragmented message as it arrives rather than only at the final frame. No store configuration changes; upgrade to pick up the refreshed framework. **Changed:** *Bundled framework updated to blamejs 0.15.15* — The vendored framework is refreshed from 0.15.14 to 0.15.15. The store carries zero runtime npm dependencies and builds entirely on this bundled framework, so its correctness and hardening improvements apply to the store on upgrade with no configuration change. **Security:** *A blocked request no longer leaks its closure for the life of the process* — When the bot guard, rate limiter, CSRF check, or any middleware halts a request by writing the response without advancing the chain — the intended way to block — the request pipeline now settles its promise instead of leaving it pending forever. Previously each blocked request retained its request/response closure, so sustained blocked traffic grew memory without bound. The store's request lifecycle composes this pipeline directly, so the fix applies to every blocked request. · *Sign-in credential verifiers fail closed on more malformed inputs* — The authentication primitives the store routes sign-in through now refuse more unbound or malformed credentials: an OAuth ID-token nonce check is no longer skipped when the supplied nonce is empty, an authenticator's current certification status is honored over the highest level it ever held, a JWT issuer presented as an array no longer satisfies a single-issuer expectation, and a JOSE header or payload that decodes to a non-object raises a typed authentication error instead of an uncaught crash. · *The content-security-policy builder refuses a directive-injecting source* — A CSP source is a single non-whitespace token; the builder now rejects a source containing whitespace or a semicolon. Because sources are space-joined and directives semicolon-joined, a malformed value could otherwise have injected a second, live directive. The store's provider hosts (payment, captcha) are already clean single tokens, so this is defense against a future misconfiguration. · *The SQL builder refuses an always-false NULL equality and validates IN-list elements* — An equality against NULL (always false in SQL; the IS NULL form is required) is now refused rather than silently matching nothing, and every element of an IN-list is validated across all database backends. This narrows the room for a query that quietly returns the wrong rows. · *WebSocket fragmented-message reassembly is bounded as it arrives* — A fragmented incoming message is now bounded by its running total on each frame instead of only when the final frame arrives, so a peer can no longer stream unbounded continuation frames to exhaust memory before the size limit is checked, and a frame arriving after close is ignored.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.76",
2
+ "version": "0.4.77",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -757,9 +757,20 @@ function create(opts) {
757
757
  for (var i = 0; i < rows.length; i += 1) {
758
758
  var policy = _shapePolicy(rows[i]);
759
759
  var interval = SCHEDULE_INTERVAL_MS[policy.schedule];
760
- if (policy.last_run_at != null && (now - policy.last_run_at) < interval) {
761
- continue;
762
- }
760
+ // Atomic cadence claim the serialization point. Advancing
761
+ // last_run_at in a single conditional UPDATE (rather than an
762
+ // in-memory comparison followed by an unconditional stamp) means two
763
+ // overlapping ticks contend on the same row: only one UPDATE matches
764
+ // the "due" predicate, the loser changes zero rows and skips the
765
+ // policy, so the vendor PO is composed and submitted exactly once per
766
+ // cadence window. A null last_run_at fires immediately.
767
+ var claim = await query(
768
+ "UPDATE auto_replenish_policies SET last_run_at = ?1, updated_at = ?1 " +
769
+ "WHERE slug = ?2 AND archived_at IS NULL " +
770
+ "AND (last_run_at IS NULL OR last_run_at <= ?3)",
771
+ [now, policy.slug, now - interval],
772
+ );
773
+ if (Number(claim.rowCount || 0) !== 1) continue; // lost the claim / not yet due
763
774
  var runRow = await _runPolicyOnce(policy, now);
764
775
  summaries.push(_shapeRun(runRow));
765
776
  }
@@ -577,13 +577,31 @@ function create(opts) {
577
577
  var prevHash = current.api_key_hash;
578
578
  var now = _now();
579
579
 
580
- await query(
580
+ // Atomic compare-and-swap on the OBSERVED key + state. The JS guard
581
+ // above is a fast-fail; this predicate is the authoritative gate, so two
582
+ // concurrent rotations can't both commit. The loser observed the same
583
+ // api_key_hash but its UPDATE no longer matches (the winner already moved
584
+ // the key), changes zero rows, and refuses — preventing an over-issue
585
+ // where the second rotation clobbers the winner's fresh key and strands
586
+ // api_key_previous_hash pointing at a key no deployed worker holds.
587
+ var claim = await query(
581
588
  "UPDATE carrier_accounts SET api_key_hash = ?1, " +
582
589
  "api_key_previous_hash = ?2, api_secret_hash = ?3, " +
583
590
  "status = 'rotating', rotated_at = ?4, updated_at = ?5 " +
584
- "WHERE id = ?6",
585
- [hashKey, prevHash, hashSec, now, now, id],
591
+ "WHERE id = ?6 AND status IN ('active','rotating') AND api_key_hash = ?7",
592
+ [hashKey, prevHash, hashSec, now, now, id, prevHash],
586
593
  );
594
+ if (Number(claim.rowCount || 0) !== 1) {
595
+ var lost = await _getRaw(id);
596
+ var conflict = new Error(
597
+ (lost && lost.status === "disabled")
598
+ ? "carrier-accounts.rotateCredentials: refused — account is disabled"
599
+ : "carrier-accounts.rotateCredentials: refused — a concurrent rotation won the claim"
600
+ );
601
+ conflict.code = (lost && lost.status === "disabled")
602
+ ? "CARRIER_ACCOUNT_DISABLED" : "CARRIER_ACCOUNT_ROTATION_CONFLICT";
603
+ throw conflict;
604
+ }
587
605
 
588
606
  var projected = _project(await _getRaw(id));
589
607
  projected.plaintext = {
package/lib/checkout.js CHANGED
@@ -1976,8 +1976,16 @@ function create(deps) {
1976
1976
  var cap = await paypal.captureOrder(paypalOrderId);
1977
1977
  var captureId = null;
1978
1978
  try { captureId = cap.purchase_units[0].payments.captures[0].id; } catch (_e) { captureId = null; }
1979
- var completed = cap && (cap.status === "COMPLETED" ||
1980
- (captureId && cap.purchase_units[0].payments.captures[0].status === "COMPLETED"));
1979
+ // Settlement is a property of the CAPTURE, not the order envelope. The
1980
+ // order-level `cap.status` can read COMPLETED while the capture itself is
1981
+ // still PENDING (e.g. an under-review / delayed-settlement payment), so
1982
+ // gating on the order status would mark the order paid before funds
1983
+ // settle. Gate solely on the per-capture status; any non-COMPLETED
1984
+ // capture (PENDING / DECLINED / FAILED) leaves the order `pending` and
1985
+ // returns handled:false so the route can surface the unsettled state.
1986
+ var captureStatus = null;
1987
+ try { captureStatus = cap.purchase_units[0].payments.captures[0].status; } catch (_e3) { captureStatus = null; }
1988
+ var completed = captureStatus === "COMPLETED";
1981
1989
  if (completed && o.status === "pending") {
1982
1990
  // Persist the capture id on the order row — refunds dial
1983
1991
  // /v2/payments/captures/<capture-id>/refund, NOT the PayPal order id
@@ -455,23 +455,20 @@ function create(opts) {
455
455
  gifted_by: existing.giver_customer_id,
456
456
  },
457
457
  };
458
- var created = await subscriptionsHandle.create(createInput);
459
- if (!created || typeof created !== "object" || typeof created.id !== "string" || !created.id.length) {
460
- throw new Error("subscriptionGifts.redeemGift: subscriptions.create must return an object with a string id");
461
- }
462
- var subscriptionId = created.id;
463
-
464
- // FSM transition: pending|delivered -> redeemed. Guarded by the
465
- // current status so a concurrent redeem (token leaked + presented
466
- // twice within ms) refuses cleanly.
467
- var r = await query(
468
- "UPDATE subscription_gifts SET status = 'redeemed', redeemed_at = ?1, redeemed_subscription_id = ?2 " +
469
- "WHERE id = ?3 AND status IN ('pending','delivered')",
470
- [now, subscriptionId, existing.id],
458
+ // FSM transition FIRST — the atomic claim must gate the irreversible
459
+ // subscription mint, not trail it. Claim pending|delivered -> redeemed in
460
+ // one conditional UPDATE; only the winner (rowCount===1) goes on to mint.
461
+ // A concurrent redeem of the same token loses the claim, mints nothing,
462
+ // and refuses cleanly — so a leaked link presented twice can never
463
+ // double-create subscriptions. The subscription id is backfilled after the
464
+ // mint succeeds.
465
+ var claim = await query(
466
+ "UPDATE subscription_gifts SET status = 'redeemed', redeemed_at = ?1, redeemed_subscription_id = NULL " +
467
+ "WHERE id = ?2 AND status IN ('pending','delivered')",
468
+ [now, existing.id],
471
469
  );
472
- if (Number(r.rowCount || 0) === 0) {
473
- // Lost the race — re-read so we surface the actual post-race
474
- // status to the caller.
470
+ if (Number(claim.rowCount || 0) !== 1) {
471
+ // Lost the race — re-read so we surface the actual post-race status.
475
472
  var after = await _getRaw(existing.id);
476
473
  var raceErr = new Error(
477
474
  "subscriptionGifts.redeemGift: gift status changed to '" +
@@ -480,6 +477,36 @@ function create(opts) {
480
477
  raceErr.code = "SUBSCRIPTION_GIFT_ALREADY_REDEEMED";
481
478
  throw raceErr;
482
479
  }
480
+
481
+ // Winner-only mint. If create() fails (or returns a bad shape), revert the
482
+ // claim so the gift can be redeemed again — nothing was minted, and the
483
+ // gift must not be stranded in 'redeemed' with no subscription.
484
+ function _revertClaim() {
485
+ return query(
486
+ "UPDATE subscription_gifts SET status = ?1, redeemed_at = NULL, redeemed_subscription_id = NULL " +
487
+ "WHERE id = ?2 AND status = 'redeemed' AND redeemed_subscription_id IS NULL",
488
+ [status, existing.id],
489
+ );
490
+ }
491
+ var created;
492
+ try {
493
+ created = await subscriptionsHandle.create(createInput);
494
+ } catch (e) {
495
+ await _revertClaim();
496
+ throw e;
497
+ }
498
+ if (!created || typeof created !== "object" || typeof created.id !== "string" || !created.id.length) {
499
+ await _revertClaim();
500
+ throw new Error("subscriptionGifts.redeemGift: subscriptions.create must return an object with a string id");
501
+ }
502
+ var subscriptionId = created.id;
503
+
504
+ // Backfill the minted subscription id onto the already-claimed row.
505
+ await query(
506
+ "UPDATE subscription_gifts SET redeemed_subscription_id = ?1 " +
507
+ "WHERE id = ?2 AND status = 'redeemed' AND redeemed_subscription_id IS NULL",
508
+ [subscriptionId, existing.id],
509
+ );
483
510
  return {
484
511
  gift: _hydrateGift(await _getRaw(existing.id)),
485
512
  subscription_id: subscriptionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.76",
3
+ "version": "0.4.77",
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": {