@blamejs/blamejs-shop 0.4.47 → 0.4.48

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.48 (2026-06-14) — **Hardening: pickup scheduling integrity, the save-for-later CSRF token, a store-credit expiry race, and gift-card ledger verification.** A batch of correctness and security hardening. A click-and-collect pickup already marked ready could be silently un-readied by re-scheduling it, which the pickup state machine has no transition for; re-scheduling in place is now restricted to a pending pickup. The pickup capacity gate counted bookings and then inserted in two steps, so two concurrent checkouts could over-book a full time slot; the cap is now enforced inside the write atomically. The authenticated Save-for-later cart action (POST /cart/lines/:line_id/save) inherited the edge cart forms' CSRF exemption by a path-prefix accident and shipped without requiring a token; it now demands the double-submit CSRF token like any other authenticated mutation, while the genuinely token-less edge cart forms stay exempt. Two concurrent store-credit expiry sweeps could over-burn still-valid credit; the sweep is now atomic and idempotent. And the gift-card ledger's chain verification accepted a populated ledger whose hash columns had all been cleared — a full-ledger rewrite read as verified; an unanchored populated chain now fails verification, while a genuinely empty ledger still passes. No migration to apply. **Fixed:** *A ready pickup can't be un-readied by re-scheduling* — Re-scheduling a click-and-collect pickup in place is now allowed only while it is still scheduled. A pickup already marked ready (its goods on the hold shelf) no longer regresses to scheduled and loses its ready timestamp when re-scheduled — the pickup state machine has no ready-to-scheduled transition, so the operator completes or escalates a ready pickup instead. · *Pickup time slots can't be over-booked under concurrency* — The pickup capacity limit is now enforced inside the booking write as a single conditional insert gated on the live count for the time slot, so two checkouts booking the same nearly-full slot at once can't both slip past the limit. Previously the count and the insert were separate steps, leaving a window where concurrent bookings over-filled a slot. · *Concurrent store-credit expiry sweeps can't over-burn valid credit* — The store-credit expiry sweep now burns expired credit atomically: the amount to expire is computed inside the write and conditioned on the credit still being unexpired, so a second sweep running at the same time finds nothing left to burn and can't dip into still-valid balance. Previously the sweep read the expirable total and then wrote in separate steps, so two concurrent sweeps could double-burn. **Security:** *The save-for-later cart action now requires its CSRF token* — POST /cart/lines/:line_id/save is a login-required cart mutation rendered only on the session-bound cart page, but it sat under the /cart/lines path prefix that exempts the cookie-less, token-less edge cart forms from CSRF — so it inherited that exemption and accepted state changes without a double-submit token. The exemption now carves this authenticated path back into CSRF protection from a single source shared by the request guard and the form renderer (so the set the guard enforces and the set the renderer tokenizes can't drift), while the legitimate edge cart forms remain exempt. · *A hash-cleared gift-card ledger no longer verifies as valid* — The gift-card ledger's chain verification reported a populated ledger whose hash-chain columns had all been nulled as verified — so an attacker who rewrote balances and cleared the chain hashes could pass verification undetected. A populated ledger with no chain anchor now fails verification; a genuinely empty ledger (no rows) still passes.
12
+
11
13
  - v0.4.47 (2026-06-14) — **Signing out wipes the browser's client-side state via Clear-Site-Data.** Both the storefront account sign-out and the admin sign-out now emit an RFC 9527 Clear-Site-Data response header, instructing the browser to clear its cookies, storage, cache, and execution contexts for the origin. Previously sign-out tore down the server-side session and expired the auth cookie but left the rest of the browser's client-side state in place — on a shared or public browser, the next person could find a cached authenticated page or leftover local/session storage. This is defense-in-depth on top of the existing server-side session teardown. No migration to apply. **Security:** *Sign-out clears client-side browser state* — The /account/logout and /admin/logout routes now send Clear-Site-Data with the cookies, storage, cache, and execution-context directives, so a browser drops the origin's client-side state on sign-out rather than only expiring the auth cookie. This closes the window on a shared or public machine where a cached authenticated page or leftover storage could outlive the session.
12
14
 
13
15
  - v0.4.46 (2026-06-14) — **A refund webhook records its own amount, so refunds processed out of order can't over-count the refunded total.** The Stripe charge.refunded mirror recorded the difference between the charge's cumulative refunded figure and the order's recorded refund total. When two refunds happen close together and the second refund's webhook is processed before the first's has been recorded — including two webhooks racing on the same order — both read the same not-yet-updated recorded total, so the later one records the full cumulative difference and double-counts the earlier refund, overstating the order's refunded total (and, with the over-refund cap keyed on that total, potentially distorting later refund limits). The mirror now records each event's own refund amount — the charge lists its refunds newest-first — keyed on the refund id, so a re-delivery or the console's own mirror of the same refund is still de-duplicated. Each refund is counted exactly once regardless of the order or timing its webhooks arrive in. No migration to apply. **Fixed:** *A refund webhook records its own amount, not the cumulative difference* — The charge.refunded mirror now records the specific refund that triggered the event, taken from the charge's own refunds list, instead of the difference between the charge's cumulative refunded figure and the order's ledger. The previous difference-based approach read the recorded total once and could record a value that included an earlier refund whose webhook hadn't landed yet — so when that earlier webhook arrived, the order's refunded total over-counted. Keying each entry on the refund id keeps re-deliveries and the console's own mirror de-duplicated, so the same refund is never recorded twice and the total converges on the true figure whatever order the webhooks arrive in.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.47",
2
+ "version": "0.4.48",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -441,32 +441,34 @@ function create(opts) {
441
441
  // discretisation in the storefront's window picker.
442
442
  var bucketStart = Math.floor(input.scheduled_window_start / HOUR_MS) * HOUR_MS;
443
443
  var bucketEnd = bucketStart + HOUR_MS;
444
- var booked = await _capacityBookedForBucket(input.location_code, bucketStart, bucketEnd);
445
- // Re-scheduling the SAME order_id within the same bucket should
446
- // not consume an additional capacity slot — check whether an
447
- // existing schedule for this order already lives in the bucket
448
- // and exclude it from the cap.
449
- var existing = await _getScheduleByOrder(orderId);
450
- if (existing && existing.location_code === input.location_code &&
451
- existing.scheduled_window_start >= bucketStart &&
452
- existing.scheduled_window_start < bucketEnd &&
453
- (existing.status === "scheduled" || existing.status === "ready")) {
454
- booked -= 1;
455
- }
456
- if (booked >= Number(loc.capacity_per_hour)) {
457
- throw new TypeError("click-and-collect.scheduleAtLocation: location_code " +
458
- JSON.stringify(input.location_code) + " is at capacity for the requested window");
459
- }
444
+ var capacity = Number(loc.capacity_per_hour);
460
445
 
446
+ var existing = await _getScheduleByOrder(orderId);
461
447
  if (existing) {
462
- // Re-schedule in place. Refuses to overwrite a terminal row
463
- // a picked_up / no_show / cancelled order books a new pickup
464
- // by going through the operator's cancel + re-place flow,
465
- // never by mutating the original schedule.
466
- if (existing.status !== "scheduled" && existing.status !== "ready") {
448
+ // Re-schedule in place. Only a `scheduled` row re-schedules:
449
+ // once goods are on the hold shelf (`ready`) the FSM does not
450
+ // regress to `scheduled` (that would silently un-ready a
451
+ // confirmed pickup and wipe ready_at) — the operator completes
452
+ // the pickup or escalates. A terminal row (picked_up / no_show
453
+ // / cancelled) books a new pickup through the operator's
454
+ // cancel + re-place flow, never by mutating the original.
455
+ if (existing.status !== "scheduled") {
467
456
  throw new TypeError("click-and-collect.scheduleAtLocation: order " + orderId +
468
- " has a terminal pickup schedule (status=" + existing.status +
469
- "); operator must place a new order to reschedule");
457
+ " has a non-rescheduleable pickup (status=" + existing.status +
458
+ "); only a scheduled pickup can be re-scheduled in place");
459
+ }
460
+ // A re-schedule moves a slot the order already holds, so the
461
+ // order's own existing row in the target bucket is not counted
462
+ // against the cap.
463
+ var booked = await _capacityBookedForBucket(input.location_code, bucketStart, bucketEnd);
464
+ if (existing.location_code === input.location_code &&
465
+ existing.scheduled_window_start >= bucketStart &&
466
+ existing.scheduled_window_start < bucketEnd) {
467
+ booked -= 1;
468
+ }
469
+ if (booked >= capacity) {
470
+ throw new TypeError("click-and-collect.scheduleAtLocation: location_code " +
471
+ JSON.stringify(input.location_code) + " is at capacity for the requested window");
470
472
  }
471
473
  await query(
472
474
  "UPDATE pickup_schedules SET location_code = ?1, scheduled_window_start = ?2, " +
@@ -476,13 +478,25 @@ function create(opts) {
476
478
  now, orderId],
477
479
  );
478
480
  } else {
479
- await query(
481
+ // New booking — fold the capacity check into the INSERT so a
482
+ // count-then-insert race can't over-book the bucket. The row
483
+ // only lands when the live bucket count is still below the
484
+ // cap; a lost race writes zero rows and refuses identically.
485
+ var ins = await query(
480
486
  "INSERT INTO pickup_schedules (id, order_id, location_code, " +
481
487
  "scheduled_window_start, scheduled_window_end, status, created_at, updated_at) " +
482
- "VALUES (?1, ?2, ?3, ?4, ?5, 'scheduled', ?6, ?7)",
488
+ "SELECT ?1, ?2, ?3, ?4, ?5, 'scheduled', ?6, ?7 WHERE (" +
489
+ "SELECT COUNT(*) FROM pickup_schedules WHERE location_code = ?3 " +
490
+ "AND status IN ('scheduled', 'ready') " +
491
+ "AND scheduled_window_start >= ?8 AND scheduled_window_start < ?9) < ?10",
483
492
  [b.uuid.v7(), orderId, input.location_code,
484
- input.scheduled_window_start, input.scheduled_window_end, now, now],
493
+ input.scheduled_window_start, input.scheduled_window_end, now, now,
494
+ bucketStart, bucketEnd, capacity],
485
495
  );
496
+ if (Number(ins.rowCount || 0) === 0) {
497
+ throw new TypeError("click-and-collect.scheduleAtLocation: location_code " +
498
+ JSON.stringify(input.location_code) + " is at capacity for the requested window");
499
+ }
486
500
  }
487
501
  return await _getScheduleByOrder(orderId);
488
502
  },
@@ -478,8 +478,11 @@ function create(opts) {
478
478
  // unverifiable legacy PREFIX and counted, but once a hashed row
479
479
  // appears every later row must link — an unhashed row after the
480
480
  // anchor, a prev_hash that doesn't name its parent, or a row_hash
481
- // that doesn't recompute is a break. O(n) per card; operator-audit
482
- // use, not hot-path.
481
+ // that doesn't recompute is a break. A populated ledger that never
482
+ // anchors (every row NULL-hashed — the shape a full-ledger rewrite
483
+ // leaves behind) fails: an all-NULL chain is unanchored, not valid.
484
+ // An empty ledger (zero rows) is the only no-anchor case that passes.
485
+ // O(n) per card; operator-audit use, not hot-path.
483
486
  verifyChain: async function (giftCardId) {
484
487
  _uuid(giftCardId, "gift_card_id");
485
488
  var r = await query(
@@ -532,6 +535,22 @@ function create(opts) {
532
535
  }
533
536
  prevHash = row.row_hash;
534
537
  }
538
+ // A populated ledger that never anchored has no cryptographic root to
539
+ // verify against — every row read as an unverifiable legacy prefix.
540
+ // That's the shape a full-ledger rewrite produces (clear every
541
+ // row_hash, then edit balances freely): an all-NULL chain is
542
+ // unanchored, not "valid". Only a genuinely empty ledger (zero rows)
543
+ // is legitimately ok with no anchor.
544
+ if (!anchored && rows.length > 0) {
545
+ return {
546
+ ok: false,
547
+ rows_verified: 0,
548
+ legacy_prefix: legacyPrefix,
549
+ break_at: 0,
550
+ break_row_id: rows[0].id,
551
+ reason: "unanchored chain (no hashed row in a populated ledger)",
552
+ };
553
+ }
535
554
  return {
536
555
  ok: true,
537
556
  rows_verified: rows.length - legacyPrefix,
@@ -225,6 +225,39 @@ var EDGE_POST_PATHS = [
225
225
  "/stock-alert/unsubscribe",
226
226
  ];
227
227
 
228
+ // Authenticated, container-only POST routes that fall UNDER an EDGE_POST_PATHS
229
+ // prefix but must NOT inherit the edge exemption. `/cart/lines` is exempt for
230
+ // the edge-cached add / qty-update / remove forms (worker/render/*), which are
231
+ // cookie-less and token-less. But `POST /cart/lines/:line_id/save` is a
232
+ // login-required mutation rendered ONLY by the container (the "Save for later"
233
+ // control on the session-bound cart page), where a session exists and the page
234
+ // carries the per-request `_csrf` token. A bare `/cart/lines` prefix match
235
+ // would exempt it by accident, dropping double-submit CSRF on an authenticated
236
+ // state change. These carve-backs re-arm the token check on exactly those
237
+ // paths while leaving every legitimate edge form exempt. Matched as exact
238
+ // regexes (anchored, `:line_id` as a free segment) so a sibling edge path is
239
+ // never re-captured.
240
+ var EDGE_EXEMPT_CARVEBACKS = [
241
+ /^\/cart\/lines\/[^/]+\/save$/,
242
+ ];
243
+
244
+ /**
245
+ * Is `pathname` covered by the edge-form CSRF exemption? True when it matches
246
+ * an EDGE_POST_PATHS prefix AND is not one of the authenticated container-only
247
+ * carve-backs above. The single source both the container csrfGuard and the
248
+ * storefront's `_csrf` form-injection consult, so the exemption set the guard
249
+ * skips and the set the renderer leaves token-less can never drift apart.
250
+ * Request-shape reader — returns a boolean for any input, never throws.
251
+ */
252
+ function isEdgeExemptPath(pathname) {
253
+ var p = typeof pathname === "string" ? pathname : "";
254
+ if (!_hasPrefix(p, EDGE_POST_PATHS)) return false;
255
+ for (var i = 0; i < EDGE_EXEMPT_CARVEBACKS.length; i += 1) {
256
+ if (EDGE_EXEMPT_CARVEBACKS[i].test(p)) return false;
257
+ }
258
+ return true;
259
+ }
260
+
228
261
  // The TLS-terminated public origin(s) the storefront is served on. Behind
229
262
  // the Cloudflare Worker the container socket is plain http, so the CSRF
230
263
  // origin pre-check would otherwise build `http://<host>` and refuse every
@@ -710,7 +743,7 @@ function mountRouteGuards(r) {
710
743
  r.use(function csrfGuard(req, res, next) {
711
744
  var pathname = req.pathname || req.url || "/";
712
745
  if (_hasPrefix(pathname, WEBHOOK_PATHS) || pathname === HEALTH_PATH) return next();
713
- if (_hasPrefix(pathname, EDGE_POST_PATHS)) return next();
746
+ if (isEdgeExemptPath(pathname)) return next();
714
747
  return csrfGate(req, res, next);
715
748
  });
716
749
 
@@ -822,5 +855,7 @@ module.exports = {
822
855
  PAYPAL_WEBHOOK_BUDGET_PER_MINUTE: PAYPAL_WEBHOOK_BUDGET_PER_MINUTE,
823
856
  TIGHT_PREFIXES: TIGHT_PREFIXES,
824
857
  EDGE_POST_PATHS: EDGE_POST_PATHS,
858
+ EDGE_EXEMPT_CARVEBACKS: EDGE_EXEMPT_CARVEBACKS,
859
+ isEdgeExemptPath: isEdgeExemptPath,
825
860
  PUBLIC_WELL_KNOWN_PATHS: PUBLIC_WELL_KNOWN_PATHS,
826
861
  };
@@ -183,10 +183,21 @@ function create(opts) {
183
183
  // expire — burns MIN(amount, live), gated on live > 0 (zero rows =
184
184
  // wallet already empty; callers degrade gracefully — by
185
185
  // design, never a throw).
186
+ // sweep — the scheduled expiry burn. `amount` carries the
187
+ // customer's expired-credit total; the still-unburned
188
+ // remainder (total minus the live SUM of prior
189
+ // SWEEP_SOURCE_REF expire rows) is recomputed INSIDE the
190
+ // insert and capped at the live balance, so two concurrent
191
+ // sweeps can't both burn the same expired pool. The second
192
+ // sweep serializes behind the first's committed row, sees
193
+ // zero pending, and matches no rows — never burning the
194
+ // still-valid (non-expired) credit left in the wallet.
195
+ // Stored as an `expire` row stamped SWEEP_SOURCE_REF.
186
196
  // Returns the written row's resolved values, or null when the guard
187
197
  // refused the write.
188
198
  async function _writeRowAtomic(kind, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs) {
189
199
  var id = b.uuid.v7();
200
+ var storedKind = kind === "sweep" ? "expire" : kind;
190
201
  var balSub = "COALESCE((SELECT balance_after_minor FROM store_credit_ledger " +
191
202
  "WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
192
203
  var tsSub = "COALESCE((SELECT occurred_at FROM store_credit_ledger " +
@@ -201,6 +212,17 @@ function create(opts) {
201
212
  amountExpr = "?3";
202
213
  afterExpr = balSub + " - ?3";
203
214
  guard = balSub + " >= ?3";
215
+ } else if (kind === "sweep") {
216
+ // `?3` is this customer's expired-credit total. Subtract the live
217
+ // sum of prior sweep-stamped expire rows to get the still-pending
218
+ // burn, then cap at the live balance — both recomputed at INSERT
219
+ // time so a racing second sweep sees the first's committed row.
220
+ var sweptSub = "COALESCE((SELECT SUM(amount_minor) FROM store_credit_ledger " +
221
+ "WHERE customer_id = ?2 AND kind = 'expire' AND source_ref = ?5), 0)";
222
+ var pending = "(?3 - " + sweptSub + ")";
223
+ amountExpr = "CASE WHEN " + balSub + " < " + pending + " THEN " + balSub + " ELSE " + pending + " END";
224
+ afterExpr = "CASE WHEN " + balSub + " < " + pending + " THEN 0 ELSE " + balSub + " - " + pending + " END";
225
+ guard = pending + " > 0 AND " + balSub + " > 0";
204
226
  } else { // expire — burn MIN(amount, live balance)
205
227
  amountExpr = "CASE WHEN " + balSub + " < ?3 THEN " + balSub + " ELSE ?3 END";
206
228
  afterExpr = "CASE WHEN " + balSub + " < ?3 THEN 0 ELSE " + balSub + " - ?3 END";
@@ -211,7 +233,7 @@ function create(opts) {
211
233
  "(id, customer_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, expires_at, occurred_at) " +
212
234
  "SELECT ?1, ?2, ?9, " + amountExpr + ", ?4, ?5, ?6, " + afterExpr + ", ?7, " + tsExpr + " " +
213
235
  "WHERE " + guard,
214
- [id, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs, kind],
236
+ [id, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs, storedKind],
215
237
  );
216
238
  if (Number(res.rowCount || 0) === 0) return null;
217
239
  var row = (await query(
@@ -572,17 +594,26 @@ function create(opts) {
572
594
  // per-credit-row because expire rows have no parent pointer at
573
595
  // the schema level. The idempotency key is the SWEEP's OWN prior
574
596
  // output — expire rows stamped with `SWEEP_SOURCE_REF` — NOT
575
- // every expire row. Counting all expire rows here was the bug:
597
+ // every expire row. Counting all expire rows here was a bug:
576
598
  // an operator-initiated `expire()` (a clawback, a goodwill burn)
577
599
  // or any non-sweep expire would be subtracted from the expired-
578
- // credit total, shrinking `pendingBurn` and leaving genuinely
600
+ // credit total, shrinking the pending burn and leaving genuinely
579
601
  // expired credit un-swept. Operator expires + debits already
580
- // reduced the BALANCE; the `min(pendingBurn, balance)` cap below
581
- // is what keeps the sweep from over-burning when the wallet was
602
+ // reduced the BALANCE; the live-balance cap inside the write is
603
+ // what keeps the sweep from over-burning when the wallet was
582
604
  // partly drained — so they must not also be netted out of the
583
- // expired pool, or the reduction is double-counted. When the
584
- // unburned remainder is zero, the sweep is a no-op for that
585
- // customer (idempotent re-run produces no duplicates).
605
+ // expired pool, or the reduction is double-counted.
606
+ //
607
+ // The pending-burn arithmetic (expired total minus this sweep's
608
+ // own prior output) and the balance cap both run INSIDE the
609
+ // guarded insert, not from a JS-side read. A read-then-write here
610
+ // raced: two concurrent sweeps both read a zero prior-burn before
611
+ // either wrote, both computed the full expired total as pending,
612
+ // and the second's capped write then burned the still-valid
613
+ // (non-expired) credit the first sweep left behind. With the
614
+ // remainder recomputed at insert time, the second sweep serializes
615
+ // behind the first's committed row, sees zero pending, and matches
616
+ // no rows — an idempotent no-op that never touches valid balance.
586
617
  var expiredByCustomer = (await query(
587
618
  "SELECT customer_id, SUM(amount_minor) AS total " +
588
619
  "FROM store_credit_ledger " +
@@ -597,30 +628,18 @@ function create(opts) {
597
628
  var customerId = row.customer_id;
598
629
  var expiredTotal = row.total;
599
630
 
600
- var burnRow = (await query(
601
- "SELECT COALESCE(SUM(amount_minor), 0) AS total " +
602
- "FROM store_credit_ledger " +
603
- "WHERE customer_id = ?1 AND kind = 'expire' AND source_ref = ?2",
604
- [customerId, SWEEP_SOURCE_REF],
605
- )).rows[0];
606
- var alreadyBurned = burnRow ? burnRow.total : 0;
607
- var pendingBurn = expiredTotal - alreadyBurned;
608
-
609
- if (pendingBurn <= 0) {
610
- // The sweep already burned this customer's expired credit on
611
- // a prior run. Idempotent skip.
612
- continue;
613
- }
614
-
615
- // The burn caps at the wallet's current balance INSIDE the
616
- // guarded insert. Debits between the credit and the sweep may
617
- // have spent the expired amount already; the write never drives
618
- // the balance negative, and a wallet already at zero refuses the
619
- // write entirely (no CHECK > 0 violation; operator reconciles
620
- // via history). The expired credits were "first-out" from the
621
- // operator's POV — the schema doesn't track FIFO at row level,
622
- // so the audit trail reflects what was actually burned.
623
- var w = await _writeRowAtomic("expire", customerId, pendingBurn, null, SWEEP_SOURCE_REF, null, null, now);
631
+ // The burn amount = (expired total − sweep's own prior burn),
632
+ // capped at the wallet's current balance — all computed INSIDE
633
+ // the guarded insert. Debits between the credit and the sweep
634
+ // may have spent the expired amount already; the write never
635
+ // drives the balance negative, and a wallet already at zero (or
636
+ // a fully-swept expired pool) refuses the write entirely. A
637
+ // refused write is the idempotent skip — no duplicate row, no
638
+ // CHECK > 0 violation; the operator reconciles via history. The
639
+ // expired credits are "first-out" from the operator's POV — the
640
+ // schema doesn't track FIFO at row level, so the audit trail
641
+ // reflects what was actually burned.
642
+ var w = await _writeRowAtomic("sweep", customerId, expiredTotal, null, SWEEP_SOURCE_REF, null, null, now);
624
643
  if (!w) continue;
625
644
  processed.push({
626
645
  id: w.id,
package/lib/storefront.js CHANGED
@@ -145,20 +145,23 @@ function _spliceRaw(html, token, fragment) {
145
145
  // byte-identical to the edge copy (the render-parity gate). They keep their
146
146
  // SameSite + fetch-metadata defense and are exempt from the token check, so
147
147
  // tokening only the container copy would both break parity and 403 a no-JS
148
- // edge submission. The exempt list is the SAME prefix set the guard exempts,
149
- // imported from lib/security-middleware so the two never drift.
150
- var _EDGE_POST_PATHS = securityMiddleware.EDGE_POST_PATHS;
148
+ // edge submission. The exempt decision is the SAME one the guard makes —
149
+ // `securityMiddleware.isEdgeExemptPath` so the set skipped and the set left
150
+ // token-less never drift.
151
151
 
152
152
  // The escaper the storefront uses for every attribute interpolation
153
153
  // (b.template.escapeHtml — attribute-safe; escapes & < > " '). Aliased so the
154
154
  // rewrite reads as "escape this attribute value".
155
155
  var _escAttr = b.template.escapeHtml;
156
156
 
157
+ // Delegate the edge-exempt decision to lib/security-middleware so the set the
158
+ // csrfGuard skips and the set this renderer leaves token-less are ONE source.
159
+ // That helper applies the EDGE_POST_PATHS prefixes minus the authenticated
160
+ // container-only carve-backs (e.g. `/cart/lines/:line_id/save`), so a form
161
+ // under an edge prefix that the guard DOES protect (and therefore needs the
162
+ // token) gets `_csrf` injected here instead of shipping token-less.
157
163
  function _actionIsEdgeExempt(action) {
158
- for (var i = 0; i < _EDGE_POST_PATHS.length; i += 1) {
159
- if (action.indexOf(_EDGE_POST_PATHS[i]) === 0) return true;
160
- }
161
- return false;
164
+ return securityMiddleware.isEdgeExemptPath(action);
162
165
  }
163
166
 
164
167
  // Inject a hidden `_csrf` field into every container POST form in `html`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.47",
3
+ "version": "0.4.48",
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": {