@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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/click-and-collect.js +40 -26
- package/lib/gift-card-ledger.js +21 -2
- package/lib/security-middleware.js +36 -1
- package/lib/store-credit.js +51 -32
- package/lib/storefront.js +10 -7
- package/package.json +1 -1
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.
|
package/lib/asset-manifest.json
CHANGED
package/lib/click-and-collect.js
CHANGED
|
@@ -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
|
|
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.
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
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
|
|
469
|
-
");
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
},
|
package/lib/gift-card-ledger.js
CHANGED
|
@@ -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.
|
|
482
|
-
//
|
|
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 (
|
|
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
|
};
|
package/lib/store-credit.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
581
|
-
//
|
|
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.
|
|
584
|
-
//
|
|
585
|
-
//
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
)
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
149
|
-
//
|
|
150
|
-
|
|
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
|
-
|
|
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