@blamejs/blamejs-shop 0.4.22 → 0.4.23

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.23 (2026-06-06) — **A stolen sign-in cookie stops working on another device, payment-provider outages fail fast and recover, replayed payment webhooks are refused, and the staff audit log is rewrite-evident.** A security-hardening release. The signed-in session cookie now carries a device fingerprint — a cookie lifted from one browser softly signs out instead of working anywhere for two weeks. Payment-provider calls run behind a circuit breaker, so a provider brown-out fails fast into the existing payment-unavailable page and recovers automatically instead of hanging every checkout. A captured payment-webhook payload can no longer be replayed inside the signature tolerance window. And the staff-activity audit log is anchored with post-quantum checkpoint signatures, with a verification endpoint that detects any rewrite of its history. **Security:** *The sign-in cookie is pinned to the device that signed in* — The signed-in session cookie was tamper-proof but portable — exfiltrated once, it worked from any device until expiry. Signing in now embeds a fingerprint of the signing-in browser inside the sealed cookie, and every authenticated request re-checks it in constant time. A mismatch signs the visitor out gently to the sign-in page rather than failing the request mid-page; network changes don't trip it (the fingerprint deliberately excludes the IP address), and sessions issued before this release continue to work until they expire naturally. · *Payment-provider calls are circuit-broken and retried where safe* — A payment-provider brown-out previously failed every checkout serially, each waiting out the full network timeout. Provider calls now run behind a circuit breaker: repeated failures trip it, subsequent checkouts fail fast into the existing payment-unavailable page, and the circuit closes again when the provider recovers. Idempotent calls — reads and writes carrying an idempotency key — retry through brief blips; non-idempotent calls never retry. The TLS configuration for provider connections is unchanged. · *Replayed payment webhooks are refused* — A captured, validly-signed payment-webhook payload could be replayed within the signature timestamp tolerance and re-drive order transitions. Each event is now recorded on first receipt and a replay of the same event answers as an already-processed no-op — enforced with a single atomic write, so two concurrent deliveries of the same event also collapse to one. Replay records expire with the signature tolerance window. · *The staff audit log detects history rewrites* — Staff-activity audit rows were hash-linked, which catches tampering with a single row but not a consistent rewrite of the whole chain. The chain is now anchored with periodic checkpoint signatures (ML-DSA, the same signing the framework audit chain uses), and `/admin/operators/audit/verify` reports both link integrity and checkpoint verification — a rewritten history fails the check. Stores that disable destructive operations behind a second approver were also evaluated: with one active operator the gate would deadlock the store, so gift-card voids, refunds, and operator disables remain single-approver — role-gated, bounded, reversible, and audited — with the stance and its revisit condition documented in the module.
12
+
11
13
  - v0.4.22 (2026-06-06) — **Privacy export covers every table, erasure revokes the login, one-click unsubscribe actually works, and bounces feed the suppression list.** A privacy and email-integrity release. The data-subject export now includes reviews, consent history, wishlist, surveys, and recently-viewed data — with a manifest that makes any absent section visible. Erasing a customer also deletes their passkeys and social-login links and revokes live sessions, so a deleted account can no longer sign in. The one-click unsubscribe that mail clients invoke from their native button now works (it pointed at a route that didn't exist), a new intake endpoint feeds provider bounce and complaint webhooks into the marketing suppression list, support-ticket edge cases are fixed, and the CSV exports share one complete injection-neutralizer. **Added:** *Bounce and complaint webhook intake* — A new `POST /api/webhooks/mail-bounce` endpoint accepts bounce and complaint webhooks from Postmark, SES, or Resend (selectable per request). Spam complaints and permanently-dead addresses land on the marketing suppression list — transactional mail still flows — and campaign metrics gain the bounce events they were built to read. The endpoint is armed only when `MAIL_BOUNCE_SECRET` is set and the provider presents it in the `x-mail-bounce-secret` header; unconfigured, it answers 503 and accepts nothing. **Fixed:** *The data-subject export includes every table that holds the customer* — The export bundle silently omitted reviews, consent history, wishlist items, survey responses, and recently-viewed data. All five are now included, and the bundle carries a completeness manifest listing every section as exported, empty, or absent — an omission is visible rather than silent. · *Erasure revokes the customer's ability to sign in* — Deleting a customer scrubbed their profile but left passkeys, Google/Apple login links, and live sessions intact — the "deleted" account could sign right back in. Erasure now deletes the passkeys and social-login links, revokes live portal sessions, and tombstones the email lookup hash irreversibly, while the anonymized record survives for order-history integrity. · *One-click unsubscribe works from the mail client's native button* — The unsubscribe headers stamped on every campaign pointed at a route that didn't exist, and the unsubscribe endpoint read its token from the form body — which a mail client's native one-click POST never carries. The header now points at the real endpoint and the token rides the URL, so the RFC 8058 one-click flow a mail client fires actually unsubscribes. The browser confirmation page is unchanged. · *Support tickets: reopen on customer reply, atomic tags, honest first-response time* — A customer reply to a resolved ticket silently disappeared from every operator queue — it now reopens the ticket with a fresh activity timestamp. Concurrent tag edits no longer overwrite each other (tag changes are single-statement updates). And an internal-only operator note no longer counts as the first response — only a reply the customer can actually see stamps the first-response time. · *CSV exports share one complete injection neutralizer* — The order-export and segment-members CSV exports each carried their own formula-injection defense, and both missed the tab, carriage-return, newline, and pipe vectors. Both now compose the framework's CSV cell guard, which covers the full vector set — including signed numerics, which the previous neutralizers deliberately exempted.
12
14
 
13
15
  - v0.4.21 (2026-06-06) — **Subscription changes reach the payment processor, smart collections match real products, refunds claw back earned points, and the remaining balance races are closed.** A correctness release across the commerce surfaces. Changing a subscription's quantity now updates the processor before the local record — what the customer sees is what they're billed — and a cadence change on a processor-backed plan is honestly refused rather than silently ignored. Smart collections, which matched zero products in production because their rules read fields the catalog rows never carried, now evaluate against the real product data. Loyalty points earned on a purchase are reversed when the order is refunded or cancelled. And the loyalty, store-credit, gift-registry, and gift-card-ledger write paths that could lose updates or oversell under concurrency now use atomic guards. **Fixed:** *Subscription quantity changes reach the payment processor* — Changing a subscription's quantity updated the local record and showed a success message while the processor kept billing the original amount. The change is now pushed to the processor first — a processor failure leaves the local record untouched and surfaces the error, so the customer-visible state and the billed state can no longer diverge. Changing delivery frequency on a processor-backed subscription is refused with honest guidance (the billing cadence is bound to the plan's price and cannot be re-cadenced in place); self-managed local subscriptions keep their frequency controls. Self-manage controls also now respect the processor's status — a subscription the processor reports as cancelled or expired shows its state instead of live controls. · *Smart collections match real catalog products* — Smart-collection rules read fields like tags, price, and stock from each product row — fields the real catalog listing never carried, so every smart collection matched zero products in production even though admin previews built on richer mock rows looked right. Rule evaluation now joins the real tag, category, vendor, price, and inventory data onto each product page, and the admin preview routes through the same path the storefront uses. Smart-collection pages also stop re-walking the entire catalog on every paginated request — the matched set is briefly cached and a rule edit invalidates it. · *Refunds and cancellations reverse the loyalty points the order earned* — Points awarded when an order was paid survived a refund or cancellation — buying, earning, and refunding farmed points indefinitely. The earn record is now claimed atomically on the order's refund or cancel transition and the awarded points are clawed back from the balance, floored at zero when some were already spent. The reversal is idempotent (a re-delivered payment webhook reverses exactly once) and lifetime points — which drive tier — are deliberately untouched. · *Balance and inventory-adjacent races closed across loyalty, store credit, gift registry, and gift-card ledger* — Loyalty earn and adjust used read-then-write absolute updates that could lose concurrent updates; the gift-registry purchase check could oversell a registry item under two simultaneous purchases; and the gift-card ledger's overdraft check could let two concurrent debits both pass. All of these now use single-statement conditional writes that refuse cleanly when the guard fails. Store-credit expiry sweeps also stop under-expiring when operator-initiated deductions exist — the sweep now keys on its own prior output rather than netting all expiry rows together. · *Search-ranking click-through metrics are bounded and attributed* — The ranking metrics screen could show click-through rates above 100%, and clicks were attributed from the query string alone — trivially spoofable. A click now only counts when the same session recorded a real impression for that query, and displayed rates are bounded at 100%.
package/lib/admin.js CHANGED
@@ -12909,6 +12909,51 @@ function mount(router, deps) {
12909
12909
  },
12910
12910
  ));
12911
12911
 
12912
+ // Operator audit-chain integrity check (ops/tooling, bearer JSON).
12913
+ // Walks the hash linkage (verifyChain) AND re-derives every signed
12914
+ // checkpoint (verifyCheckpoints) so an operator can confirm the chain
12915
+ // is both internally consistent AND anchored — the checkpoint layer is
12916
+ // what catches a full-chain rewrite the hash linkage alone can't.
12917
+ // Read-only: any failure to read degrades to an unavailable verdict,
12918
+ // never a 500. Mounts only when the audit log is wired.
12919
+ if (operatorAuditLog && typeof operatorAuditLog.verifyChain === "function") {
12920
+ router.get("/admin/operators/audit/verify", _pageOrApi(true,
12921
+ R(async function (req, res) {
12922
+ var chain = null;
12923
+ var checkpoints = null;
12924
+ try { chain = await operatorAuditLog.verifyChain(); }
12925
+ catch (_e) { chain = { ok: false, reason: "verify-unavailable" }; }
12926
+ if (typeof operatorAuditLog.verifyCheckpoints === "function") {
12927
+ try { checkpoints = await operatorAuditLog.verifyCheckpoints(); }
12928
+ catch (_e) { checkpoints = { ok: false, reason: "verify-unavailable" }; }
12929
+ }
12930
+ _json(res, 200, {
12931
+ chain: chain,
12932
+ checkpoints: checkpoints,
12933
+ signing_available: !!(operatorAuditLog.signingAvailable && operatorAuditLog.signingAvailable()),
12934
+ });
12935
+ return false;
12936
+ }),
12937
+ // No dedicated console screen — the operators page links here for
12938
+ // tooling; a browser hit gets the same JSON verdict.
12939
+ async function (req, res) {
12940
+ var chain = null;
12941
+ var checkpoints = null;
12942
+ try { chain = await operatorAuditLog.verifyChain(); }
12943
+ catch (_e) { chain = { ok: false, reason: "verify-unavailable" }; }
12944
+ if (typeof operatorAuditLog.verifyCheckpoints === "function") {
12945
+ try { checkpoints = await operatorAuditLog.verifyCheckpoints(); }
12946
+ catch (_e) { checkpoints = { ok: false, reason: "verify-unavailable" }; }
12947
+ }
12948
+ _json(res, 200, {
12949
+ chain: chain,
12950
+ checkpoints: checkpoints,
12951
+ signing_available: !!(operatorAuditLog.signingAvailable && operatorAuditLog.signingAvailable()),
12952
+ });
12953
+ },
12954
+ ));
12955
+ }
12956
+
12912
12957
  // Create an operator. The first operator is created by the ADMIN_API_KEY
12913
12958
  // owner; thereafter any owner can. `created_by` is the acting operator's
12914
12959
  // id (or the "owner" sentinel for the break-glass key).
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.22",
2
+ "version": "0.4.23",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/checkout.js CHANGED
@@ -246,6 +246,60 @@ function create(deps) {
246
246
  var backorder = deps.backorder || null;
247
247
  var preorder = deps.preorder || null;
248
248
 
249
+ // Optional inbound-webhook replay defense. A validly-signed Stripe event
250
+ // can be replayed verbatim inside the ±5-minute signature tolerance: the
251
+ // signature still verifies, so signature-checking alone does not stop a
252
+ // replay, and the downstream order-state idempotency is keyed on order
253
+ // state (not event identity) so a refund/cancel replay or a replay that
254
+ // races the first delivery slips past it. When `webhookReplayQuery` (a
255
+ // D1 query fn) is wired, every verified Stripe event id is atomically
256
+ // recorded with an INSERT ... ON CONFLICT DO NOTHING; a replay loses the
257
+ // PRIMARY-KEY race and is treated as an already-processed no-op. The
258
+ // store composes b.nonceStore over a D1-backed atomic backend rather
259
+ // than a hand-rolled has/set (which would race). Absent — the handler is
260
+ // byte-identical to the un-wired flow (order-state idempotency still
261
+ // covers the common re-delivery), so this is additive, never required.
262
+ var webhookReplayQuery = (typeof deps.webhookReplayQuery === "function")
263
+ ? deps.webhookReplayQuery : null;
264
+ var STRIPE_REPLAY_TTL_MS = b.constants.TIME.minutes(5); // matches the signature tolerance window
265
+ var _stripeReplayStore = null;
266
+ function _stripeReplay() {
267
+ if (!webhookReplayQuery) return null;
268
+ if (!_stripeReplayStore) {
269
+ // Custom b.nonceStore backend: the atomicity lives in the D1
270
+ // INSERT ... ON CONFLICT DO NOTHING (the PRIMARY KEY race decides
271
+ // first-seen vs replay), so the check + insert can never interleave.
272
+ _stripeReplayStore = b.nonceStore.create({
273
+ backend: {
274
+ checkAndInsert: async function (eventId, expireAt) {
275
+ var nowMs = Date.now();
276
+ var r = await webhookReplayQuery(
277
+ "INSERT INTO stripe_webhook_events (event_id, first_seen_at, expires_at) " +
278
+ "VALUES (?1, ?2, ?3) ON CONFLICT (event_id) DO NOTHING",
279
+ [eventId, nowMs, expireAt],
280
+ );
281
+ // rowCount/changes === 1 → first sighting (recorded); 0 → replay.
282
+ var changes = (r && r.meta && typeof r.meta.changes === "number") ? r.meta.changes
283
+ : (r && typeof r.rowCount === "number") ? r.rowCount
284
+ : (r && typeof r.changes === "number") ? r.changes : 0;
285
+ return changes > 0;
286
+ },
287
+ purgeExpired: async function () {
288
+ var r = await webhookReplayQuery(
289
+ "DELETE FROM stripe_webhook_events WHERE expires_at < ?1",
290
+ [Date.now()],
291
+ );
292
+ if (r && r.meta && typeof r.meta.changes === "number") return r.meta.changes;
293
+ if (r && typeof r.rowCount === "number") return r.rowCount;
294
+ if (r && typeof r.changes === "number") return r.changes;
295
+ return 0;
296
+ },
297
+ },
298
+ });
299
+ }
300
+ return _stripeReplayStore;
301
+ }
302
+
249
303
  // Reprice a list of cart lines through the quantity-discount engine.
250
304
  // Returns a shallow copy with `unit_amount_minor` overwritten by the
251
305
  // discounted unit for each line's SKU at its quantity. A line whose
@@ -1261,6 +1315,22 @@ function create(deps) {
1261
1315
  var event = v.event;
1262
1316
  var eventType = event && event.type;
1263
1317
 
1318
+ // Replay defense — atomically claim this event id the moment the
1319
+ // signature verifies, BEFORE any subscription routing or state
1320
+ // transition. A replayed (already-seen) event id loses the
1321
+ // PRIMARY-KEY race and short-circuits to a processed no-op so no
1322
+ // transition, refund-mirror, or subscription update runs twice. A
1323
+ // store error fails CLOSED inside the nonceStore (returns
1324
+ // not-fresh) — a replay is indistinguishable from a wiped store, so
1325
+ // refusing is the safe default. No-op when the store isn't wired.
1326
+ var replay = _stripeReplay();
1327
+ if (replay && event && typeof event.id === "string" && event.id.length > 0) {
1328
+ var fresh = await replay.checkAndInsert(event.id, Date.now() + STRIPE_REPLAY_TTL_MS);
1329
+ if (!fresh) {
1330
+ return { handled: true, event_type: eventType || null, skipped: "replay", event_id: event.id };
1331
+ }
1332
+ }
1333
+
1264
1334
  // Subscription events route to the subscriptions primitive
1265
1335
  // (if wired). The one-time-order PaymentIntent path below
1266
1336
  // stays unchanged.
@@ -87,9 +87,60 @@
87
87
  *
88
88
  * Storage: `migrations-d1/0213_operator_accounts.sql`.
89
89
  *
90
+ * Dual-control (two-operator approval) — evaluated, deliberately NOT
91
+ * wired in v1:
92
+ *
93
+ * `b.dualControl` raises a destructive op to "two distinct named
94
+ * operators must approve before it runs." It is the right control for
95
+ * a store run by a TEAM, but it is structurally a two-actor M-of-N
96
+ * gate — `create()` hard-validates `minApprovers >= 2`, and there is
97
+ * no auto-satisfy-on-single-operator path. The dominant deployment of
98
+ * this storefront is a SINGLE owner; on such a store a dual-control
99
+ * gate would DEADLOCK the destructive op forever — there is no second
100
+ * operator to approve, so the op can never consume its grant. Wiring
101
+ * it unconditionally is therefore a worse failure than the
102
+ * single-operator risk it would close. It is also a two-request async
103
+ * workflow (request → a different actor approves → consume), which
104
+ * does not fit the synchronous single-POST shape these admin actions
105
+ * have today.
106
+ *
107
+ * Per destructive op, the v1 stance:
108
+ *
109
+ * - gift-card void (POST /admin/gift-cards/:id/void): recoverable —
110
+ * void is a status flip that PRESERVES the balance (it burns no
111
+ * value), so re-issuing or restoring the card's status makes a
112
+ * mistaken void recoverable, and the giftcard ledger records the
113
+ * acting operator. The cost of a deadlock-on-single-owner
114
+ * outweighs the benefit. Stance: role-gate (`orders.write`) +
115
+ * audit, no dual-control.
116
+ *
117
+ * - refunds (POST /admin/orders/:id/refund, /admin/returns/:id/
118
+ * refund): real money out, but bounded by the order's captured
119
+ * amount (the payment primitive refuses to over-refund a PI), and
120
+ * every refund is idempotency-keyed + audited with the operator
121
+ * id. The damage ceiling is one order's total, not the whole
122
+ * book. Stance: role-gate (`orders.write`) + audit, no
123
+ * dual-control.
124
+ *
125
+ * - operator disable (POST /admin/operators/:id/disable): the
126
+ * highest-blast-radius op (it can revoke a co-owner), but it is
127
+ * REVERSIBLE in one click (`/enable`), gated on `operators.manage`
128
+ * (owner-only), and audited. A malicious self-lockout still leaves
129
+ * the `ADMIN_API_KEY` break-glass owner credential working.
130
+ * Stance: role-gate + audit, no dual-control.
131
+ *
132
+ * Re-open condition: when a store carries TWO OR MORE active `owner`/
133
+ * `manager` operators, dual-control becomes wire-able WITHOUT the
134
+ * deadlock risk — gate the approval flow on `listAccounts({ status:
135
+ * "active" })` length >= 2, auto-satisfy below that, and move the
136
+ * destructive POST to a request/approve/consume sequence keyed off a
137
+ * b.cache grant. That is the natural follow-up the moment a real
138
+ * multi-operator store exists; it is not defensible to ship a control
139
+ * that bricks the single-operator common case to pre-empt it.
140
+ *
90
141
  * @primitive operatorAccounts
91
142
  * @related operatorRoles, operatorSessions, operatorAuditLog,
92
- * b.password, b.crypto, b.guardEmail, b.uuid
143
+ * b.password, b.crypto, b.guardEmail, b.uuid, b.dualControl
93
144
  */
94
145
 
95
146
  var EMAIL_NAMESPACE = "operator-email";
@@ -84,6 +84,11 @@ var SHA3_512_HEX_LEN = 128;
84
84
 
85
85
  var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
86
86
 
87
+ // Canonical prefix for the bytes a checkpoint signs. Stable forever —
88
+ // changing it invalidates every prior checkpoint signature. Mirrors the
89
+ // framework audit chain's "blamejs-audit-checkpoint-v1" format.
90
+ var CHECKPOINT_FORMAT = "blamejs-operator-audit-checkpoint-v1";
91
+
87
92
  var ALLOWED_ACTOR_TYPES = Object.freeze(["operator", "system", "app"]);
88
93
  var ALLOWED_UA_CLASSES = Object.freeze([
89
94
  "browser",
@@ -581,6 +586,152 @@ function create(opts) {
581
586
  return { ok: true, rows_verified: rows.length, last_hash: prevHash };
582
587
  }
583
588
 
589
+ // -- checkpoint anchoring ----------------------------------------------
590
+ //
591
+ // The hash chain is tamper-EVIDENT against a single edited/deleted row,
592
+ // but an attacker who can rewrite the whole table can re-hash from a
593
+ // forged genesis and leave the chain internally consistent. Anchoring
594
+ // the tip with a post-quantum signature over the head row_hash — keyed
595
+ // off the framework's b.auditSign keypair, whose private half never
596
+ // touches D1 — closes that hole: a full-chain rewrite can't reproduce a
597
+ // valid checkpoint signature over the rewritten tip.
598
+ //
599
+ // checkpoint() signs the CURRENT head and inserts an anchor row.
600
+ // verifyCheckpoints() re-derives every anchor's signature and confirms
601
+ // the anchored row still carries the signed hash. Both no-op gracefully
602
+ // when b.auditSign isn't initialized (the chain stays hash-linked; the
603
+ // signature layer is simply absent) so a deployment without the
604
+ // audit-signing keypair still records + verifies the linkage.
605
+
606
+ function _auditSignReady() {
607
+ try { b.auditSign.getPublicKeyFingerprint(); return true; }
608
+ catch (_e) { return false; }
609
+ }
610
+
611
+ // Canonical signed bytes for a checkpoint. STABLE FOREVER — changing
612
+ // this layout invalidates every prior checkpoint signature.
613
+ function _checkpointPayload(atRowId, atOccurredAt, atRowHash, createdAt) {
614
+ return Buffer.from(
615
+ CHECKPOINT_FORMAT + "\n" +
616
+ atRowId + "\n" +
617
+ String(atOccurredAt) + "\n" +
618
+ atRowHash + "\n" +
619
+ String(createdAt),
620
+ "utf8",
621
+ );
622
+ }
623
+
624
+ // Anchor the current chain tip with a fresh PQC signature. Returns the
625
+ // inserted checkpoint row, or null when the chain is empty (nothing to
626
+ // anchor) or `skipIfUnchanged` and the tip hasn't advanced since the
627
+ // last checkpoint. Throws OperatorAuditCheckpointSigningUnavailable-
628
+ // shaped TypeError only if asked to checkpoint without a signing key —
629
+ // callers that want a soft skip check `signingAvailable()` first.
630
+ async function checkpoint(checkpointOpts) {
631
+ checkpointOpts = checkpointOpts || {};
632
+ if (!_auditSignReady()) {
633
+ throw new TypeError("operatorAuditLog.checkpoint: b.auditSign is not initialized — " +
634
+ "no signing key to anchor the chain with");
635
+ }
636
+ var head = await _currentHead();
637
+ if (head.id === null) return null; // empty chain — nothing to anchor
638
+
639
+ if (checkpointOpts.skipIfUnchanged) {
640
+ var last = await query(
641
+ "SELECT at_row_hash FROM operator_audit_checkpoints " +
642
+ "ORDER BY created_at DESC, id DESC LIMIT 1",
643
+ [],
644
+ );
645
+ if (last.rows.length && last.rows[0].at_row_hash === head.row_hash) {
646
+ return null; // already anchored at this exact tip
647
+ }
648
+ }
649
+
650
+ var createdAt = _now();
651
+ var payload = _checkpointPayload(head.id, head.occurred_at, head.row_hash, createdAt);
652
+ var signature = b.auditSign.sign(payload).toString("base64");
653
+ var pubFp = b.auditSign.getPublicKeyFingerprint();
654
+ var id = b.uuid.v7();
655
+
656
+ await query(
657
+ "INSERT INTO operator_audit_checkpoints " +
658
+ "(id, created_at, at_row_id, at_occurred_at, at_row_hash, signature, public_key_fingerprint) " +
659
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
660
+ [id, createdAt, head.id, head.occurred_at, head.row_hash, signature, pubFp],
661
+ );
662
+
663
+ return {
664
+ id: id,
665
+ created_at: createdAt,
666
+ at_row_id: head.id,
667
+ at_occurred_at: head.occurred_at,
668
+ at_row_hash: head.row_hash,
669
+ public_key_fingerprint: pubFp,
670
+ };
671
+ }
672
+
673
+ // Walk every checkpoint oldest-first, re-derive its signature, and
674
+ // confirm the anchored row still carries the signed hash. Reports the
675
+ // first divergence — a forged signature, a key-rotation fingerprint
676
+ // mismatch, a vanished anchor row, or a rewritten tip whose hash no
677
+ // longer matches what was signed.
678
+ async function verifyCheckpoints() {
679
+ if (!_auditSignReady()) {
680
+ return { ok: true, checkpoints_verified: 0, reason: "audit-sign-unavailable" };
681
+ }
682
+ var r = await query(
683
+ "SELECT * FROM operator_audit_checkpoints ORDER BY created_at ASC, id ASC",
684
+ [],
685
+ );
686
+ var rows = r.rows;
687
+ if (!rows.length) return { ok: true, checkpoints_verified: 0 };
688
+
689
+ var currentFp = b.auditSign.getPublicKeyFingerprint();
690
+ var currentPub = b.auditSign.getPublicKey();
691
+
692
+ for (var i = 0; i < rows.length; i += 1) {
693
+ var c = rows[i];
694
+ // Only the current key verifies (no key-history table). A rotation
695
+ // without re-signing surfaces here rather than silently failing.
696
+ if (c.public_key_fingerprint !== currentFp) {
697
+ return {
698
+ ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
699
+ reason: "public key fingerprint mismatch (key rotated without re-signing?)",
700
+ expected: currentFp, actual: c.public_key_fingerprint,
701
+ };
702
+ }
703
+ var payload = _checkpointPayload(c.at_row_id, Number(c.at_occurred_at), c.at_row_hash, Number(c.created_at));
704
+ var sigBuf;
705
+ try { sigBuf = Buffer.from(c.signature, "base64"); }
706
+ catch (_e) {
707
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
708
+ reason: "checkpoint signature not decodable" };
709
+ }
710
+ if (!b.auditSign.verify(payload, sigBuf, currentPub)) {
711
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
712
+ reason: "post-quantum signature failed" };
713
+ }
714
+ // The anchored row must still exist AND still carry the signed hash.
715
+ // A full-chain rewrite changes row_hash → this mismatch is the catch.
716
+ var anchored = await query(
717
+ "SELECT row_hash FROM operator_audit_events WHERE id = ?1 LIMIT 1",
718
+ [c.at_row_id],
719
+ );
720
+ if (!anchored.rows.length) {
721
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
722
+ reason: "anchored audit row missing (id=" + c.at_row_id + ")" };
723
+ }
724
+ if (anchored.rows[0].row_hash !== c.at_row_hash) {
725
+ return {
726
+ ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
727
+ reason: "anchored row_hash mismatch — operator_audit_events was rewritten",
728
+ expected: c.at_row_hash, actual: anchored.rows[0].row_hash,
729
+ };
730
+ }
731
+ }
732
+ return { ok: true, checkpoints_verified: rows.length };
733
+ }
734
+
584
735
  return {
585
736
  MAX_ACTOR_ID_LEN: MAX_ACTOR_ID_LEN,
586
737
  MAX_ACTION_LEN: MAX_ACTION_LEN,
@@ -593,12 +744,20 @@ function create(opts) {
593
744
  ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
594
745
  ZERO_HASH: ZERO_HASH,
595
746
 
596
- record: record,
597
- listByActor: listByActor,
598
- listByResource: listByResource,
599
- searchAction: searchAction,
600
- chainHead: chainHead,
601
- verifyChain: verifyChain,
747
+ record: record,
748
+ listByActor: listByActor,
749
+ listByResource: listByResource,
750
+ searchAction: searchAction,
751
+ chainHead: chainHead,
752
+ verifyChain: verifyChain,
753
+ // Checkpoint anchoring — sign the chain tip out-of-band so a
754
+ // full-chain rewrite (which verifyChain alone can't catch) becomes
755
+ // detectable. `signingAvailable()` lets callers soft-skip when the
756
+ // audit-signing key isn't initialized.
757
+ checkpoint: checkpoint,
758
+ verifyCheckpoints: verifyCheckpoints,
759
+ signingAvailable: _auditSignReady,
760
+ CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
602
761
  };
603
762
  }
604
763
 
@@ -614,4 +773,5 @@ module.exports = {
614
773
  ALLOWED_ACTOR_TYPES: ALLOWED_ACTOR_TYPES,
615
774
  ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
616
775
  ZERO_HASH: ZERO_HASH,
776
+ CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
617
777
  };
package/lib/payment.js CHANGED
@@ -8,9 +8,15 @@
8
8
  * verification (HMAC-SHA256 over `<timestamp>.<body>` with
9
9
  * `whsec_...` secret, ±5 min tolerance, constant-time compare) and
10
10
  * outbound API calls (PaymentIntent create / retrieve / confirm /
11
- * cancel + Refund) composed on `b.httpClient` (SSRF-gated, retried,
12
- * circuit-broken, ALPN HTTP/2). No `stripe` npm dep every byte is
13
- * either node built-in or vendored blamejs primitive.
11
+ * cancel + Refund) on `b.httpClient` (SSRF-gated, response-capped,
12
+ * ALPN HTTP/2). Each adapter instance wraps its dials in a per-upstream
13
+ * `b.circuitBreaker` plus a bounded `b.retry` (the latter only on
14
+ * idempotent dials — GET reads + idempotency-keyed writes — so a
15
+ * transient blip never re-sends a one-shot charge). While the breaker is
16
+ * open the dial fast-fails with `code: "CIRCUIT_OPEN"` BEFORE the
17
+ * request, so checkout renders the recoverable payment-unavailable page
18
+ * rather than stranding an order mid-charge. No `stripe` npm dep — every
19
+ * byte is either node built-in or vendored blamejs primitive.
14
20
  *
15
21
  * Future Adyen / Mollie / Paddle adapters land as additional
16
22
  * factory functions returning the same `{ verifyWebhook,
@@ -48,6 +54,59 @@ var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
48
54
  var STRIPE_HTTP_TIMEOUT_MS = 15000;
49
55
  var CURRENCY_RE = /^[a-z]{3}$/; // Stripe wants lowercase ISO 4217
50
56
 
57
+ // ---- PSP dial resilience (circuit breaker + bounded retry) ----------------
58
+ //
59
+ // b.httpClient does NOT itself circuit-break or retry — it SSRF-gates, caps,
60
+ // and pools, but the failure-threshold breaker + backoff retry are separate
61
+ // primitives a consumer composes around it. Each adapter instance owns ONE
62
+ // breaker per upstream (Stripe / PayPal): per-target is the only correct
63
+ // scope — sharing a breaker across unrelated peers defeats the
64
+ // failure-threshold semantic.
65
+ //
66
+ // Open-circuit posture: while the breaker is open every dial fast-fails with
67
+ // a plain Error carrying `code: "CIRCUIT_OPEN"` (NOT a TypeError), so the
68
+ // storefront's checkout handler renders it through the existing recoverable
69
+ // "checkout didn't go through" page — the same structured payment-unavailable
70
+ // surface a raw upstream 5xx already produces — rather than charging or
71
+ // 400-ing a field. Nothing is captured: the breaker fails BEFORE the dial, so
72
+ // no order is stranded mid-charge.
73
+ //
74
+ // Retry rides ONLY on idempotent dials — GET reads and writes carrying an
75
+ // idempotency key (Stripe Idempotency-Key / PayPal-Request-Id). A
76
+ // keyless POST is sent exactly once: re-driving it could double-charge.
77
+ var BREAKER_FAILURE_THRESHOLD = 5; // consecutive failures before opening
78
+ var BREAKER_COOLDOWN_MS = C.TIME.seconds(30); // open → half-open probe delay
79
+ var BREAKER_SUCCESS_THRESHOLD = 2; // half-open probes to re-close
80
+ var DIAL_RETRY_MAX_ATTEMPTS = 3; // total tries incl. first (idempotent only)
81
+ var DIAL_RETRY_BASE_DELAY_MS = 200;
82
+ var DIAL_RETRY_MAX_DELAY_MS = C.TIME.seconds(2);
83
+
84
+ // One breaker per adapter instance. `idempotent` selects whether the dial
85
+ // also rides the bounded backoff retry — the breaker counts the retry loop's
86
+ // FINAL outcome as a single call (b.retry.withBreaker), so a transient blip
87
+ // that the retry rides out never inflates the failure counter.
88
+ function _makeBreaker(name) {
89
+ return b.circuitBreaker.create({
90
+ name: name,
91
+ failureThreshold: BREAKER_FAILURE_THRESHOLD,
92
+ cooldownMs: BREAKER_COOLDOWN_MS,
93
+ successThreshold: BREAKER_SUCCESS_THRESHOLD,
94
+ });
95
+ }
96
+
97
+ function _dial(breaker, idempotent, fn) {
98
+ if (!breaker) return fn();
99
+ if (!idempotent) return breaker.wrap(fn);
100
+ return b.retry.withBreaker(fn, {
101
+ breaker: breaker,
102
+ retry: {
103
+ maxAttempts: DIAL_RETRY_MAX_ATTEMPTS,
104
+ baseDelayMs: DIAL_RETRY_BASE_DELAY_MS,
105
+ maxDelayMs: DIAL_RETRY_MAX_DELAY_MS,
106
+ },
107
+ });
108
+ }
109
+
51
110
  // Stripe holds idempotency keys for 24h, so the local cache row
52
111
  // expires on the same window — operators who run `cleanupExpired()`
53
112
  // on a daily schedule keep the table small without ever shortening
@@ -196,33 +255,47 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
196
255
  headers["idempotency-key"] = idempotencyKey;
197
256
  }
198
257
  var httpClient = opts.httpClient || b.httpClient;
199
- var res = await httpClient.request({
200
- method: method,
201
- url: url,
202
- headers: headers,
203
- body: body || undefined,
204
- timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
205
- agent: _PSP_TLS_AGENT,
258
+ // A GET read is always idempotent; a write is idempotent only when it
259
+ // carries an Idempotency-Key (Stripe dedupes a replay of the SAME key
260
+ // server-side). A keyless POST rides the breaker but NOT the retry, so
261
+ // a transient blip never re-sends it (double-charge guard).
262
+ var idempotent = method === "GET" || !!idempotencyKey;
263
+ // The HTTP request AND the non-2xx → throw both run INSIDE the dialed
264
+ // closure so the breaker counts a 5xx / transport error as a failure
265
+ // (and the idempotent-path retry retries it). A 4xx is the request's
266
+ // fault, not the peer's health — `err.statusCode` makes the retry
267
+ // classifier skip it, and a 4xx still increments the breaker, which is
268
+ // acceptable since a sustained stream of 4xx is itself a degraded state.
269
+ var json = await _dial(opts._breaker, idempotent, async function () {
270
+ var res = await httpClient.request({
271
+ method: method,
272
+ url: url,
273
+ headers: headers,
274
+ body: body || undefined,
275
+ timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
276
+ agent: _PSP_TLS_AGENT,
277
+ });
278
+ var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
279
+ var parsed = null;
280
+ try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
281
+ if (res.statusCode < 200 || res.statusCode >= 300) {
282
+ var err = new Error("stripe: " + method + " " + path + " → HTTP " + res.statusCode +
283
+ (parsed && parsed.error && parsed.error.message ? " — " + parsed.error.message : ""));
284
+ err.code = (parsed && parsed.error && parsed.error.code) || "STRIPE_HTTP_" + res.statusCode;
285
+ err.statusCode = res.statusCode;
286
+ err.stripe = parsed && parsed.error || null;
287
+ err._stripeRawText = text;
288
+ err._stripeStatus = res.statusCode;
289
+ throw err;
290
+ }
291
+ // Carry the raw status + serialised body alongside the parsed JSON
292
+ // so the idempotency layer can persist them verbatim for replay
293
+ // without re-stringifying (preserves byte-for-byte fidelity with
294
+ // what Stripe returned, including field ordering).
295
+ Object.defineProperty(parsed, "_stripeStatus", { value: res.statusCode, enumerable: false });
296
+ Object.defineProperty(parsed, "_stripeRawText", { value: text, enumerable: false });
297
+ return parsed;
206
298
  });
207
- var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
208
- var json = null;
209
- try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
210
- if (res.statusCode < 200 || res.statusCode >= 300) {
211
- var err = new Error("stripe: " + method + " " + path + " → HTTP " + res.statusCode +
212
- (json && json.error && json.error.message ? " — " + json.error.message : ""));
213
- err.code = (json && json.error && json.error.code) || "STRIPE_HTTP_" + res.statusCode;
214
- err.statusCode = res.statusCode;
215
- err.stripe = json && json.error || null;
216
- err._stripeRawText = text;
217
- err._stripeStatus = res.statusCode;
218
- throw err;
219
- }
220
- // Carry the raw status + serialised body alongside the parsed JSON
221
- // so the idempotency layer can persist them verbatim for replay
222
- // without re-stringifying (preserves byte-for-byte fidelity with
223
- // what Stripe returned, including field ordering).
224
- Object.defineProperty(json, "_stripeStatus", { value: res.statusCode, enumerable: false });
225
- Object.defineProperty(json, "_stripeRawText", { value: text, enumerable: false });
226
299
  return json;
227
300
  }
228
301
 
@@ -330,6 +403,13 @@ function stripe(opts) {
330
403
  throw new TypeError("payment: now must be a function returning current epoch ms");
331
404
  }
332
405
 
406
+ // One circuit breaker per adapter instance, guarding every Stripe dial.
407
+ // Stashed on opts so the module-level `_stripeCall` reaches it without a
408
+ // signature change. Skippable in tests via `breaker: false`.
409
+ if (opts._breaker === undefined) {
410
+ opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-stripe");
411
+ }
412
+
333
413
  // Idempotency state shared across every mutating call. When `query`
334
414
  // is not supplied the primitive runs in legacy mode — every
335
415
  // mutating call goes straight to Stripe, no cache writes, no
@@ -349,6 +429,11 @@ function stripe(opts) {
349
429
  return {
350
430
  name: "stripe",
351
431
 
432
+ // The per-adapter circuit breaker (or null when disabled). Exposed so
433
+ // an operator dashboard can read `breaker.getState()` ("closed" /
434
+ // "open" / "half") and reset it after a confirmed recovery.
435
+ breaker: opts._breaker,
436
+
352
437
  verifyWebhook: function (headers, rawBody, vOpts) {
353
438
  return _verifyWebhook(headers, rawBody, opts.webhookSecret, vOpts);
354
439
  },
@@ -648,28 +733,33 @@ async function _paypalToken(opts, state) {
648
733
  if (state.token && now < state.tokenExpiresAt) return state.token;
649
734
  var httpClient = opts.httpClient || b.httpClient;
650
735
  var basic = Buffer.from(opts.clientId + ":" + opts.secret).toString("base64");
651
- var res = await httpClient.request({
652
- method: "POST",
653
- url: _paypalApiBase(opts) + "/v1/oauth2/token",
654
- headers: {
655
- "authorization": "Basic " + basic,
656
- "accept": "application/json",
657
- "content-type": "application/x-www-form-urlencoded",
658
- "user-agent": "blamejs-shop (zero-dep)",
659
- },
660
- body: "grant_type=client_credentials",
661
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
662
- agent: _PSP_TLS_AGENT,
736
+ // The client-credentials token exchange is idempotent (re-asking for a
737
+ // token is always safe), so it rides the breaker AND the bounded retry.
738
+ var json = await _dial(opts._breaker, true, async function () {
739
+ var res = await httpClient.request({
740
+ method: "POST",
741
+ url: _paypalApiBase(opts) + "/v1/oauth2/token",
742
+ headers: {
743
+ "authorization": "Basic " + basic,
744
+ "accept": "application/json",
745
+ "content-type": "application/x-www-form-urlencoded",
746
+ "user-agent": "blamejs-shop (zero-dep)",
747
+ },
748
+ body: "grant_type=client_credentials",
749
+ timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
750
+ agent: _PSP_TLS_AGENT,
751
+ });
752
+ var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
753
+ var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = {}; }
754
+ if (res.statusCode < 200 || res.statusCode >= 300 || !parsed.access_token) {
755
+ var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
756
+ (parsed && parsed.error_description ? " — " + parsed.error_description : ""));
757
+ err.code = "PAYPAL_AUTH_" + res.statusCode;
758
+ err.statusCode = res.statusCode;
759
+ throw err;
760
+ }
761
+ return parsed;
663
762
  });
664
- var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
665
- var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = {}; }
666
- if (res.statusCode < 200 || res.statusCode >= 300 || !json.access_token) {
667
- var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
668
- (json && json.error_description ? " — " + json.error_description : ""));
669
- err.code = "PAYPAL_AUTH_" + res.statusCode;
670
- err.statusCode = res.statusCode;
671
- throw err;
672
- }
673
763
  state.token = json.access_token;
674
764
  var ttlMs = (typeof json.expires_in === "number" ? json.expires_in : 0) * 1000; // allow:raw-time-literal — PayPal expires_in is a runtime seconds value; *1000 → ms
675
765
  state.tokenExpiresAt = now + Math.max(0, ttlMs - PAYPAL_TOKEN_SKEW_MS);
@@ -688,26 +778,34 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
688
778
  var body = bodyObj != null ? JSON.stringify(bodyObj) : undefined;
689
779
  if (body) headers["content-length"] = Buffer.byteLength(body, "utf8");
690
780
  var httpClient = opts.httpClient || b.httpClient;
691
- var res = await httpClient.request({
692
- method: method,
693
- url: _paypalApiBase(opts) + path,
694
- headers: headers,
695
- body: body,
696
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
697
- agent: _PSP_TLS_AGENT,
781
+ // A GET is idempotent; a write is idempotent only when it carries a
782
+ // PayPal-Request-Id (PayPal dedupes a replay of the SAME id, and the
783
+ // same id rides every retry attempt within one call). A keyless write
784
+ // rides the breaker but not the retry.
785
+ var idempotent = method === "GET" || !!requestId;
786
+ var json = await _dial(opts._breaker, idempotent, async function () {
787
+ var res = await httpClient.request({
788
+ method: method,
789
+ url: _paypalApiBase(opts) + path,
790
+ headers: headers,
791
+ body: body,
792
+ timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
793
+ agent: _PSP_TLS_AGENT,
794
+ });
795
+ var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
796
+ var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
797
+ if (res.statusCode < 200 || res.statusCode >= 300) {
798
+ var detail = parsed && (parsed.message || (parsed.details && parsed.details[0] && parsed.details[0].description)) || "";
799
+ var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
800
+ err.code = (parsed && parsed.name) || "PAYPAL_HTTP_" + res.statusCode;
801
+ err.statusCode = res.statusCode;
802
+ err.paypal = parsed || null;
803
+ throw err;
804
+ }
805
+ Object.defineProperty(parsed, "_paypalStatus", { value: res.statusCode, enumerable: false });
806
+ Object.defineProperty(parsed, "_paypalRawText", { value: text, enumerable: false });
807
+ return parsed;
698
808
  });
699
- var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
700
- var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
701
- if (res.statusCode < 200 || res.statusCode >= 300) {
702
- var detail = json && (json.message || (json.details && json.details[0] && json.details[0].description)) || "";
703
- var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
704
- err.code = (json && json.name) || "PAYPAL_HTTP_" + res.statusCode;
705
- err.statusCode = res.statusCode;
706
- err.paypal = json || null;
707
- throw err;
708
- }
709
- Object.defineProperty(json, "_paypalStatus", { value: res.statusCode, enumerable: false });
710
- Object.defineProperty(json, "_paypalRawText", { value: text, enumerable: false });
711
809
  return json;
712
810
  }
713
811
 
@@ -721,6 +819,13 @@ function paypal(opts) {
721
819
  if (opts.now != null && typeof opts.now !== "function") {
722
820
  throw new TypeError("payment: now must be a function returning current epoch ms");
723
821
  }
822
+ // One circuit breaker per adapter instance, guarding every PayPal dial
823
+ // (the token exchange + every Orders-v2 call). Skippable in tests via
824
+ // `breaker: false`.
825
+ if (opts._breaker === undefined) {
826
+ opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-paypal");
827
+ }
828
+
724
829
  var state = {
725
830
  query: opts.query || null,
726
831
  now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
@@ -744,6 +849,10 @@ function paypal(opts) {
744
849
  return {
745
850
  name: "paypal",
746
851
 
852
+ // The per-adapter circuit breaker (or null when disabled). Same
853
+ // operator-dashboard surface as the Stripe adapter's.
854
+ breaker: opts._breaker,
855
+
747
856
  // Create an Orders-v2 order (intent CAPTURE). The returned `id` is the
748
857
  // PayPal order id the buyer approves; `captureOrder` finalizes it.
749
858
  createOrder: function (input, idempotencyKey) {
package/lib/storefront.js CHANGED
@@ -9085,13 +9085,70 @@ function _setSidCookie(req, res, sid) {
9085
9085
  });
9086
9086
  }
9087
9087
 
9088
+ // Store-free device-fingerprint binding for the sealed auth cookie. The
9089
+ // sealed envelope is tamper-proof but device-PORTABLE: a cookie lifted
9090
+ // off one device replays for the full 14-day life on any other. We bind
9091
+ // it softly to a SHAKE256 fingerprint of the device shape (User-Agent +
9092
+ // sorted Accept-Language / Accept-Encoding) carried INSIDE the sealed
9093
+ // envelope, recomputed + constant-time-compared on read. No external
9094
+ // store: `binding.fingerprint(req)` is a pure function of the request, so
9095
+ // the fingerprint lives in the cookie itself rather than a session table.
9096
+ //
9097
+ // The IP component is deliberately disabled (`ipPrefixBits {v4:0, v6:0}`):
9098
+ // a mobile or VPN visitor roams networks constantly, and signing them out
9099
+ // on a network hop is a worse outcome than the residual portability a
9100
+ // UA+Accept-only fingerprint leaves. Drift in the device shape is the
9101
+ // signal; a network change is not.
9102
+ //
9103
+ // `b.sessionDeviceBinding.create()` refuses to construct without either a
9104
+ // bindingStore or storeInSession — neither of which the store-free
9105
+ // `fingerprint()` path touches. Pass a b.cache-shaped no-op so the
9106
+ // constructor's opt-shape gate is satisfied; its methods are never called.
9107
+ var _NOOP_BINDING_STORE = {
9108
+ get: function () { return undefined; },
9109
+ set: function () { return undefined; },
9110
+ del: function () { return undefined; },
9111
+ };
9112
+ var _deviceBinding = null;
9113
+ function _deviceBindingInstance() {
9114
+ if (!_deviceBinding) {
9115
+ _deviceBinding = b.sessionDeviceBinding.create({
9116
+ bindingStore: _NOOP_BINDING_STORE,
9117
+ ipPrefixBits: { v4: 0, v6: 0 },
9118
+ });
9119
+ }
9120
+ return _deviceBinding;
9121
+ }
9122
+
9123
+ // The hex device fingerprint for THIS request, or null when it can't be
9124
+ // computed (a request shape the primitive refuses). A null fingerprint is
9125
+ // stored as "no binding" — the read side skips the check rather than
9126
+ // signing the visitor out over a missing signal.
9127
+ function _authDeviceFingerprint(req) {
9128
+ try {
9129
+ var fp = _deviceBindingInstance().fingerprint(req);
9130
+ return fp ? fp.toString("hex") : null;
9131
+ } catch (_e) {
9132
+ return null;
9133
+ }
9134
+ }
9135
+
9088
9136
  // Auth + WebAuthn-challenge cookies carry a vault-sealed JSON envelope.
9089
9137
  // writeSealed/readSealed handle the seal + the on-wire prefix; the
9090
9138
  // caller works in plain objects.
9091
9139
  function _setAuthCookie(req, res, env) {
9092
9140
  var T = b.constants.TIME;
9093
9141
  var secure = _secureForReq(req);
9094
- _cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(env), {
9142
+ // Stash the device fingerprint inside the sealed envelope at mint time
9143
+ // so a later read can detect a cookie that has moved to a different
9144
+ // device shape. Additive: an env handed in WITHOUT `fp` (a caller that
9145
+ // doesn't know about binding) just gets it filled here.
9146
+ var sealed = env;
9147
+ if (env && env.fp == null) {
9148
+ var fp = _authDeviceFingerprint(req);
9149
+ if (fp) sealed = Object.assign({}, env, { fp: fp });
9150
+ }
9151
+ _cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(sealed), {
9095
9152
  expires: new Date(Date.now() + T.days(14)),
9096
9153
  secure: secure,
9097
9154
  });
@@ -9570,6 +9627,14 @@ var LOGIN_ERROR_MESSAGES = {
9570
9627
  link: "That sign-in link is invalid or has expired. Request a fresh one.",
9571
9628
  };
9572
9629
 
9630
+ // Neutral (non-error) notices surfaced on the sign-in screen. The
9631
+ // device-binding soft sign-out lands here: a reassuring "sign in again"
9632
+ // message, never an alarming error, and it discloses nothing about WHY
9633
+ // (no "your session looked suspicious" — the visitor just signs in again).
9634
+ var LOGIN_NOTICE_MESSAGES = {
9635
+ device: "You've been signed out for your security. Please sign in again.",
9636
+ };
9637
+
9573
9638
  function renderAccountLogin(opts) {
9574
9639
  opts = opts || {};
9575
9640
  var oauthButtons = "";
@@ -9588,6 +9653,12 @@ function renderAccountLogin(opts) {
9588
9653
  var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
9589
9654
  ? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
9590
9655
  : "";
9656
+ // Neutral notice (e.g. the device-binding soft sign-out) renders in the
9657
+ // same slot with non-error styling. Error wins if both are somehow set.
9658
+ if (!errHtml && opts.notice && LOGIN_NOTICE_MESSAGES[opts.notice]) {
9659
+ errHtml = "<p class=\"auth-form__message\" role=\"status\">" +
9660
+ b.template.escapeHtml(LOGIN_NOTICE_MESSAGES[opts.notice]) + "</p>";
9661
+ }
9591
9662
  // Render the email-link path INLINE (a working no-JS form), not as a link
9592
9663
  // to a separate page, so both passwordless paths live on one screen.
9593
9664
  var magicHtml = opts.magic_link_enabled ? LOGIN_MAGIC_INLINE : "";
@@ -11910,9 +11981,28 @@ function mount(router, deps) {
11910
11981
  // block) and the account routes inside it, so there's one auth-cookie
11911
11982
  // reader rather than a copy per call site. A missing / malformed /
11912
11983
  // expired cookie returns null — never throws.
11984
+ //
11985
+ // Device-binding (soft): an envelope carrying a stashed `fp` is checked
11986
+ // against THIS request's recomputed fingerprint with a constant-time
11987
+ // compare. On drift the visitor reads as signed-out (return null) and a
11988
+ // one-shot `req._authDeviceDrift` flag is set so the page-level gates
11989
+ // clear the now-stale cookie and bounce to a neutral sign-in — never a
11990
+ // hard 401 mid-page. A pre-binding envelope (no `fp`, minted before this
11991
+ // shipped) passes through unchanged until its natural expiry, so a
11992
+ // deploy never mass-signs-out live sessions.
11913
11993
  function _currentCustomerEnv(req) {
11914
11994
  var env = _readAuthEnv(req);
11915
11995
  if (!env || !env.customer_id || !env.exp || env.exp < Date.now()) return null;
11996
+ if (typeof env.fp === "string" && env.fp.length > 0) {
11997
+ var current = _authDeviceFingerprint(req);
11998
+ // A request whose fingerprint can't be recomputed (current === null)
11999
+ // is NOT treated as drift — absence of signal is not evidence of a
12000
+ // moved cookie. Only a present-but-mismatching fingerprint signs out.
12001
+ if (current !== null && !b.crypto.timingSafeEqual(env.fp, current)) {
12002
+ if (req) req._authDeviceDrift = true;
12003
+ return null;
12004
+ }
12005
+ }
11916
12006
  return env;
11917
12007
  }
11918
12008
 
@@ -14808,6 +14898,9 @@ function mount(router, deps) {
14808
14898
  res.status(303); res.setHeader && res.setHeader("location", "/account");
14809
14899
  return res.end ? res.end() : res.send("");
14810
14900
  }
14901
+ // A drifted cookie that reached the sign-in screen directly still gets
14902
+ // cleared so the next request carries no stale envelope.
14903
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
14811
14904
  var cartCount = await _cartCountForReq(req);
14812
14905
  var url = req.url ? new URL(req.url, "http://localhost") : null;
14813
14906
  // Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
@@ -14827,6 +14920,7 @@ function mount(router, deps) {
14827
14920
  apple_enabled: !!deps.oauthApple,
14828
14921
  magic_link_enabled: !!(deps.customerPortal && deps.customerPortalEmail),
14829
14922
  error: url && url.searchParams.get("error"),
14923
+ notice: url && url.searchParams.get("signed_out"),
14830
14924
  captcha_kind: captchaLoginOn ? captchaKind : null,
14831
14925
  captcha_public_key: captchaLoginOn ? captchaPubKey : null,
14832
14926
  }));
@@ -15247,7 +15341,12 @@ function mount(router, deps) {
15247
15341
  throw e;
15248
15342
  }
15249
15343
  if (!auth) {
15250
- res.status(303); res.setHeader && res.setHeader("location", "/account/login");
15344
+ // On device-binding drift, clear the stale cookie + surface a
15345
+ // neutral sign-in notice (matches _accountAuth's soft sign-out).
15346
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
15347
+ res.status(303);
15348
+ res.setHeader && res.setHeader("location",
15349
+ (req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
15251
15350
  return res.end ? res.end() : res.send("");
15252
15351
  }
15253
15352
  var customer = await deps.customers.get(auth.customer_id);
@@ -15505,7 +15604,14 @@ function mount(router, deps) {
15505
15604
  throw e;
15506
15605
  }
15507
15606
  if (!auth) {
15508
- res.status(303); res.setHeader && res.setHeader("location", "/account/login");
15607
+ // Device-binding drift: clear the now-stale cookie and bounce to a
15608
+ // neutral sign-in notice (never a hard 401 mid-page). Any other
15609
+ // not-signed-in case (no cookie, expired) bounces to the plain
15610
+ // login with no notice.
15611
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
15612
+ res.status(303);
15613
+ res.setHeader && res.setHeader("location",
15614
+ (req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
15509
15615
  res.end ? res.end() : res.send("");
15510
15616
  return null;
15511
15617
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.22",
3
+ "version": "0.4.23",
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": {