@blamejs/blamejs-shop 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.3 (2026-06-05) — **Passkey sign-in works again: WebAuthn is permitted on the pages that host it.** The framework's deny-all Permissions-Policy disabled the browser's WebAuthn API everywhere — including the sign-in page's own top-level document — so attempting a passkey sign-in failed with the browser reporting that publickey-credentials-get is not enabled. The policy now permits exactly the WebAuthn capability each ceremony page needs: credential assertion on the sign-in page, credential creation on the registration and passkey-management pages, both scoped to the page's own origin. Every other page keeps the strict deny-all policy, and every other feature (camera, microphone, geolocation, payment outside the payment page) remains denied on the ceremony pages too. **Fixed:** *Passkey ceremonies are no longer blocked by Permissions-Policy* — Sign-in carries publickey-credentials-get=(self); registration and passkey management carry publickey-credentials-create=(self). The allowance is scoped per route following the same pattern the payment page uses, grants apply only to the page's own origin (no cross-origin delegation), and unrecognized feature requests relax nothing. Tests assert the exact header tokens per route and that unrelated pages still deny both WebAuthn features.
12
+
13
+ - v0.4.2 (2026-06-05) — **Abandoned checkouts release their stock holds, and five more inventory and admin hardening fixes.** The inventory enforcement introduced in 0.4.0 placed a stock hold at checkout but had no path to free it when a buyer abandoned without paying or cancelling — each abandoned checkout permanently subtracted from sellable stock until an operator intervened. A scheduled reaper now cancels pending orders older than a configurable age (default two hours), cancelling the payment intent first so a late payment can never complete against a reaped order, and releasing the held stock through the existing cancellation path. Around it, five more fixes harden the same surface: settlement failures during payment confirmation no longer strand holds silently (each item settles independently and failures land in the operator error log with the exact item and quantity), a rollback path no longer releases holds belonging to an order that was successfully created, pre-order campaigns whose launch date has passed now enforce real stock limits instead of remaining exempt, the admin activity timeline rejects protocol-relative link targets, and activity reads are bounded instead of scanning a customer's full history per page view. **Fixed:** *Stock holds from abandoned checkouts are reclaimed* — A pending order that never completes payment now has its stock hold released automatically. The scheduled reaper cancels pending orders older than CHECKOUT_PENDING_TTL_MINUTES (default 120, minimum 5; invalid values refuse to boot). For card payments the payment intent is cancelled at the processor before the order is touched — if the processor reports the payment already succeeded, the order is left alone for the webhook to settle. Each sweep reports counts of reaped, skipped, and errored orders. · *Payment settlement is crash-safe per item* — When an order is marked paid, each line's stock decrement now settles independently: one item's database failure no longer blocks the others, no longer fails the payment webhook, and is captured to the operator error log naming the item, quantity, and order so the operator can reconcile stock from the existing adjustment screen. · *Checkout rollback no longer releases holds it does not own* — If checkout fails after the order record was created, the error path previously released all of the attempt's stock holds — which could free units belonging to the order itself or, on a shared item, a concurrent shopper's reservation. Holds are now released on failure only when no order was created; once an order exists it owns its holds, and cancellation or the reaper frees them. The same correction applies to the PayPal order-creation path. · *Pre-order campaigns enforce stock after their launch date* — An active pre-order campaign exempts its product from stock holds by design — pre-orders sell beyond the shelf. That exemption now ends when the campaign's launch date passes: a launched product sells from real inventory even if the campaign has not yet been moved out of its pre-order state in the console. · *Admin activity links and reads hardened* — The customer activity timeline's internal-link guard now rejects protocol-relative targets, and each activity source is read with a bound matching the requested page instead of scanning the customer's entire history. Pagination, ordering, and the summary counts are unchanged.
14
+
11
15
  - v0.4.1 (2026-06-05) — **Order notes and a customer activity timeline in the admin console.** Two operator surfaces land in the admin console. Every order detail screen gains a customer-service notes thread: operators record internal notes or customer-visible ones, pin the important thread to the top, and mark issues resolved or reopen them — with note bodies length-bounded, control-character-rejected, and escaped at render. Every customer detail screen gains a read-only activity timeline aggregating what that customer has done across the store — orders placed and their lifecycle transitions, loyalty points earned, wishlist additions, reviews submitted, and support tickets opened — read directly from the tables those features already populate, newest first. Both panels appear only when their backing modules are wired, and every note mutation is ownership-scoped to its order and audited. **Added:** *Customer-service notes on order detail* — The admin order screen shows a notes thread with pinned notes floated first and the rest newest-first. Operators add notes as internal (the default) or customer-visible, pin and unpin them, and resolve or reopen them with a short resolution summary; resolving a customer-visible note is refused so customer-facing context is never silently closed out. Bodies are validated server-side (8000-character cap, control characters rejected) and HTML-escaped at render. Each mutation verifies the note belongs to the order in the URL before acting, returns clean errors for unknown or malformed identifiers, and emits an audit event. The same surface is available as JSON under the admin bearer token. · *Customer activity timeline on customer detail* — The admin customer screen shows an aggregated, newest-first activity feed: order placements and status transitions, loyalty point movements, wishlist additions, review submissions, and support tickets. The timeline is a read-only view over the tables those features already write — no new tracking or recording was added anywhere in the request path. The panel shows the most recent fifty events, links each event to its admin screen where one exists, and renders an explicit empty state for customers with no history. The same feed is available as JSON under the admin bearer token.
12
16
 
13
17
  - v0.4.0 (2026-06-05) — **Inventory is enforced at the point of sale, completing the transactions, checkout, and analytics surface.** Stock levels were previously display-only: the product page showed honest availability, but nothing reserved inventory at checkout or debited it on a sale, so concurrent buyers could oversell a SKU and stock counts never moved. Checkout now places an atomic per-SKU hold before any charge — a sold-out line re-renders the form with a friendly message instead of charging — and the order lifecycle settles the hold: payment converts it to a real stock decrement (idempotent across webhook re-deliveries), cancellation releases it, and refunds deliberately leave restocking to the operator's judgment. Untracked SKUs remain unlimited, and pre-order campaigns keep their own reservation flow. The README previously described an oversell-prevention mechanism that was not actually wired; it now describes the real one. This minor release caps the transactions, checkout, and analytics arc: server-validated addresses and digital-cart checkout, discount codes with console authoring, gift cards, loyalty, store pickup, a dark-themed Stripe payment surface with express wallets and verified 3-D Secure, shipment tracking timelines, consent-gated funnel analytics with an admin dashboard, abandoned-cart visibility with honest recovery codes, operator error logs, and confirmation resends. Known, documented deferrals: customer receipt downloads (the confirmation page and signed email serve as the receipt), partial refunds from the browser console (the JSON API supports them), and a real-time new-order notification (the dashboard and outbound webhooks cover arrival today). **Added:** *Point-of-sale inventory enforcement* — Checkout reserves stock with an atomic conditional hold per shippable line before charging — insufficient stock re-renders the checkout with a clear message and charges nothing, and two concurrent buyers of the last unit resolve to exactly one sale. Payment converts holds into stock decrements, idempotently across webhook re-deliveries; cancelling a pending order releases its holds precisely, even when other shoppers hold the same SKU. Refunds do not auto-restock — returned goods re-enter stock through the operator's existing restock action, by judgment. SKUs without an inventory row remain available without limit, and pre-order campaigns are unaffected. The inventory primitive gains the hold and decrement operations its documentation previously described, and the README now reflects the actual oversell-prevention mechanism. **Changed:** *The 0.3 series rolls up* — This release follows nineteen 0.3.x patches that built out the commerce surface: server-side address validation with accessible per-field errors, digital-cart checkout without an address, delivery-date estimates, discount unlock codes with cart redemption and console authoring, full shipment-tracking timelines, consent-gated funnel analytics and an Analytics console screen, abandoned-cart visibility with single-use recovery codes, an operator-readable error log with a JSON API, order-confirmation resends, segment CSV exports, payment-processor TLS fixes verified by live payment, a dark-themed payment surface with express wallets, and a vendored-framework refresh. See the changelog for the full sequence.
package/SECURITY.md CHANGED
@@ -180,6 +180,17 @@ node -e "
180
180
  Redemption decrements with an atomic `balance >= amount` SQL guard
181
181
  keyed on the order id, so concurrent or replayed checkouts can never
182
182
  overdraw a card or apply more than the remaining balance.
183
+ - **Abandoned checkouts don't strand stock forever.** Checkout reserves
184
+ stock with an atomic conditional hold before charging; an order whose
185
+ buyer abandons the payment sheet (or whose PaymentIntent expires) would
186
+ otherwise hold that stock indefinitely. A background tick cancels
187
+ pending orders older than `CHECKOUT_PENDING_TTL_MINUTES` (default 120,
188
+ minimum 5) so their holds release back to the shelf. For a Stripe order
189
+ the tick cancels the PaymentIntent FIRST — if Stripe reports the payment
190
+ already succeeded, the order is left pending for the webhook to settle,
191
+ so a reap can never cancel an order whose payment completed. Tune the
192
+ TTL to the longest payment flow you support (slow 3-D Secure, manual
193
+ bank pushes) so a legitimate in-progress checkout is never reaped.
183
194
  - **Loyalty points are account-scoped money — earned and spent under
184
195
  the same double-spend discipline.** The `/account/loyalty` page and
185
196
  the redeem actions are login-gated and read the customer id from the
package/lib/admin.js CHANGED
@@ -13553,7 +13553,11 @@ function renderAdminCustomerDetail(opts) {
13553
13553
  var activityPanel = "";
13554
13554
  if (opts.can_activity) {
13555
13555
  var activityRows = (opts.activity || []).map(function (e) {
13556
- var link = (typeof e.link === "string" && e.link.charAt(0) === "/") ? e.link : null;
13556
+ // Same-origin path only: a leading "/" that is NOT "//" a
13557
+ // protocol-relative "//evil.example/x" also starts with "/" but
13558
+ // resolves to an off-site origin, and _htmlEscape leaves "/"
13559
+ // untouched, so it would survive into a working cross-origin href.
13560
+ var link = (typeof e.link === "string" && e.link.charAt(0) === "/" && e.link.charAt(1) !== "/") ? e.link : null;
13557
13561
  var titleCell = link
13558
13562
  ? "<a href=\"" + _htmlEscape(link) + "\">" + _htmlEscape(String(e.title || e.kind)) + "</a>"
13559
13563
  : _htmlEscape(String(e.title || e.kind));
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.1",
2
+ "version": "0.4.3",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/checkout.js CHANGED
@@ -606,7 +606,24 @@ function create(deps) {
606
606
  async function _holdExempt(sku) {
607
607
  if (preorder && typeof preorder.openCampaignForSku === "function") {
608
608
  try {
609
- if (await preorder.openCampaignForSku(sku)) return true;
609
+ var campaign = await preorder.openCampaignForSku(sku);
610
+ // A pre-order line sells beyond the shelf only while the campaign
611
+ // is genuinely pre-launch: before `launch_at` the unit isn't
612
+ // released yet, so the hold is deliberately skipped. ONCE
613
+ // `launch_at` has passed, the SKU is selling against real stock —
614
+ // keep it hold-exempt and a past-launch campaign that the operator
615
+ // never manually flipped to `launched` would oversell real
616
+ // inventory without bound. So past-launch_at → NOT exempt (holds
617
+ // apply). openCampaignForSku's own semantics are unchanged (the
618
+ // storefront PDP CTA still keys off the active row); the launch_at
619
+ // gate lives here, on the buy path. A campaign with no launch_at
620
+ // is treated as still-pre-launch (exempt).
621
+ if (campaign) {
622
+ var launchAt = campaign.launch_at == null ? null : Number(campaign.launch_at);
623
+ if (launchAt == null || !Number.isFinite(launchAt) || launchAt > Date.now()) {
624
+ return true;
625
+ }
626
+ }
610
627
  } catch (_e) { /* not exempt on lookup failure */ }
611
628
  }
612
629
  if (backorder && typeof backorder.availabilityFor === "function") {
@@ -660,10 +677,12 @@ function create(deps) {
660
677
 
661
678
  // Best-effort release of a set of placed holds (the rollback path).
662
679
  // Drop-silent per hold: a release failure must not mask the original
663
- // error that triggered the rollback, and the TTL-free holds here are
664
- // only ever consumed by the paid decrement or cleared by a cancel, so
665
- // a missed release self-heals when the abandoned pending order is later
666
- // cancelled / expired.
680
+ // error that triggered the rollback. This path runs ONLY when no pending
681
+ // order was created (the conditional rollback in confirm) once an order
682
+ // exists it owns its holds, and the only thing that frees them is the
683
+ // paid decrement, an explicit cancel, or the stale-pending-order reaper
684
+ // (`reapStalePending`), driven once a minute by the worker cron, which
685
+ // cancels pending orders older than the TTL so their holds release.
667
686
  async function _releaseStockHolds(holds) {
668
687
  if (!Array.isArray(holds)) return;
669
688
  for (var i = 0; i < holds.length; i += 1) {
@@ -821,10 +840,19 @@ function create(deps) {
821
840
  // before the order is committed, so a refused gift-card / payment
822
841
  // error never strands held stock.
823
842
  var stockHolds = await _placeStockHolds(quote.lines);
843
+ // Conditional rollback: the catch releases the placed holds ONLY when
844
+ // no pending order was created. Once createFromCart succeeds, the
845
+ // order row OWNS those holds — releasing them here would double-free
846
+ // (the paid decrement / cancel release, or the stale-pending reaper,
847
+ // settles them once the order exists), and a floored blanket release
848
+ // could even eat a CONCURRENT shopper's hold on the same SKU. The
849
+ // context object is mutated inside _confirmAfterHolds the instant the
850
+ // order is created.
851
+ var rollbackCtx = { orderCreated: false };
824
852
  try {
825
- return await this._confirmAfterHolds(input, quote, email, stockHolds);
853
+ return await this._confirmAfterHolds(input, quote, email, stockHolds, rollbackCtx);
826
854
  } catch (e) {
827
- await _releaseStockHolds(stockHolds);
855
+ if (!rollbackCtx.orderCreated) await _releaseStockHolds(stockHolds);
828
856
  throw e;
829
857
  }
830
858
  },
@@ -835,8 +863,11 @@ function create(deps) {
835
863
  // Stripe-intent) both live inside that guard. Internal — invoked only
836
864
  // through confirm() above (hence the `_` prefix); `stockHolds` is the
837
865
  // list of placed holds, which both terminal paths leave in place (the
838
- // pending order owns them until paid / cancelled).
839
- _confirmAfterHolds: async function (input, quote, email, stockHolds) {
866
+ // pending order owns them until paid / cancelled). `rollbackCtx` is the
867
+ // mutable flag the catch reads: set `orderCreated` true the moment a
868
+ // pending order exists so a LATER throw doesn't release holds the order
869
+ // now owns.
870
+ _confirmAfterHolds: async function (input, quote, email, stockHolds, rollbackCtx) {
840
871
  // Already validated in confirm() above; re-read here since the PI
841
872
  // creation moved into this split-out body.
842
873
  var idempotencyKey = input.idempotency_key;
@@ -906,6 +937,9 @@ function create(deps) {
906
937
  customer_email_hash: emailHash,
907
938
  lines: orderLines,
908
939
  });
940
+ // The pending order now owns the holds — a throw from here on must
941
+ // NOT blanket-release them (see the conditional rollback in confirm).
942
+ if (rollbackCtx) rollbackCtx.orderCreated = true;
909
943
  if (gc) await _redeemGiftCard(gc, paidOrder.id);
910
944
  if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id);
911
945
  // Best-effort: record the auto-discount redemptions against the
@@ -962,6 +996,11 @@ function create(deps) {
962
996
  customer_email_hash: emailHash,
963
997
  lines: orderLines,
964
998
  });
999
+ // The pending order now owns the holds — a throw from here on (gift-card
1000
+ // burn, discount recording) must NOT blanket-release them (see the
1001
+ // conditional rollback in confirm). The reaper / cancel / paid edge
1002
+ // settles them now that the order exists.
1003
+ if (rollbackCtx) rollbackCtx.orderCreated = true;
965
1004
 
966
1005
  // Burn the gift-card + loyalty credits against the created order.
967
1006
  // Runs after the order row exists so a failed order never spends
@@ -995,6 +1034,93 @@ function create(deps) {
995
1034
  };
996
1035
  },
997
1036
 
1037
+ // Stale-pending-order reaper. A pending order whose buyer abandoned the
1038
+ // payment sheet (Stripe / PayPal) or whose PaymentIntent expired holds
1039
+ // its reserved stock forever — `confirm` places the holds and creates
1040
+ // the pending order, but the only hold-freeing FSM edges are
1041
+ // pending→paid (decrement) and pending→cancelled (release), and nothing
1042
+ // fires the cancel automatically. This reaper, driven once a minute by
1043
+ // the worker cron's `/_/stale-order-reap` tick, cancels orders older
1044
+ // than the TTL so their holds release.
1045
+ //
1046
+ // Per order, Stripe-paid orders: cancel the PaymentIntent FIRST so a
1047
+ // late authorization can't complete AFTER we cancel the order. If Stripe
1048
+ // reports the PI already succeeded / is not cancelable
1049
+ // (`payment_intent_unexpected_state`, or any non-success cancel), SKIP
1050
+ // the order — the webhook has settled or will settle it, and cancelling
1051
+ // an order whose payment may have completed would lose a real sale.
1052
+ // Only after a clean PI cancel (or for an order with no Stripe
1053
+ // PaymentIntent — a PayPal-created order, whose capture path guards on
1054
+ // local status so reaping first is safe, or a zero-amount leftover) do
1055
+ // we fire the cancel transition, which releases the holds via the
1056
+ // existing path.
1057
+ //
1058
+ // Per-order failures are drop-silent + continue (hot-path sink tier) but
1059
+ // COUNTED in the returned summary so the operator sees the reap's shape.
1060
+ // `ttlMinutes` is validated config-time by the caller (the tick handler
1061
+ // throws on garbage at boot); defended here too with a documented
1062
+ // default so a direct call is never unsafe.
1063
+ reapStalePending: async function (opts) {
1064
+ opts = opts || {};
1065
+ var ttlMinutes = opts.ttl_minutes;
1066
+ if (ttlMinutes == null) ttlMinutes = 120;
1067
+ if (typeof ttlMinutes !== "number" || !isFinite(ttlMinutes) || !Number.isInteger(ttlMinutes) || ttlMinutes < 5) {
1068
+ throw new TypeError("checkout.reapStalePending: ttl_minutes must be an integer >= 5");
1069
+ }
1070
+ var nowMs = typeof opts.now === "number" ? opts.now : Date.now();
1071
+ var cutoffTs = nowMs - b.constants.TIME.minutes(ttlMinutes);
1072
+ var batchLimit = opts.limit == null ? 100 : opts.limit;
1073
+
1074
+ var summary = {
1075
+ scanned: 0,
1076
+ reaped: 0, // cancelled (holds released)
1077
+ skipped_paid: 0, // PI already succeeded / not cancelable — left pending
1078
+ errored: 0, // a per-order failure, dropped + counted
1079
+ };
1080
+
1081
+ var stale;
1082
+ try {
1083
+ stale = (await order.listStalePending(cutoffTs, batchLimit)).rows;
1084
+ } catch (e) {
1085
+ // The candidate read itself failed — surface it in the summary
1086
+ // rather than throwing out of the tick (which would 5xx the cron).
1087
+ return Object.assign({ ok: false, error: (e && e.message) || String(e) }, summary);
1088
+ }
1089
+ summary.scanned = stale.length;
1090
+
1091
+ for (var i = 0; i < stale.length; i += 1) {
1092
+ var row = stale[i];
1093
+ try {
1094
+ var piId = row.payment_intent_id;
1095
+ // A Stripe PaymentIntent id is `pi_…`; a PayPal order id is opaque
1096
+ // (no `pi_` prefix) and has no cancel API for an unapproved order
1097
+ // (its capture path guards on local status, so reaping first can
1098
+ // never double-charge). Only the Stripe case needs a pre-cancel.
1099
+ var isStripePi = typeof piId === "string" && piId.indexOf("pi_") === 0;
1100
+ if (isStripePi && payment && typeof payment.cancelPaymentIntent === "function") {
1101
+ try {
1102
+ await payment.cancelPaymentIntent(piId);
1103
+ } catch (_cancelErr) {
1104
+ // PI already succeeded / not cancelable → the webhook owns this
1105
+ // order's settlement. Leave it pending; never cancel an order
1106
+ // whose payment may have completed.
1107
+ summary.skipped_paid += 1;
1108
+ continue;
1109
+ }
1110
+ }
1111
+ // PI cancelled cleanly (or no Stripe PI) — release the holds by
1112
+ // cancelling the order through the existing FSM edge.
1113
+ await order.transition(row.id, "cancel", { reason: "stale-pending-reap" });
1114
+ summary.reaped += 1;
1115
+ } catch (_e) {
1116
+ // drop-silent per order — by design (hot-path sink): one bad order
1117
+ // must not poison the sweep. Counted so the operator sees it.
1118
+ summary.errored += 1;
1119
+ }
1120
+ }
1121
+ return Object.assign({ ok: true }, summary);
1122
+ },
1123
+
998
1124
  // Verify a Stripe webhook payload and dispatch the order
999
1125
  // transition. Returns { handled, order?, event_type }.
1000
1126
  handleStripeEvent: async function (input) {
@@ -1091,6 +1217,14 @@ function create(deps) {
1091
1217
  // here (no PayPal order created). Released on any throw before the
1092
1218
  // local order row commits.
1093
1219
  var ppHolds = await _placeStockHolds(quote.lines);
1220
+ // Conditional rollback, identical discipline to the Stripe confirm:
1221
+ // release the placed holds ONLY when no pending order was created.
1222
+ // Once createFromCart succeeds the order owns the holds; releasing
1223
+ // them here would double-free and could eat a concurrent shopper's
1224
+ // hold on the same SKU. A pending PayPal order that the buyer never
1225
+ // approves is reaped by the stale-pending reaper (no PayPal-side
1226
+ // cancel needed — the capture path guards on local status).
1227
+ var ppOrderCreated = false;
1094
1228
  try {
1095
1229
  // Resolve an optional gift-card credit before opening the PayPal
1096
1230
  // order so a bad code fails without a remote round-trip.
@@ -1121,6 +1255,7 @@ function create(deps) {
1121
1255
  customer_email_hash: emailHash,
1122
1256
  lines: ppLines,
1123
1257
  });
1258
+ ppOrderCreated = true;
1124
1259
  await _redeemGiftCard(gc, paidOrder.id);
1125
1260
  await cart.setStatus(quote.cart_id, "converted");
1126
1261
  var settled = await order.transition(paidOrder.id, "mark_paid", { reason: "gift_card:full" });
@@ -1149,13 +1284,15 @@ function create(deps) {
1149
1284
  customer_email_hash: emailHash,
1150
1285
  lines: ppLines,
1151
1286
  });
1287
+ ppOrderCreated = true;
1152
1288
  if (gc) await _redeemGiftCard(gc, createdOrder.id);
1153
1289
  await cart.setStatus(quote.cart_id, "converted");
1154
1290
  return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status, gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null };
1155
1291
  } catch (e) {
1156
- // Any throw before the order row commits (PayPal open failure,
1292
+ // A throw BEFORE the order row commits (PayPal open failure,
1157
1293
  // gift-card error) releases the holds so PayPal can't strand stock.
1158
- await _releaseStockHolds(ppHolds);
1294
+ // After the order commits it owns the holds — don't blanket-release.
1295
+ if (!ppOrderCreated) await _releaseStockHolds(ppHolds);
1159
1296
  throw e;
1160
1297
  }
1161
1298
  },
@@ -248,7 +248,11 @@ function create(opts) {
248
248
  // the aggregator). Missing peers collapse to an empty list so the
249
249
  // aggregator stays the same shape regardless of wiring.
250
250
 
251
- async function _collectOrderEvents(customerId, fromTs, toTs) {
251
+ // A read bound is "active" only for a positive integer; null / undefined
252
+ // (the summary path) means read everything, as before.
253
+ function _isBound(n) { return Number.isInteger(n) && n > 0; }
254
+
255
+ async function _collectOrderEvents(customerId, fromTs, toTs, boundLimit) {
252
256
  if (!orderPeer) return [];
253
257
  var sql = "SELECT ot.order_id, ot.from_state, ot.to_state, ot.on_event, " +
254
258
  "ot.reason, ot.occurred_at " +
@@ -259,7 +263,11 @@ function create(opts) {
259
263
  var idx = 2;
260
264
  if (fromTs != null) { sql += " AND ot.occurred_at >= ?" + idx; params.push(fromTs); idx += 1; }
261
265
  if (toTs != null) { sql += " AND ot.occurred_at <= ?" + idx; params.push(toTs); idx += 1; }
262
- sql += " ORDER BY ot.occurred_at ASC";
266
+ // Bounded read: newest-N (DESC + LIMIT) when the paginated caller supplies
267
+ // a bound; the aggregator re-sorts newest-first so the DESC here is just
268
+ // the read order. Unbounded (summary path) keeps the full ASC scan.
269
+ if (_isBound(boundLimit)) { sql += " ORDER BY ot.occurred_at DESC LIMIT ?" + idx; params.push(boundLimit); }
270
+ else { sql += " ORDER BY ot.occurred_at ASC"; }
263
271
  var rows = (await query(sql, params)).rows;
264
272
  var out = [];
265
273
  for (var i = 0; i < rows.length; i += 1) {
@@ -278,7 +286,7 @@ function create(opts) {
278
286
  return out;
279
287
  }
280
288
 
281
- async function _collectWishlistEvents(customerId, fromTs, toTs) {
289
+ async function _collectWishlistEvents(customerId, fromTs, toTs, boundLimit) {
282
290
  if (!wishlistPeer) return [];
283
291
  var sql = "SELECT id, product_id, variant_id, notes, created_at " +
284
292
  "FROM wishlist_entries WHERE customer_id = ?1";
@@ -286,7 +294,8 @@ function create(opts) {
286
294
  var idx = 2;
287
295
  if (fromTs != null) { sql += " AND created_at >= ?" + idx; params.push(fromTs); idx += 1; }
288
296
  if (toTs != null) { sql += " AND created_at <= ?" + idx; params.push(toTs); idx += 1; }
289
- sql += " ORDER BY created_at ASC";
297
+ if (_isBound(boundLimit)) { sql += " ORDER BY created_at DESC LIMIT ?" + idx; params.push(boundLimit); }
298
+ else { sql += " ORDER BY created_at ASC"; }
290
299
  var rows = (await query(sql, params)).rows;
291
300
  var out = [];
292
301
  for (var i = 0; i < rows.length; i += 1) {
@@ -303,7 +312,7 @@ function create(opts) {
303
312
  return out;
304
313
  }
305
314
 
306
- async function _collectLoyaltyEvents(customerId, fromTs, toTs) {
315
+ async function _collectLoyaltyEvents(customerId, fromTs, toTs, boundLimit) {
307
316
  if (!loyaltyPeer) return [];
308
317
  var sql = "SELECT id, transaction_type, points, source, order_id, notes, " +
309
318
  "occurred_at FROM loyalty_transactions WHERE customer_id = ?1";
@@ -311,7 +320,8 @@ function create(opts) {
311
320
  var idx = 2;
312
321
  if (fromTs != null) { sql += " AND occurred_at >= ?" + idx; params.push(fromTs); idx += 1; }
313
322
  if (toTs != null) { sql += " AND occurred_at <= ?" + idx; params.push(toTs); idx += 1; }
314
- sql += " ORDER BY occurred_at ASC";
323
+ if (_isBound(boundLimit)) { sql += " ORDER BY occurred_at DESC LIMIT ?" + idx; params.push(boundLimit); }
324
+ else { sql += " ORDER BY occurred_at ASC"; }
315
325
  var rows = (await query(sql, params)).rows;
316
326
  var out = [];
317
327
  for (var i = 0; i < rows.length; i += 1) {
@@ -331,19 +341,27 @@ function create(opts) {
331
341
  return out;
332
342
  }
333
343
 
334
- async function _collectSupportEvents(customerId, fromTs, toTs) {
344
+ async function _collectSupportEvents(customerId, fromTs, toTs, boundLimit) {
335
345
  if (!supportPeer) return [];
336
346
  // The support_tickets row contributes an "opened" event at
337
347
  // opened_at and a "resolved" event at resolved_at when stamped.
338
348
  // The bounded-window filter is applied per-event after
339
349
  // splitting (a ticket opened inside the window but resolved
340
- // outside still surfaces its opened event).
341
- var rows = (await query(
342
- "SELECT id, subject, category, status, priority, opened_at, " +
343
- "resolved_at, closed_at FROM support_tickets WHERE customer_id = ?1 " +
344
- "ORDER BY opened_at ASC",
345
- [customerId],
346
- )).rows;
350
+ // outside still surfaces its opened event). Bounded read: order by
351
+ // the ticket's NEWEST event timestamp — MAX(opened_at, resolved_at)
352
+ // not opened_at alone, or an old ticket resolved recently would
353
+ // be dropped and its resolved event would vanish from the page.
354
+ // Every event in the true newest-`boundLimit` set belongs to a
355
+ // ticket whose newest-event timestamp is at least that event's, so
356
+ // the newest `boundLimit` tickets under this ordering cover any
357
+ // single page of `boundLimit` events. Unbounded (summary path)
358
+ // keeps the full ASC scan for exact windowed counts.
359
+ var supSql = "SELECT id, subject, category, status, priority, opened_at, " +
360
+ "resolved_at, closed_at FROM support_tickets WHERE customer_id = ?1 ";
361
+ var supParams = [customerId];
362
+ if (_isBound(boundLimit)) { supSql += "ORDER BY MAX(opened_at, COALESCE(resolved_at, opened_at)) DESC LIMIT ?2"; supParams.push(boundLimit); }
363
+ else { supSql += "ORDER BY opened_at ASC"; }
364
+ var rows = (await query(supSql, supParams)).rows;
347
365
  var out = [];
348
366
  for (var i = 0; i < rows.length; i += 1) {
349
367
  var t = rows[i];
@@ -375,7 +393,7 @@ function create(opts) {
375
393
  return out;
376
394
  }
377
395
 
378
- async function _collectReviewEvents(customerId, fromTs, toTs) {
396
+ async function _collectReviewEvents(customerId, fromTs, toTs, boundLimit) {
379
397
  if (!reviewsPeer) return [];
380
398
  // Only authenticated-customer reviews carry a customer_id (the
381
399
  // anonymous email-hash submissions land in the reviews table
@@ -387,7 +405,8 @@ function create(opts) {
387
405
  var idx = 2;
388
406
  if (fromTs != null) { sql += " AND created_at >= ?" + idx; params.push(fromTs); idx += 1; }
389
407
  if (toTs != null) { sql += " AND created_at <= ?" + idx; params.push(toTs); idx += 1; }
390
- sql += " ORDER BY created_at ASC";
408
+ if (_isBound(boundLimit)) { sql += " ORDER BY created_at DESC LIMIT ?" + idx; params.push(boundLimit); }
409
+ else { sql += " ORDER BY created_at ASC"; }
391
410
  var rows = (await query(sql, params)).rows;
392
411
  var out = [];
393
412
  for (var i = 0; i < rows.length; i += 1) {
@@ -406,13 +425,18 @@ function create(opts) {
406
425
 
407
426
  // ---- aggregation ------------------------------------------------------
408
427
 
409
- async function _collectAll(customerId, fromTs, toTs) {
428
+ // `boundLimit` (optional) caps each collector's read to the newest N rows
429
+ // — passed by the paginated `forCustomer` read where a page is at most
430
+ // `limit` events. The summary path passes null/undefined (unbounded) so
431
+ // the 30/90/365-day windowed kind-counts stay exact for a very active
432
+ // customer. A null bound preserves the original read-everything behavior.
433
+ async function _collectAll(customerId, fromTs, toTs, boundLimit) {
410
434
  var batches = await Promise.all([
411
- _collectOrderEvents(customerId, fromTs, toTs),
412
- _collectWishlistEvents(customerId, fromTs, toTs),
413
- _collectLoyaltyEvents(customerId, fromTs, toTs),
414
- _collectSupportEvents(customerId, fromTs, toTs),
415
- _collectReviewEvents(customerId, fromTs, toTs),
435
+ _collectOrderEvents(customerId, fromTs, toTs, boundLimit),
436
+ _collectWishlistEvents(customerId, fromTs, toTs, boundLimit),
437
+ _collectLoyaltyEvents(customerId, fromTs, toTs, boundLimit),
438
+ _collectSupportEvents(customerId, fromTs, toTs, boundLimit),
439
+ _collectReviewEvents(customerId, fromTs, toTs, boundLimit),
416
440
  ]);
417
441
  var flat = [];
418
442
  for (var b = 0; b < batches.length; b += 1) {
@@ -647,7 +671,32 @@ function create(opts) {
647
671
  var limit = _limit(input.limit, MAX_LIMIT, DEFAULT_LIMIT);
648
672
  var cursor = _decodeCursor(input.cursor, "forCustomer");
649
673
 
650
- var events = await _collectAll(customerId, fromTs, toTs);
674
+ // Per-collector read bound: a single page draws at most `limit` events
675
+ // total across all sources, so the newest rows from EACH source (at or
676
+ // before the cursor's timestamp when paginating) are a sufficient
677
+ // superset — the merge + sort + cursor + slice below can never need an
678
+ // older row than the `limit`-th newest of any one source. A customer
679
+ // with thousands of events no longer reads them all per panel render.
680
+ //
681
+ // The cursor's tail timestamp tightens the upper edge so deep pages
682
+ // stay correct: collectors return the newest events at-or-before it,
683
+ // and _applyCursor then drops the equal-or-newer boundary tuple. Because
684
+ // that drop removes the single boundary event per source, the bound is
685
+ // `limit + 1` — enough that a full `limit` page (and a correct
686
+ // next_cursor) survives the drop and pagination doesn't stall early.
687
+ // A `kinds` filter narrows the OUTPUT after collection, so when one is
688
+ // active the bound widens to MAX_LIMIT to give the filter enough
689
+ // candidates per source.
690
+ var collectorBound = kinds ? MAX_LIMIT : (limit + 1);
691
+ var collectorToTs = toTs;
692
+ if (cursor && cursor.length) {
693
+ var cursorTs = Number(cursor[0]);
694
+ if (Number.isFinite(cursorTs)) {
695
+ collectorToTs = (collectorToTs == null) ? cursorTs : Math.min(collectorToTs, cursorTs);
696
+ }
697
+ }
698
+
699
+ var events = await _collectAll(customerId, fromTs, collectorToTs, collectorBound);
651
700
  events = _filterKinds(events, kinds);
652
701
  _sortNewestFirst(events);
653
702
  if (cursor) events = _applyCursor(events, cursor);
package/lib/order.js CHANGED
@@ -209,6 +209,14 @@ function create(opts) {
209
209
  // the physical return is inspected. Opt-in like the other handles so
210
210
  // tests and an inventory-less deploy run unchanged.
211
211
  var inventory = opts.inventory || null;
212
+ // Optional error-log handle — when present, an inventory settlement
213
+ // failure (a decrement / release throw on the paid / cancel edge) is
214
+ // captured to the operator's error feed (/admin/errors) with the exact
215
+ // sku / qty / order so the stranded hold is reconcilable by hand via the
216
+ // inventory-adjustment surface. Opt-in like the other handles; absent it,
217
+ // settlement failures still surface to the audit sink (b.audit.safeEmit
218
+ // below), just not the durable error feed.
219
+ var errorLog = opts.errorLog || null;
212
220
  // Pagination cursors for listForCustomer are HMAC-tagged via
213
221
  // b.pagination so an operator can't hand-craft one to skip past a
214
222
  // hidden order or replay across deployments. The secret defaults
@@ -223,6 +231,48 @@ function create(opts) {
223
231
  }
224
232
  var cursorSecret = opts.cursorSecret;
225
233
 
234
+ // Settle one held SKU on a state edge — `inventory.decrement` on the
235
+ // paid edge, `inventory.release` on the cancel-from-pending edge.
236
+ //
237
+ // drop-silent-with-capture — by design: the order's payment has already
238
+ // succeeded (paid edge) or the cancel has already persisted (cancel
239
+ // edge), and the webhook driving this MUST return 2xx or Stripe retries
240
+ // forever. A throw here would 500 the webhook, the retry would hit the
241
+ // already-advanced guard and skip, and the hold would strand silently.
242
+ // So a per-SKU settlement failure is caught, NOT re-thrown, and surfaced
243
+ // LOUDLY instead: an `order.settlement.error` audit event plus, when the
244
+ // error-log handle is wired, a durable row in /admin/errors carrying the
245
+ // exact sku / qty / order so the operator reconciles the stranded hold
246
+ // via the inventory-adjustment surface. Returns true on success, false on
247
+ // a (captured) failure so the caller can count strandings.
248
+ async function _settleSku(verb, sku, qty, orderId) {
249
+ try {
250
+ await inventory[verb](sku, qty);
251
+ return true;
252
+ } catch (e) {
253
+ var message = "order.settlement." + verb + " failed — sku=" + sku +
254
+ " qty=" + qty + " order=" + orderId + ": " + (e && e.message || e);
255
+ try {
256
+ b.audit.safeEmit({
257
+ action: "order.settlement.error",
258
+ outcome: "failure",
259
+ metadata: { verb: verb, sku: sku, qty: qty, order_id: orderId, message: (e && e.message) || String(e) },
260
+ });
261
+ } catch (_auditErr) { /* drop-silent — the capture below is the durable record */ }
262
+ if (errorLog && typeof errorLog.captureServerError === "function") {
263
+ try {
264
+ await errorLog.captureServerError({
265
+ route: "/order/" + orderId + "/settlement",
266
+ method: "POST",
267
+ status: 500,
268
+ message: message,
269
+ });
270
+ } catch (_logErr) { /* drop-silent — never let the error-feed write mask the original failure */ }
271
+ }
272
+ return false;
273
+ }
274
+ }
275
+
226
276
  return {
227
277
  TERMINAL_STATES: TERMINAL_STATES,
228
278
 
@@ -376,6 +426,13 @@ function create(opts) {
376
426
  var holdMap = _stockHoldMap(refreshed);
377
427
  var holdSkus = Object.keys(holdMap);
378
428
  if (holdSkus.length) {
429
+ // Each SKU settles in its own try/catch (_settleSku) so a single
430
+ // SKU's decrement / release throw on a transient DB error can't
431
+ // strand the OTHER SKUs' holds or 500 the webhook — the remaining
432
+ // SKUs in the loop still settle, and each failure surfaces loudly
433
+ // (audit + error-feed) for manual reconciliation. The transition
434
+ // has already persisted; settlement is post-commit best-effort
435
+ // with a durable failure trail, never a hard gate.
379
436
  if (result.from === "pending" && result.to === "paid"
380
437
  && typeof inventory.decrement === "function") {
381
438
  // Convert each held SKU's reservation into a real shelf debit,
@@ -383,7 +440,7 @@ function create(opts) {
383
440
  // guard (stock_held >= qty) makes a re-delivered mark_paid a
384
441
  // no-op, so this is idempotent across webhook re-deliveries.
385
442
  for (var di = 0; di < holdSkus.length; di += 1) {
386
- await inventory.decrement(holdSkus[di], holdMap[holdSkus[di]]);
443
+ await _settleSku("decrement", holdSkus[di], holdMap[holdSkus[di]], orderId);
387
444
  }
388
445
  } else if (result.from === "pending" && result.to === "cancelled"
389
446
  && typeof inventory.release === "function") {
@@ -393,7 +450,7 @@ function create(opts) {
393
450
  // another shopper holds on the same SKU. release floors at zero
394
451
  // so a double-cancel can't underflow stock_held.
395
452
  for (var ri = 0; ri < holdSkus.length; ri += 1) {
396
- await inventory.release(holdSkus[ri], holdMap[holdSkus[ri]]);
453
+ await _settleSku("release", holdSkus[ri], holdMap[holdSkus[ri]], orderId);
397
454
  }
398
455
  }
399
456
  }
@@ -586,6 +643,34 @@ function create(opts) {
586
643
  return { rows: rows };
587
644
  },
588
645
 
646
+ // Pending orders whose `created_at` is at or before `olderThanTs` —
647
+ // the candidate set for the stale-pending-order reaper. A pending
648
+ // order that never advanced to paid (the buyer abandoned the Stripe /
649
+ // PayPal sheet, the PaymentIntent expired) holds its reserved stock
650
+ // forever with no FSM edge to free it; the reaper cancels these so the
651
+ // hold releases. Bounded (`limit`, default 100, capped at
652
+ // MAX_LIST_LIMIT) so one tick reaps a chunk and the next picks up the
653
+ // rest. Returns the raw row (id + payment_intent_id + created_at are
654
+ // what the reaper needs); the reaper drives the actual hold release
655
+ // through `transition(id, "cancel", …)`. Oldest-first so the longest-
656
+ // stranded holds free first.
657
+ listStalePending: async function (olderThanTs, limit) {
658
+ if (typeof olderThanTs !== "number" || !isFinite(olderThanTs) || olderThanTs < 0) {
659
+ throw new TypeError("order.listStalePending: olderThanTs must be a non-negative epoch-ms number");
660
+ }
661
+ var lim = limit == null ? 100 : limit;
662
+ if (!Number.isInteger(lim) || lim <= 0 || lim > MAX_LIST_LIMIT) {
663
+ throw new TypeError("order.listStalePending: limit must be 1..." + MAX_LIST_LIMIT);
664
+ }
665
+ var rows = (await query(
666
+ "SELECT id, payment_intent_id, created_at FROM orders " +
667
+ "WHERE status = 'pending' AND created_at <= ?1 " +
668
+ "ORDER BY created_at ASC, id ASC LIMIT ?2",
669
+ [olderThanTs, lim],
670
+ )).rows;
671
+ return { rows: rows };
672
+ },
673
+
589
674
  // The actions available from a given status, as {on, to, label} —
590
675
  // drives the transition buttons on the operator order-detail page.
591
676
  // A terminal status returns []. Synchronous (pure lookup).
@@ -380,31 +380,42 @@ function scopedCsp(keys) {
380
380
  }).join("; ") + ";";
381
381
  }
382
382
 
383
- // ---- route-scoped Permissions-Policy (payment surface) ------------------
383
+ // ---- route-scoped Permissions-Policy (payment + passkey surfaces) --------
384
384
  //
385
385
  // The app-level Permissions-Policy is the vendored strict denylist
386
- // (DEFAULT_PERMISSIONS) — every powerful API, including `payment=()`,
387
- // disabled in every document. That is correct everywhere EXCEPT the one
388
- // page whose entire job is taking a payment: GET /pay/:order_id mounts
389
- // Stripe's Express Checkout Element, whose Google Pay / Apple Pay buttons
390
- // drive the Payment Request API. Under `payment=()` the browser refuses
391
- // the API inside the cross-origin pay.google.com / Stripe wallet frames
392
- // ("Permissions policy violation: payment is not allowed in this
393
- // document"), so the wallet express buttons are degraded on the payment
394
- // page. The card form is unaffected (it doesn't use the Payment Request
395
- // API), which is why card captures complete while wallets don't.
386
+ // (DEFAULT_PERMISSIONS) — every powerful API disabled in every document,
387
+ // including `payment=()`, `publickey-credentials-get=()`, and
388
+ // `publickey-credentials-create=()`. That is correct everywhere EXCEPT the
389
+ // handful of pages whose whole job needs one of those features:
396
390
  //
397
- // `scopedPermissionsPolicy()` returns a Permissions-Policy string for
398
- // `res.setHeader("permissions-policy", ...)` on the /pay route's response:
399
- // byte-identical to the vendored default EXCEPT the `payment=()` entry,
400
- // which becomes `payment=(self "https://js.stripe.com" "https://pay.google.com")`
401
- // — granting the feature ONLY to the same origin + the Stripe SDK frame +
402
- // the Google Pay frame, ONLY on that one response. setHeader OVERWRITES, so
403
- // the app-level strict header still governs every OTHER route. Every other
404
- // feature in the denylist stays `()`. Derived from the vendored
405
- // DEFAULT_PERMISSIONS array (the single source the app-level header is also
406
- // built from in `securityHeadersOpts` the vendored default), never a
407
- // hand-forked copy of the list.
391
+ // - GET /pay/:order_id mounts Stripe's Express Checkout Element, whose
392
+ // Google Pay / Apple Pay buttons drive the Payment Request API. Under
393
+ // `payment=()` the browser refuses the API inside the cross-origin
394
+ // pay.google.com / Stripe wallet frames, degrading the wallet express
395
+ // buttons (the card form is unaffected, which is why card captures
396
+ // complete while wallets don't).
397
+ //
398
+ // - The passkey (WebAuthn) ceremonies. `navigator.credentials.get()`
399
+ // (assertion / sign-in) is gated by `publickey-credentials-get`;
400
+ // `navigator.credentials.create()` (registration / enrollment) by
401
+ // `publickey-credentials-create`. Under the deny-all default the browser
402
+ // refuses the API in the TOP-LEVEL document with "The
403
+ // 'publickey-credentials-get' feature is not enabled in this document"
404
+ // (resp. -create), so sign-in / enrollment fail outright. These run on
405
+ // container-served routes:
406
+ // publickey-credentials-get — GET /account/login (passkey-login.js)
407
+ // publickey-credentials-create — GET /account/register (passkey-register.js)
408
+ // GET /account/passkeys (passkey-add.js)
409
+ //
410
+ // `scopedPermissionsPolicy(opts)` returns a Permissions-Policy string for
411
+ // `res.setHeader("permissions-policy", ...)` on the route's response:
412
+ // byte-identical to the vendored default EXCEPT the named feature(s), each
413
+ // re-enabled for ONLY the allowlist that feature needs, ONLY on that one
414
+ // response. setHeader OVERWRITES, so the app-level strict header still
415
+ // governs every OTHER route. Every other feature in the denylist stays `()`.
416
+ // Derived from the vendored DEFAULT_PERMISSIONS array (the single source the
417
+ // app-level header is also built from in `securityHeadersOpts` → the vendored
418
+ // default), never a hand-forked copy of the list.
408
419
 
409
420
  // The allowlist that replaces `payment=()` on the pay surface: same origin
410
421
  // (the pay page's own form), the Stripe SDK frame, and the Google Pay frame
@@ -415,21 +426,61 @@ function scopedCsp(keys) {
415
426
  // it here.
416
427
  var _PAYMENT_ALLOWLIST = 'payment=(self "https://js.stripe.com" "https://pay.google.com")';
417
428
 
429
+ // WebAuthn assertion / attestation run in the page's OWN top-level document
430
+ // (the islands call navigator.credentials.get / .create directly, never from
431
+ // a cross-origin child frame), so `self` is the entire allowlist — no third-
432
+ // party origin is delegated. Keeping the grant to `self` is the tightest
433
+ // value that unblocks the ceremony: a cross-origin iframe on the same page
434
+ // still cannot drive WebAuthn. Single source — every passkey page names the
435
+ // feature → allowlist mapping here.
436
+ var _PASSKEY_FEATURE_ALLOWLIST = {
437
+ "publickey-credentials-get": "publickey-credentials-get=(self)",
438
+ "publickey-credentials-create": "publickey-credentials-create=(self)",
439
+ };
440
+
441
+ // Feature name → its scoped allowlist token. The pay surface relaxes
442
+ // `payment`; the passkey surfaces relax the two WebAuthn features. A caller
443
+ // names which feature(s) to relax; every feature NOT named stays at the
444
+ // vendored `()` deny. This is the single registry of "which feature may be
445
+ // re-enabled, and to exactly what" — nothing outside it can be loosened.
446
+ var _SCOPED_FEATURE_OVERRIDES = Object.assign(
447
+ { payment: _PAYMENT_ALLOWLIST },
448
+ _PASSKEY_FEATURE_ALLOWLIST
449
+ );
450
+
418
451
  /**
419
452
  * Build a route-scoped Permissions-Policy string from the vendored strict
420
- * denylist with the `payment` feature re-enabled for the same origin + the
421
- * Stripe / Google Pay wallet frames. Every other feature is carried through
422
- * verbatim as `feature=()`. Returns the string for the pay route's
453
+ * denylist with one or more features re-enabled to their scoped allowlist.
454
+ * `opts.features` is an array of feature names to relax (each must appear in
455
+ * `_SCOPED_FEATURE_OVERRIDES`); every other feature is carried through
456
+ * verbatim as `feature=()`. With no argument it defaults to relaxing
457
+ * `payment` — the established pay-surface behavior — so existing callers are
458
+ * unchanged. Returns the string for the route's
423
459
  * `res.setHeader("permissions-policy", ...)`; the app-level strict header is
424
460
  * unchanged on every other route (setHeader overwrites this one response).
425
461
  *
426
- * Called per-response on the pay render path, so it never throws — it maps
427
- * over the vendored default array and swaps the one entry, failing safe (a
428
- * missing `payment` entry simply yields the strict default for that feature).
462
+ * Called per-response on a render path, so it never throws — it maps over the
463
+ * vendored default array and swaps only the named entries, failing safe (an
464
+ * unknown feature name simply leaves that feature at the strict deny, and a
465
+ * feature the default does not list is never invented).
429
466
  */
430
- function scopedPermissionsPolicy() {
467
+ function scopedPermissionsPolicy(opts) {
468
+ var features = (opts && Array.isArray(opts.features) && opts.features.length)
469
+ ? opts.features
470
+ : ["payment"];
471
+ // Map requested feature names → their override token, ignoring any name not
472
+ // in the registry (fail-safe: an unknown key relaxes nothing).
473
+ var overrides = {};
474
+ features.forEach(function (name) {
475
+ if (Object.prototype.hasOwnProperty.call(_SCOPED_FEATURE_OVERRIDES, name)) {
476
+ overrides[name] = _SCOPED_FEATURE_OVERRIDES[name];
477
+ }
478
+ });
431
479
  return _vendoredSecurityHeaders.DEFAULT_PERMISSIONS.map(function (entry) {
432
- return entry.indexOf("payment=") === 0 ? _PAYMENT_ALLOWLIST : entry;
480
+ var feature = entry.split("=")[0];
481
+ return Object.prototype.hasOwnProperty.call(overrides, feature)
482
+ ? overrides[feature]
483
+ : entry;
433
484
  }).join(", ");
434
485
  }
435
486
 
package/lib/storefront.js CHANGED
@@ -13447,6 +13447,13 @@ function mount(router, deps) {
13447
13447
  // Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
13448
13448
  // CSP that admits the provider host render only when login is opted in.
13449
13449
  _setAuthCaptchaCsp(res, "login");
13450
+ // Passkey sign-in (passkey-login.js) calls navigator.credentials.get(),
13451
+ // which the app-level Permissions-Policy denies via
13452
+ // publickey-credentials-get=(). Re-enable it for self on THIS response
13453
+ // only so the assertion runs in the top-level document; every other
13454
+ // feature stays denied and every other route keeps the strict default.
13455
+ res.setHeader && res.setHeader("permissions-policy",
13456
+ securityMiddleware.scopedPermissionsPolicy({ features: ["publickey-credentials-get"] }));
13450
13457
  _send(res, 200, renderAccountLogin({
13451
13458
  shop_name: shopName,
13452
13459
  cart_count: cartCount,
@@ -13464,6 +13471,12 @@ function mount(router, deps) {
13464
13471
  // Signup captcha renders whenever a provider is active; the scoped CSP
13465
13472
  // admits the provider host only then (no setHeader otherwise).
13466
13473
  _setAuthCaptchaCsp(res, "signup");
13474
+ // Passkey enrollment (passkey-register.js) calls
13475
+ // navigator.credentials.create(), denied by the app-level
13476
+ // publickey-credentials-create=(). Re-enable it for self on THIS
13477
+ // response only so registration runs in the top-level document.
13478
+ res.setHeader && res.setHeader("permissions-policy",
13479
+ securityMiddleware.scopedPermissionsPolicy({ features: ["publickey-credentials-create"] }));
13467
13480
  _send(res, 200, renderAccountRegister({
13468
13481
  shop_name: shopName,
13469
13482
  cart_count: cartCount,
@@ -13982,6 +13995,12 @@ function mount(router, deps) {
13982
13995
  var cartCount = await _cartCountForReq(req);
13983
13996
  var url = req.url ? new URL(req.url, "http://localhost") : null;
13984
13997
  var okKind = url ? url.searchParams.get("ok") : null;
13998
+ // The "Add a passkey" island (passkey-add.js) calls
13999
+ // navigator.credentials.create(), denied by the app-level
14000
+ // publickey-credentials-create=(). Re-enable it for self on THIS
14001
+ // response only so enrollment runs in the top-level document.
14002
+ res.setHeader && res.setHeader("permissions-policy",
14003
+ securityMiddleware.scopedPermissionsPolicy({ features: ["publickey-credentials-create"] }));
13985
14004
  _send(res, code || 200, renderPasskeys({
13986
14005
  passkeys: pks,
13987
14006
  has_oauth: hasOAuth,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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": {