@blamejs/blamejs-shop 0.4.104 → 0.4.105

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.105 (2026-06-25) — **A loyalty points balance is now an exactly-once, tamper-evident running ledger instead of a stored counter updated alongside its history.** A customer's loyalty balance, lifetime points, and tier were held in a stored account row and updated in a statement separate from the transaction-history insert. On a backend without interactive transactions, a failure between those two writes could leave the balance and the history disagreeing, and there was no way to make a points credit exactly-once with a stored counter. Every loyalty mutation — earn, redeem, expire, adjust, credit, and the refund-time reverse/restore credits — is now a single guarded insert into the transaction ledger that also carries the new running balance, lifetime, and tier, so the balance change and its audit row are one write with no window to half-apply. The running balance is read from the ledger tip rather than a separate counter, redemptions refuse an overdraft inside that one statement, and concurrent writes to the same customer are serialized by a per-customer chain so two redemptions can never both spend the same points. Each ledger row is linked into a per-customer SHA3-512 hash chain, so the history is tamper-evident and can be verified end to end (the loyalty addition to the store-credit and gift-card ledger tamper-evidence). Balances, tiers, the points ratios, and the entire loyalty API are unchanged; the running-balance columns are added by a migration that anchors each existing customer to their current trusted balance without replaying history. The account row is kept as a denormalized mirror for the tier leaderboard and tier-expiry, refreshed after each write and never on a balance decision path. **Changed:** *Loyalty balance, lifetime, and tier are computed from a single guarded ledger write* — The loyalty balance, lifetime points, and tier are no longer a stored counter mutated next to a separate history insert. Each earn / redeem / expire / adjust / credit and each refund-time reverse or restore is now one guarded insert into the loyalty transaction ledger that records the event and carries the resulting running balance, lifetime, and tier in the same row — so a balance change and its audit entry are a single write that cannot half-apply, and a points credit is exactly-once and crash-safe. The current balance is read from the latest ledger row; a redemption's overdraft refusal lives inside that insert; and concurrent writes to one customer are serialized so two redemptions can't both spend the same balance. The customer's balances, tier, the points-per-dollar and redemption ratios, and the public loyalty API are all unchanged. · *Loyalty history is now a tamper-evident hash chain with a verifyChain audit* — Each loyalty transaction row is linked to the previous one for the same customer by a SHA3-512 hash chain, the same tamper-evidence the store-credit and gift-card ledgers already carry. A new verifyChain audit recomputes the chain to prove no past row was edited or re-ordered. A schema migration adds the running-balance and chain columns and anchors every existing customer to their current trusted balance — it copies the live balance verbatim rather than replaying history, so a balance is never recomputed (and never corrupted) during the upgrade. The migration applies automatically on deploy; no operator action is required.
12
+
11
13
  - v0.4.104 (2026-06-25) — **Compare-and-swap atomic claims across the framework now decide won/lost through the shared row-count primitive instead of hand-rolled checks.** Throughout the framework, an atomic claim on a single-statement-per-request backend is a guarded write — UPDATE ... WHERE the row is still in its expected state, or a conditional INSERT — whose affected-row count decides whether this caller won the race (exactly one row) or lost and must no-op. That won/lost decision was hand-rolled at each site as a Number(result.rowCount || 0) === 1 check, which silently treats a result carrying no recognizable row-count as a lost race. Every such site (104 across commissions, redemption caps, inventory and stock-transfer claims, fulfillment and replenishment leases, gift-card and loyalty ledger writes, webhook delivery leases, and more) now routes that decision through the framework's shared row-count primitive, which owns the exactly-one-row check and the cross-adapter row-count field naming, and surfaces an indeterminate result instead of silently counting it as lost. On the production database path the affected-row count is always reported, so there is no behavior change; the change centralizes the interpretation and makes an otherwise-silent indeterminate result observable. No configuration changes and no schema changes. **Changed:** *Atomic compare-and-swap claims use the shared row-count primitive for their won/lost decision* — Compare-and-swap atomic claims — the guarded UPDATE / conditional INSERT pattern used wherever two requests can race the same row on a backend without interactive transactions — previously decided won-versus-lost with a hand-written row-count comparison at each call site, and a result that carried no recognizable row-count was silently taken as a lost race. These claims now make that decision through the framework's shared row-count primitive, which centralizes the exactly-one-row check and the adapter-specific row-count field naming and raises an error on an indeterminate result rather than treating it as a loss. Because the production database always reports the affected-row count, the won/lost outcome is unchanged on every real path; the benefit is one consistent, audited interpretation and a previously-silent indeterminate result now surfacing. A hand-rolled cross-adapter row-count extraction in the webhook delivery-retry lease is removed in favor of the same primitive.
12
14
 
13
15
  - v0.4.103 (2026-06-25) — **A return refund that exceeds the order's remaining refundable balance is now refused cleanly, instead of failing at the payment provider.** The direct refund console refuses an over-refund up front: it compares the requested amount against the order's remaining refundable balance and returns a clear 'more than is still refundable' error before any money moves. The provider-backed return (RMA) refund path skipped that check — it issued the RMA's approved amount straight to the payment provider, relying on the provider to reject anything past the captured charge. The result was a late, opaque provider-failure error (and a returns workflow left mid-flight) instead of the clean, actionable refusal the console gives. The RMA refund path now applies the same remaining-balance guard before calling the provider: an approved amount greater than what remains refundable — including a return stacked on top of an earlier partial refund — is refused with the same clear message, the return is left in its received state so the operator can re-approve a corrected amount, and the payment provider is never dialed. A refund of an already-refunded return continues to return the existing conflict response. No configuration changes. **Fixed:** *Return (RMA) refunds honor the order's remaining-balance cap before reaching the payment provider* — A provider-backed return refund issued the return's approved amount directly to Stripe or PayPal with no remaining-balance check, so an amount larger than what was still refundable on the order — for example a return approved for more than the order total, or stacked on top of an earlier partial refund — failed only at the payment provider, surfacing as an opaque provider-failure error rather than the clean refusal the direct refund console returns. The return refund path now compares the approved amount against the order's remaining refundable balance first: an over-cap amount is refused with a clear message, the return stays in its received state so a corrected amount can be re-approved, and the provider is never called. A refund attempt on an already-refunded return still returns the existing conflict response.
package/README.md CHANGED
@@ -81,7 +81,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
81
81
  | **`lib/quotes.js`** | Request-for-quote negotiation. A signed-in customer requests a quote from the cart (line quantities + an optional message); the operator responds from the console with per-line pricing and a validity window; the customer reviews and accepts or declines from `/account/quotes` or through a single-use capability link (`/quote/:token` — the token is stored only as a namespaced hash and compared timing-safe). Accepting converts through the normal order path — inventory holds are placed first and rolled back if order creation fails — and every status change replays a `b.fsm` lifecycle (requested → responded → accepted / declined / expired / withdrawn / converted), with expiry enforced at accept time so a stale price is never honored. A scheduled sweep also transitions quotes past their validity window to `expired` (so the console's expired filter reflects the real lifecycle), operators can reprice an open quote (the customer's existing link shows the new pricing) or convert a verbally-approved one to an order with a recorded reason, and the customer view renders the operator's per-line notes and the validity date. |
82
82
  | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
83
83
  | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
84
- | **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
84
+ | **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger — the balance, lifetime, and tier ride that ledger as a per-customer running snapshot written in one guarded row per event (exactly-once, with no separate counter to fall out of step), linked into a per-customer SHA3-512 hash chain like the gift-card and store-credit ledgers, with `loyalty.verifyChain(customerId)` to recompute it end to end. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
85
85
  | **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
86
86
  | **`lib/product-compare.js`** | Side-by-side product comparison, no sign-in required. Each product page gets an "Add to compare" / "Remove from compare" control; `POST /compare/toggle` adds or removes the product and `GET /compare` renders the comparison table — image, price, availability, and the per-product attributes resolved through the catalog. The basket is keyed on the visitor's existing sealed session cookie (a signed-in shopper's customer id rides alongside), capped at four products (a fifth add is refused with a notice rather than silently dropping one), and is idempotent. Products that go archived or out of stock resolve out of the table with a remove control instead of breaking the render; an empty basket shows a friendly empty state. `POST /compare/clear` empties it. Linked from the footer on every page. |
87
87
  | **`lib/bundles.js`** | Sell products together at a set price. A product page shows the bundles it belongs to as a "Bundle & save" offer — the member products, the bundle price, and the saving against the parts. `POST /cart/bundle` adds the whole bundle in one action: the price is recomputed from the live catalog and allocated across the member lines (proportional to list price, remainder on the last line) so the cart subtotal matches the quoted price. The add is atomic — an archived or out-of-stock member shows the bundle unavailable and adds nothing rather than a partial set; an unpriceable bundle (missing member price / currency mismatch) is shown unavailable, never a 500. The client sends only the bundle code; the server prices it. The offer renders identically from the edge worker and the container. |
package/SECURITY.md CHANGED
@@ -319,15 +319,18 @@ node -e "
319
319
  (success / failure / denied) and paginated. Opening the log is itself
320
320
  recorded (an `audit.read` row), so reviewing the audit trail leaves
321
321
  its own forensic mark.
322
- - **The gift-card and store-credit ledgers are hash-chained, fork-proof,
323
- and verifiable.** Every ledger entry — credits, debits, and
322
+ - **The gift-card, store-credit, and loyalty ledgers are hash-chained,
323
+ fork-proof, and verifiable.** Every ledger entry — credits, debits, and
324
324
  expirations alike — links to its predecessor through a SHA3-512 chain,
325
- keyed per card (gift cards) or per customer (store credit), so editing a
326
- row or deleting one from the middle breaks the linkage. A uniqueness
327
- fence (one child per chain tip) makes concurrent writes serialize rather
328
- than fork the chain. `giftCardLedger.verifyChain(cardId)` /
329
- `storeCredit.verifyChain(customerId)` recompute a ledger's chain end to
330
- end and report the first divergence run them whenever a balance is
325
+ keyed per card (gift cards) or per customer (store credit, loyalty), so
326
+ editing a row or deleting one from the middle breaks the linkage. A
327
+ uniqueness fence (one child per chain tip) makes concurrent writes
328
+ serialize rather than fork the chain, and each loyalty / gift-card /
329
+ store-credit balance change is written in that same single guarded row,
330
+ so a points or balance mutation is exactly-once even on a backend without
331
+ interactive transactions. `giftCardLedger.verifyChain(cardId)` /
332
+ `storeCredit.verifyChain(customerId)` / `loyalty.verifyChain(customerId)`
333
+ recompute a ledger's chain end to end and report the first divergence — run them whenever a balance is
331
334
  disputed; pass a trusted anchor (a row count + head hash from an earlier
332
335
  snapshot) to also catch a truncation that deletes the most-recent rows,
333
336
  which an append-only chain can't detect on its own. The overdraft
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.104",
2
+ "version": "0.4.105",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/loyalty.js CHANGED
@@ -154,6 +154,51 @@ function _validateThresholds(thresholds) {
154
154
 
155
155
  function _now() { return Date.now(); }
156
156
 
157
+ // ---- chain hashing ------------------------------------------------------
158
+ //
159
+ // loyalty_transactions is a per-customer running-balance hash chain (the
160
+ // gift-card-ledger / store-credit-ledger model). Each mutating row carries the
161
+ // running balance_after_points + lifetime_after_points + tier_after, written by
162
+ // the SAME guarded INSERT that records the event, so the balance change and the
163
+ // audit row are one write — the stored-column two-statement window (and the
164
+ // double-credit-on-revert hazard it forced) is structurally gone. A SHA3-512
165
+ // chain (prev_hash + row_hash, fenced by UNIQUE(customer_id, prev_hash)) makes
166
+ // the running snapshot tamper-evident and serializes concurrent writers.
167
+ //
168
+ // row_hash = SHA3-512(prev_hash || canonical-json(row-fields))
169
+ //
170
+ // order_id and restored_points are EXCLUDED from the hashed fields: both are
171
+ // mutated AFTER the row is inserted (linkRedemptionToOrder stamps order_id;
172
+ // reverse/restore advance restored_points), so hashing them would report a
173
+ // false tamper on every linked redemption and every restore.
174
+ var SHA3_512_HEX_LEN = 128;
175
+ var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
176
+
177
+ function _rowFieldsForHash(row) {
178
+ return {
179
+ id: row.id,
180
+ customer_id: row.customer_id,
181
+ transaction_type: row.transaction_type,
182
+ points: Number(row.points),
183
+ source: row.source == null ? null : row.source,
184
+ notes: row.notes == null ? null : row.notes,
185
+ balance_after_points: Number(row.balance_after_points),
186
+ lifetime_after_points: Number(row.lifetime_after_points),
187
+ tier_after: row.tier_after,
188
+ occurred_at: Number(row.occurred_at),
189
+ // order_id + restored_points EXCLUDED — both mutated post-insert.
190
+ };
191
+ }
192
+
193
+ function _computeRowHash(prevHash, rowFields) {
194
+ var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
195
+ var preimage = Buffer.concat([
196
+ Buffer.from(prevHash, "hex"),
197
+ Buffer.from(canonical, "utf8"),
198
+ ]);
199
+ return b.crypto.sha3Hash(preimage);
200
+ }
201
+
157
202
  // ---- factory ------------------------------------------------------------
158
203
 
159
204
  function create(opts) {
@@ -205,17 +250,6 @@ function create(opts) {
205
250
  return r.rows[0] || null;
206
251
  }
207
252
 
208
- async function _writeTx(customerId, type, points, source, orderId, notes, ts) {
209
- var id = b.uuid.v7();
210
- await query(
211
- "INSERT INTO loyalty_transactions " +
212
- "(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at) " +
213
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
214
- [id, customerId, type, points, source, orderId, notes, ts],
215
- );
216
- return id;
217
- }
218
-
219
253
  async function _ensureAccountRow(customerId, ts) {
220
254
  // INSERT OR IGNORE is idempotent — repeated calls land a single
221
255
  // row. We don't bump `updated_at` on the existing-row path; the
@@ -228,23 +262,133 @@ function create(opts) {
228
262
  );
229
263
  }
230
264
 
231
- // SQL fragment that derives the tier from a lifetime-points
232
- // expression evaluated INSIDE the UPDATE statement, so balance,
233
- // lifetime, and tier all move in one atomic write off the row's
234
- // live value rather than a stale snapshot. `lifetimeExpr` is the
235
- // post-mutation lifetime SQL (e.g. `lifetime_points + ?2`). The
236
- // operator-tunable thresholds bind as literals they're validated
237
- // non-negative integers at factory time, never operator input here,
238
- // so inlining them keeps the CASE a single self-contained
239
- // expression without widening the bound-parameter list per call.
240
- // Mirrors computeTier's highest-threshold-first ladder so the SQL
241
- // and JS classifications never diverge.
242
- function _tierCase(lifetimeExpr) {
243
- return "CASE" +
244
- " WHEN (" + lifetimeExpr + ") >= " + thresholds.platinum + " THEN 'platinum'" +
245
- " WHEN (" + lifetimeExpr + ") >= " + thresholds.gold + " THEN 'gold'" +
246
- " WHEN (" + lifetimeExpr + ") >= " + thresholds.silver + " THEN 'silver'" +
247
- " ELSE 'bronze' END";
265
+ // O(1) chain-tip read: the latest row by (customer_id, occurred_at DESC,
266
+ // id DESC) carries the running balance/lifetime/tier snapshot AFTER it.
267
+ // No SUM at read time. Falls through to a bronze zero-tip when the customer
268
+ // has no rows (a brand-new account anchors its chain from ZERO on the first
269
+ // write). A legacy/genesis NULL row_hash coerces to ZERO_HASH so the first
270
+ // hashed row anchors the chain (the lazy genesis anchor).
271
+ async function _readLatest(customerId) {
272
+ var r = await query(
273
+ "SELECT id, balance_after_points, lifetime_after_points, tier_after, occurred_at, row_hash " +
274
+ "FROM loyalty_transactions WHERE customer_id = ?1 " +
275
+ "ORDER BY occurred_at DESC, id DESC LIMIT 1",
276
+ [customerId],
277
+ );
278
+ if (!r.rows.length) {
279
+ return { id: null, balance: 0, lifetime: 0, tier: "bronze",
280
+ occurred_at: null, row_hash: ZERO_HASH };
281
+ }
282
+ var row = r.rows[0];
283
+ return {
284
+ id: row.id,
285
+ balance: Number(row.balance_after_points),
286
+ lifetime: Number(row.lifetime_after_points),
287
+ tier: row.tier_after,
288
+ occurred_at: Number(row.occurred_at),
289
+ row_hash: row.row_hash == null ? ZERO_HASH : row.row_hash,
290
+ };
291
+ }
292
+
293
+ // Strict-monotonic per-customer occurred_at: two writes in the same ms (or a
294
+ // backdated write) would tie and make the tip ambiguous, so a colliding
295
+ // timestamp bumps to prior + 1. Non-colliding (future) timestamps land as
296
+ // requested.
297
+ function _resolveOccurredAt(requestedTs, latestTs) {
298
+ if (latestTs == null) return requestedTs;
299
+ if (requestedTs > latestTs) return requestedTs;
300
+ return latestTs + 1;
301
+ }
302
+
303
+ // Re-derive the tip this many times when the chain-parent fence refuses an
304
+ // INSERT (a concurrent write claimed the slot). Each fence round lets exactly
305
+ // one racing writer win, so the cap is sized well beyond realistic same-
306
+ // customer fan-in; a genuine non-collision insert error re-throws on attempt
307
+ // one (the tip is unchanged), so a high cap never spins on a real failure.
308
+ var CHAIN_WRITE_ATTEMPTS = 64;
309
+
310
+ // One fenced write attempt off a freshly-read tip. Every event kind writes
311
+ // through here so every row joins the per-customer chain. The chain-parent
312
+ // fence (UNIQUE(customer_id, prev_hash)) makes the app-computed prev_hash /
313
+ // row_hash safe: a row derived from a stale tip collides instead of forking
314
+ // the chain or persisting a stale balance_after. The overdraft / underflow
315
+ // gate stays INSIDE the statement (correlated subquery against the live tip,
316
+ // never an app-side check) so a refusal is a zero-row no-op. Returns one of
317
+ // { written }, { refused } (guard said no), { collided } (tip moved — retry).
318
+ async function _attemptChainedWrite(customerId, d) {
319
+ var id = b.uuid.v7();
320
+ var rowHash = _computeRowHash(d.prevHash, {
321
+ id: id,
322
+ customer_id: customerId,
323
+ transaction_type: d.type,
324
+ points: d.points,
325
+ source: d.source,
326
+ notes: d.notes,
327
+ balance_after_points: d.balanceAfter,
328
+ lifetime_after_points: d.lifetimeAfter,
329
+ tier_after: d.tierAfter,
330
+ occurred_at: d.ts,
331
+ });
332
+ var balSub =
333
+ "COALESCE((SELECT balance_after_points FROM loyalty_transactions " +
334
+ "WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
335
+ try {
336
+ var res = await query(
337
+ "INSERT INTO loyalty_transactions " +
338
+ "(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at, " +
339
+ "balance_after_points, lifetime_after_points, tier_after, prev_hash, row_hash) " +
340
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 " +
341
+ "WHERE " + (d.guard ? d.guard.replace(/__BAL__/g, balSub) : "1"),
342
+ [id, customerId, d.type, d.points, d.source, d.orderId, d.notes, d.ts,
343
+ d.balanceAfter, d.lifetimeAfter, d.tierAfter, d.prevHash, rowHash],
344
+ );
345
+ if (Number(res.rowCount || 0) === 0) return { refused: true };
346
+ return { written: { id: id, points: d.points, balance: d.balanceAfter,
347
+ lifetime: d.lifetimeAfter, tier: d.tierAfter, occurred_at: d.ts } };
348
+ } catch (e) {
349
+ // State-agnostic collision detection — never error-message matching (the
350
+ // prod D1 bridge redacts the SQLite UNIQUE text to a generic HTTP 500).
351
+ // If the tip advanced past the parent we tried to chain off, a competing
352
+ // write claimed our slot — retry; otherwise the insert failed for another
353
+ // reason — re-throw.
354
+ var after = await _readLatest(customerId);
355
+ if (after.row_hash !== d.prevHash) return { collided: true };
356
+ throw e;
357
+ }
358
+ }
359
+
360
+ // Run a chained write with bounded tip-contention retries. `derive(tip)` maps
361
+ // the freshly-read tip to the concrete row fields; each fence collision
362
+ // re-reads + re-derives so the values that land are always consistent with
363
+ // the parent they chain off. Returns the written row, or { refused: true }
364
+ // when the in-statement guard rejected it against the live tip.
365
+ async function _writeChained(customerId, derive) {
366
+ for (var attempt = 0; attempt < CHAIN_WRITE_ATTEMPTS; attempt += 1) {
367
+ var latest = await _readLatest(customerId);
368
+ var d = derive(latest);
369
+ d.prevHash = latest.row_hash;
370
+ var r = await _attemptChainedWrite(customerId, d);
371
+ if (r.collided) continue;
372
+ if (r.refused) return { refused: true };
373
+ return r.written;
374
+ }
375
+ var contention = new Error("loyalty: persistent chain-tip contention — retry the write");
376
+ contention.code = "LOYALTY_CONTENTION";
377
+ throw contention;
378
+ }
379
+
380
+ // Best-effort refresh of the loyalty_accounts mirror from a freshly-written
381
+ // tip. The mirror is NOT authoritative and NOT on any mutation's decision
382
+ // path — balance() and every guard read the chain tip; the mirror exists only
383
+ // for the cross-customer tierLeaderboard and the operator tier_expires_at
384
+ // time-bound. Runs OUTSIDE the atomicity boundary: a crash here leaves money
385
+ // exactly-once correct and the next advancing write re-syncs it.
386
+ async function _refreshMirror(customerId, w, ts) {
387
+ await query(
388
+ "UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
389
+ "tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
390
+ [w.balance, w.lifetime, w.tier, ts, customerId],
391
+ );
248
392
  }
249
393
 
250
394
  return {
@@ -277,29 +421,31 @@ function create(opts) {
277
421
  var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
278
422
  var notes = _notes(input.notes);
279
423
 
280
- var ts = _now();
281
- await _ensureAccountRow(customerId, ts);
282
- // Snapshot the tier ONLY to report `tier_changed` the balance /
283
- // lifetime / tier mutation itself is relative-atomic below, so a
284
- // concurrent earn can't clobber this credit (the lost-update the
285
- // absolute write suffered). The tier is recomputed in-SQL off the
286
- // row's live `lifetime_points`, not this stale snapshot.
287
- var before = await _readAccount(customerId);
288
- await query(
289
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
290
- "lifetime_points = lifetime_points + ?1, " +
291
- "tier = " + _tierCase("lifetime_points + ?1") + ", " +
292
- "updated_at = ?2 WHERE customer_id = ?3",
293
- [points, ts, customerId],
294
- );
295
- await _writeTx(customerId, "earn", points, source, orderId, notes, ts);
296
-
297
- var after = await _readAccount(customerId);
424
+ var requested = _now();
425
+ await _ensureAccountRow(customerId, requested);
426
+ // Balance + lifetime + tier all ride the SAME guarded chained INSERT off
427
+ // the freshly-read tip there is no stored-column second statement to
428
+ // lose. earn advances lifetime and recomputes the tier off the new
429
+ // lifetime; `tier_changed` compares the chain's before/after tiers (both
430
+ // from the tip, no separate snapshot read).
431
+ var tierBefore;
432
+ var w = await _writeChained(customerId, function (latest) {
433
+ tierBefore = latest.tier;
434
+ var lifeAfter = latest.lifetime + points;
435
+ return {
436
+ type: "earn", points: points, source: source, orderId: orderId, notes: notes,
437
+ balanceAfter: latest.balance + points,
438
+ lifetimeAfter: lifeAfter,
439
+ tierAfter: computeTier(lifeAfter),
440
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
441
+ };
442
+ });
443
+ await _refreshMirror(customerId, w, requested);
298
444
  return {
299
- balance: after.balance_points,
300
- lifetime: after.lifetime_points,
301
- tier: after.tier,
302
- tier_changed: after.tier !== before.tier,
445
+ balance: w.balance,
446
+ lifetime: w.lifetime,
447
+ tier: w.tier,
448
+ tier_changed: w.tier !== tierBefore,
303
449
  };
304
450
  },
305
451
 
@@ -312,43 +458,41 @@ function create(opts) {
312
458
  var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
313
459
  var notes = _notes(input.notes);
314
460
 
315
- var ts = _now();
316
- await _ensureAccountRow(customerId, ts);
317
- var before = await _readAccount(customerId);
318
- if (before.balance_points < points) {
461
+ var requested = _now();
462
+ await _ensureAccountRow(customerId, requested);
463
+ // The overdraft refusal lives INSIDE the guarded INSERT (balSub + points
464
+ // >= 0, where points is the negative burn delta) — never an app-side
465
+ // pre-check — so two concurrent redemptions can't double-spend off a
466
+ // stale snapshot: the chain-parent fence serializes them and the loser
467
+ // re-reads the winner's smaller balance and is refused. Lifetime is not
468
+ // affected; redemption spends from the running balance only.
469
+ var w = await _writeChained(customerId, function (latest) {
470
+ return {
471
+ type: "redeem", points: -points, source: "redeem", orderId: orderId, notes: notes,
472
+ balanceAfter: latest.balance - points,
473
+ lifetimeAfter: latest.lifetime,
474
+ tierAfter: latest.tier,
475
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
476
+ guard: "__BAL__ + ?4 >= 0",
477
+ };
478
+ });
479
+ if (w.refused) {
319
480
  var ins = new Error("loyalty.redeem: insufficient balance");
320
481
  ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
321
482
  throw ins;
322
483
  }
323
-
324
- // Atomic decrement guarded by a balance check at the SQL tier so
325
- // two concurrent redemptions can't double-spend. Lifetime is not
326
- // affected — redemption spends from the running balance only.
327
- var dec = await query(
328
- "UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
329
- "updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
330
- [points, ts, customerId],
331
- );
332
- if (!b.sql.casWon(dec).won) {
333
- var raced = new Error("loyalty.redeem: insufficient balance");
334
- raced.code = "LOYALTY_INSUFFICIENT_BALANCE";
335
- throw raced;
336
- }
337
-
338
- var txId = await _writeTx(customerId, "redeem", -points, "redeem", orderId, notes, ts);
339
-
340
- var after = await _readAccount(customerId);
484
+ await _refreshMirror(customerId, w, requested);
485
+ var acct = await _readAccount(customerId);
341
486
  return {
342
- balance: after.balance_points,
343
- lifetime: after.lifetime_points,
344
- tier: after.tier,
345
- tier_expires_at: after.tier_expires_at,
346
- // Ledger row id of this burn — checkout debits points BEFORE its
347
- // order exists (the debit is the double-spend gate) and links the
348
- // row to the order afterwards via linkRedemptionToOrder, or
349
- // compensates via reverseRedemptionById when the checkout dies
350
- // before the order is created.
351
- tx_id: txId,
487
+ balance: w.balance,
488
+ lifetime: w.lifetime,
489
+ tier: w.tier,
490
+ tier_expires_at: acct ? acct.tier_expires_at : null,
491
+ // Ledger row id of this burn — checkout debits points BEFORE its order
492
+ // exists (the debit is the double-spend gate) and links the row to the
493
+ // order afterwards via linkRedemptionToOrder, or compensates via
494
+ // reverseRedemptionById when the checkout dies before the order exists.
495
+ tx_id: w.id,
352
496
  };
353
497
  },
354
498
 
@@ -396,13 +540,22 @@ function create(opts) {
396
540
  );
397
541
  if (!b.sql.casWon(claim).won) return { restored_points: 0 }; // lost the claim
398
542
  await _ensureAccountRow(row.customer_id, ts);
399
- await query(
400
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
401
- "updated_at = ?2 WHERE customer_id = ?3",
402
- [spent, ts, row.customer_id],
403
- );
404
- await _writeTx(row.customer_id, "redeem", spent, "redeem-reversal", null,
405
- "restored ref=tx:" + row.id, ts);
543
+ // Forward-only credit-back: ONE chained +spent row off the live tip
544
+ // (lifetime untouched). The restored_points claim above is the
545
+ // serialization point, so a double-fire wins no claim and appends
546
+ // nothing — exactly-once. There is no revert anywhere, so the
547
+ // two-statement double-credit-on-revert hazard is structurally gone.
548
+ var w = await _writeChained(row.customer_id, function (latest) {
549
+ return {
550
+ type: "redeem", points: spent, source: "redeem-reversal", orderId: null,
551
+ notes: "restored ref=tx:" + row.id,
552
+ balanceAfter: latest.balance + spent,
553
+ lifetimeAfter: latest.lifetime,
554
+ tierAfter: latest.tier,
555
+ ts: _resolveOccurredAt(ts, latest.occurred_at),
556
+ };
557
+ });
558
+ await _refreshMirror(row.customer_id, w, ts);
406
559
  return { restored_points: spent };
407
560
  },
408
561
 
@@ -475,18 +628,24 @@ function create(opts) {
475
628
  );
476
629
  if (!b.sql.casWon(claim).won) continue; // lost the claim
477
630
  await _ensureAccountRow(row.customer_id, ts);
478
- // Credit BALANCE only lifetime is untouched (a redeem never moved
479
- // lifetime; restoring it mustn't either). Relative-atomic so a
480
- // concurrent earn/redeem can't clobber it.
481
- await query(
482
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
483
- "updated_at = ?2 WHERE customer_id = ?3",
484
- [delta, ts, row.customer_id],
485
- );
486
- // Positive `redeem`-type ledger row so the trail nets to the
487
- // un-refunded spend. `source` distinguishes it from the original burn.
488
- await _writeTx(row.customer_id, "redeem", delta, "redeem-reversal", oid,
489
- "restored ref=order:" + oid, ts);
631
+ // Forward-only credit-back: ONE chained +delta row off the live tip,
632
+ // BALANCE only (lifetime untouched a redeem never moved it, so the
633
+ // restore mustn't either). The restored_points claim above is the
634
+ // serialization point; there is no revert, so no double-credit hazard.
635
+ // `source='redeem-reversal'` distinguishes it from the original burn so
636
+ // the scan above never re-reads it. _writeChained is awaited before the
637
+ // loop advances, so `delta`/`oid`/`ts` are stable inside the derive.
638
+ var wRestore = await _writeChained(row.customer_id, function (latest) {
639
+ return {
640
+ type: "redeem", points: delta, source: "redeem-reversal", orderId: oid,
641
+ notes: "restored ref=order:" + oid,
642
+ balanceAfter: latest.balance + delta,
643
+ lifetimeAfter: latest.lifetime,
644
+ tierAfter: latest.tier,
645
+ ts: _resolveOccurredAt(ts, latest.occurred_at),
646
+ };
647
+ });
648
+ await _refreshMirror(row.customer_id, wRestore, ts);
490
649
  restoredTotal += delta;
491
650
  }
492
651
  return { restored_points: restoredTotal };
@@ -501,47 +660,38 @@ function create(opts) {
501
660
  var source = _source(input.source);
502
661
  var notes = _notes(input.notes);
503
662
 
504
- var ts = _now();
505
- await _ensureAccountRow(customerId, ts);
506
- // Snapshot the pre-adjust tier only to report `tier_changed`. The
507
- // mutation is relative-atomic with an underflow guard at the SQL
508
- // tier so two concurrent adjustments can't lose an update or drive
509
- // the balance negative past each other.
510
- var before = await _readAccount(customerId);
511
-
512
- // Positive adjustments also increment lifetime — operators
513
- // crediting a customer for a service recovery should see that
514
- // credit count toward tier. Negative adjustments do NOT
515
- // decrement lifetime (otherwise a clawback could downgrade tier
516
- // retroactively, which is a customer-facing surprise). The
517
- // lifetime delta is therefore the positive part of `delta`.
663
+ var requested = _now();
664
+ await _ensureAccountRow(customerId, requested);
665
+ // Positive adjustments increment lifetime (operator goodwill counts
666
+ // toward tier) and recompute the tier; negative adjustments do NOT move
667
+ // lifetime (no retroactive tier downgrade) and are guarded so the balance
668
+ // can't go below zero (balSub + delta >= 0, delta negative) the loser
669
+ // of a concurrent over-draw re-reads the live balance and is refused.
518
670
  var lifetimeDelta = delta > 0 ? delta : 0;
519
- // Conditional UPDATE: the row mutates ONLY when the post-adjust
520
- // balance stays non-negative, checked against the row's LIVE
521
- // balance (not the stale snapshot). A racing concurrent adjust
522
- // that already spent the balance makes this match zero rows, so
523
- // we surface the same insufficient-balance refusal rather than
524
- // writing a ledger row that diverges from the account.
525
- var upd = await query(
526
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
527
- "lifetime_points = lifetime_points + ?2, " +
528
- "tier = " + _tierCase("lifetime_points + ?2") + ", " +
529
- "updated_at = ?3 WHERE customer_id = ?4 AND balance_points + ?1 >= 0",
530
- [delta, lifetimeDelta, ts, customerId],
531
- );
532
- if (!b.sql.casWon(upd).won) {
671
+ var tierBefore;
672
+ var w = await _writeChained(customerId, function (latest) {
673
+ tierBefore = latest.tier;
674
+ var lifeAfter = latest.lifetime + lifetimeDelta;
675
+ return {
676
+ type: "adjust", points: delta, source: source, orderId: null, notes: notes,
677
+ balanceAfter: latest.balance + delta,
678
+ lifetimeAfter: lifeAfter,
679
+ tierAfter: delta > 0 ? computeTier(lifeAfter) : latest.tier,
680
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
681
+ guard: delta < 0 ? "__BAL__ + ?4 >= 0" : undefined,
682
+ };
683
+ });
684
+ if (w.refused) {
533
685
  var ins = new Error("loyalty.adjust: adjustment would underflow balance");
534
686
  ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
535
687
  throw ins;
536
688
  }
537
- await _writeTx(customerId, "adjust", delta, source, null, notes, ts);
538
-
539
- var after = await _readAccount(customerId);
689
+ await _refreshMirror(customerId, w, requested);
540
690
  return {
541
- balance: after.balance_points,
542
- lifetime: after.lifetime_points,
543
- tier: after.tier,
544
- tier_changed: after.tier !== before.tier,
691
+ balance: w.balance,
692
+ lifetime: w.lifetime,
693
+ tier: w.tier,
694
+ tier_changed: w.tier !== tierBefore,
545
695
  };
546
696
  },
547
697
 
@@ -564,21 +714,22 @@ function create(opts) {
564
714
  var source = _source(input.source);
565
715
  var notes = _notes(input.notes);
566
716
 
567
- var ts = _now();
568
- await _ensureAccountRow(customerId, ts);
569
- await query(
570
- "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
571
- "updated_at = ?2 WHERE customer_id = ?3",
572
- [points, ts, customerId],
573
- );
574
- await _writeTx(customerId, "adjust", points, source, null, notes, ts);
575
-
576
- var after = await _readAccount(customerId);
577
- return {
578
- balance: after.balance_points,
579
- lifetime: after.lifetime_points,
580
- tier: after.tier,
581
- };
717
+ var requested = _now();
718
+ await _ensureAccountRow(customerId, requested);
719
+ // Forward-only +points chained row, BALANCE only — never moves lifetime
720
+ // and never recomputes tier (giving redeemed points back must not inflate
721
+ // the tier-driving total above what was actually earned).
722
+ var w = await _writeChained(customerId, function (latest) {
723
+ return {
724
+ type: "adjust", points: points, source: source, orderId: null, notes: notes,
725
+ balanceAfter: latest.balance + points,
726
+ lifetimeAfter: latest.lifetime,
727
+ tierAfter: latest.tier,
728
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
729
+ };
730
+ });
731
+ await _refreshMirror(customerId, w, requested);
732
+ return { balance: w.balance, lifetime: w.lifetime, tier: w.tier };
582
733
  },
583
734
 
584
735
  expire: async function (input) {
@@ -592,55 +743,49 @@ function create(opts) {
592
743
  }
593
744
  var reason = _source(input.reason);
594
745
 
595
- var ts = _now();
596
- await _ensureAccountRow(customerId, ts);
597
- var before = await _readAccount(customerId);
598
-
599
- // Expiry caps at the current balance operators schedule annual
600
- // sweeps that compute "points older than 365d" from the ledger;
601
- // when the live balance is already smaller than the requested
602
- // expire amount (because of an interim redemption), we expire
603
- // only what's there rather than refusing.
604
- var toExpire = points > before.balance_points ? before.balance_points : points;
605
- if (toExpire === 0) {
606
- // No-op write the ledger row anyway so the audit trail
607
- // records that the operator ran the sweep and found nothing.
608
- await _writeTx(customerId, "expire", 0, reason, null, "", ts);
746
+ var requested = _now();
747
+ await _ensureAccountRow(customerId, requested);
748
+ // Cap at the LIVE balance on every attempt (an interim redemption may
749
+ // have shrunk it) — expire only what's there, never refusing. A zero-cap
750
+ // (empty balance) still writes a points:0 breadcrumb so the audit trail
751
+ // records the sweep ran. Lifetime is untouched. The chained row carries
752
+ // the new running balance, so there is no stored-column second statement.
753
+ var w = await _writeChained(customerId, function (latest) {
754
+ var toExpire = points > latest.balance ? latest.balance : points;
609
755
  return {
610
- balance: before.balance_points,
611
- lifetime: before.lifetime_points,
612
- tier: before.tier,
613
- expired: 0,
756
+ type: "expire", points: -toExpire, source: reason, orderId: null, notes: "",
757
+ balanceAfter: latest.balance - toExpire,
758
+ lifetimeAfter: latest.lifetime,
759
+ tierAfter: latest.tier,
760
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
761
+ // toExpire is capped at the same tip so the debit never underflows;
762
+ // the guard is belt-and-braces against a racing debit (which would
763
+ // instead collide on the fence and re-cap on retry).
764
+ guard: "__BAL__ + ?4 >= 0",
614
765
  };
615
- }
616
-
617
- await query(
618
- "UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
619
- "updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
620
- [toExpire, ts, customerId],
621
- );
622
- await _writeTx(customerId, "expire", -toExpire, reason, null, "", ts);
623
-
624
- var after = await _readAccount(customerId);
766
+ });
767
+ await _refreshMirror(customerId, w, requested);
625
768
  return {
626
- balance: after.balance_points,
627
- lifetime: after.lifetime_points,
628
- tier: after.tier,
629
- expired: toExpire,
769
+ balance: w.balance,
770
+ lifetime: w.lifetime,
771
+ tier: w.tier,
772
+ expired: -w.points,
630
773
  };
631
774
  },
632
775
 
633
776
  balance: async function (customerId) {
634
777
  _uuid(customerId, "customer_id");
635
- var row = await _readAccount(customerId);
636
- if (!row) {
637
- return { balance: 0, lifetime: 0, tier: "bronze", tier_expires_at: null };
638
- }
778
+ // Authoritative balance/lifetime/tier come from the chain tip (O(1) on
779
+ // the running index), NOT the advisory loyalty_accounts mirror. Only
780
+ // tier_expires_at an operator time-bound the ledger doesn't carry — is
781
+ // read from the mirror.
782
+ var tip = await _readLatest(customerId);
783
+ var acct = await _readAccount(customerId);
639
784
  return {
640
- balance: row.balance_points,
641
- lifetime: row.lifetime_points,
642
- tier: row.tier,
643
- tier_expires_at: row.tier_expires_at,
785
+ balance: tip.balance,
786
+ lifetime: tip.lifetime,
787
+ tier: tip.tier,
788
+ tier_expires_at: acct ? acct.tier_expires_at : null,
644
789
  };
645
790
  },
646
791
 
@@ -652,8 +797,14 @@ function create(opts) {
652
797
  throw new TypeError("loyalty.history: limit must be an integer in [1, 500]");
653
798
  }
654
799
  var cursor = opts2.cursor;
800
+ // Exclude the per-customer chain-genesis anchor (the migration backfill
801
+ // row, id 'genesis-<customer_id>', points 0) — it carries the running-
802
+ // balance snapshot for the chain, not a real customer transaction. A real
803
+ // uuid.v7 id never starts with 'genesis-', so this excludes only the
804
+ // anchor (filtering by id-prefix, not by source: an operator source could
805
+ // legitimately be 'chain-genesis').
655
806
  var sql = "SELECT id, customer_id, transaction_type, points, source, order_id, notes, occurred_at " +
656
- "FROM loyalty_transactions WHERE customer_id = ?1";
807
+ "FROM loyalty_transactions WHERE customer_id = ?1 AND id NOT LIKE 'genesis-%'";
657
808
  var params = [customerId];
658
809
  if (cursor != null) {
659
810
  if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0) {
@@ -700,6 +851,70 @@ function create(opts) {
700
851
  var r = await query(sql, params);
701
852
  return r.rows;
702
853
  },
854
+
855
+ // Walk a customer's running-balance chain and recompute every row_hash to
856
+ // prove no row was edited or re-ordered after the fact (the loyalty leg of
857
+ // the money-ledger tamper-evidence). Mirrors gift-card-ledger.verifyChain:
858
+ // a NULL-row_hash legacy/genesis prefix is skipped until the first hashed
859
+ // row anchors the chain; a populated all-NULL ledger is `unanchored` (the
860
+ // shape a full rewrite produces), not "valid". An optional trusted
861
+ // { count, head } anchor additionally rules out a tail truncation. Verifies
862
+ // over _rowFieldsForHash — the SAME tuple the writer hashes (order_id +
863
+ // restored_points excluded), so a linked redemption or a restore can't
864
+ // false-tamper. O(n); operator-audit use, not hot-path.
865
+ verifyChain: async function (customerId, opts) {
866
+ _uuid(customerId, "customer_id");
867
+ opts = opts || {};
868
+ var anchor = null;
869
+ if (opts.anchor != null) {
870
+ if (typeof opts.anchor !== "object"
871
+ || typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
872
+ || typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
873
+ throw new TypeError("loyalty.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
874
+ }
875
+ anchor = opts.anchor;
876
+ }
877
+ var rows = (await query(
878
+ "SELECT * FROM loyalty_transactions WHERE customer_id = ?1 ORDER BY occurred_at ASC, id ASC",
879
+ [customerId],
880
+ )).rows;
881
+ var legacyPrefix = 0, anchored = false, prevHash = ZERO_HASH;
882
+ for (var i = 0; i < rows.length; i += 1) {
883
+ var row = rows[i];
884
+ if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
885
+ anchored = true;
886
+ var breakBase = { ok: false, rows_verified: i - legacyPrefix, legacy_prefix: legacyPrefix,
887
+ break_at: i, break_row_id: row.id };
888
+ if (row.row_hash == null) return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
889
+ if (row.prev_hash !== prevHash) {
890
+ return Object.assign(breakBase, { reason: "prev_hash mismatch", expected: prevHash, actual: row.prev_hash });
891
+ }
892
+ var computed = _computeRowHash(prevHash, _rowFieldsForHash(row));
893
+ if (computed !== row.row_hash) {
894
+ return Object.assign(breakBase, { reason: "row_hash mismatch", expected: computed, actual: row.row_hash });
895
+ }
896
+ prevHash = row.row_hash;
897
+ }
898
+ if (!anchored && rows.length > 0) {
899
+ return { ok: false, rows_verified: 0, legacy_prefix: legacyPrefix, break_at: 0,
900
+ break_row_id: rows[0].id, reason: "unanchored chain (no hashed row in a populated ledger)" };
901
+ }
902
+ if (anchor) {
903
+ if (rows.length < anchor.count) {
904
+ return { ok: false, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
905
+ anchor_checked: true, reason: "row count below anchor (possible tail truncation)",
906
+ expected_count: anchor.count, actual_count: rows.length };
907
+ }
908
+ var anchorRow = rows[anchor.count - 1];
909
+ if (anchorRow.row_hash !== anchor.head) {
910
+ return { ok: false, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
911
+ anchor_checked: true, reason: "anchor head mismatch (chain replaced below snapshot)",
912
+ expected: anchor.head, actual: anchorRow.row_hash };
913
+ }
914
+ }
915
+ return { ok: true, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
916
+ head: prevHash, anchor_checked: !!anchor };
917
+ },
703
918
  };
704
919
  }
705
920
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.104",
3
+ "version": "0.4.105",
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": {