@blamejs/blamejs-shop 0.4.88 → 0.4.89
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/SECURITY.md +14 -9
- package/lib/asset-manifest.json +1 -1
- package/lib/gift-card-ledger.js +72 -15
- package/lib/store-credit.js +340 -89
- 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.89 (2026-06-24) — **Store-credit wallet balances are now tamper-evident, and concurrent gift-card ledger writes retry reliably in production.** The store-credit ledger gains the same per-customer cryptographic hash chain the gift-card ledger already carries, so a balance can no longer be silently altered by a direct database edit. Each wallet row now links to the prior one with a SHA3-512 hash over its contents, and a new verifyChain check recomputes the chain and reports the first row that doesn't link — a denormalized balance snapshot was previously a value, not a proof, so a row edit that inflated a balance or a deletion that dropped a debit passed a balance read undetected. The same change makes the balance computation safe under concurrency through a chain-parent fence and a re-read-and-retry on contention. Separately, a latent reliability bug in the gift-card ledger's concurrent-write retry is fixed: it detected a write collision by matching the database's error text, but the production data-layer redacts that text to a generic error, so a genuine collision between two writes against the same gift card could surface as a failed request instead of retrying — both ledgers now detect a collision by re-reading the chain tip, which is robust regardless of how the error is reported. No configuration changes; the store-credit schema additions apply automatically on upgrade. **Added:** *Store-credit ledger is tamper-evident with a per-customer hash chain* — Every store-credit wallet row now carries prev_hash + row_hash columns forming a per-customer SHA3-512 chain: row_hash = SHA3-512(prev_hash || canonical-json(row fields)), anchored at a genesis sentinel for a wallet's first row. A direct database edit that inflates a balance snapshot, or a deletion that drops a debit, breaks the chain at that row. A new storeCredit.verifyChain(customer_id) walks the wallet and returns the first divergence (or confirms the chain is intact); a wallet whose hash columns have all been cleared reads as unanchored rather than trusting the unverifiable rows. Because deleting a chain's most-recent rows leaves the remaining prefix internally consistent — a truncation an append-only chain can't detect on its own — verifyChain optionally accepts a trusted anchor (a row count and head hash captured from an earlier snapshot) and reports a possible truncation when the chain has fallen below it. The balance and occurred-at values are now computed in the application and protected under concurrency by a chain-parent uniqueness fence — a write derived from a stale view of the wallet collides and is retried against the current tip, so the denormalized balance stays exact under concurrent credits, debits, expirations, and the scheduled expiry sweep. **Fixed:** *Concurrent gift-card and store-credit ledger writes retry reliably in production* — The ledger retries a write that loses a race against a concurrent write to the same entity. It previously recognized that collision by matching the database engine's constraint-violation text — but the production data-layer redacts that text to a generic error, so in production a genuine collision was not recognized and the write surfaced as a failed request instead of retrying (the path only worked under the in-memory test engine, where the full error text survives). Both the gift-card ledger and the new store-credit chain now detect a collision by re-reading the chain tip after a failed write: if the tip has advanced past the row the write tried to extend, a competing write claimed the slot and the write is retried; otherwise the original error is surfaced. The retry is now robust regardless of how the underlying error is reported.
|
|
12
|
+
|
|
11
13
|
- v0.4.88 (2026-06-24) — **Refreshes the vendored framework to 0.15.19 and makes the vendored-dependency SBOM a committed, drift-guarded artifact.** Two supply-chain changes, no application configuration changes. The vendored blamejs framework moves from 0.15.16 to 0.15.19, picking up a set of upstream correctness and confidentiality fixes that ride inside the primitives the store already composes: a rejection on an established encrypted-API session is now returned inside the encrypted session envelope instead of disclosing which check failed in cleartext; stapled-OCSP response-freshness validation is restored; the DPoP proof's request-URI reconstruction now peer-gates forwarded scheme/host headers; the bundled public-suffix list is refreshed; and a family of credential/certificate verifiers continues to fail closed on an unparseable timestamp. Separately, the vendored-dependency SBOM (sbom.vendored.cdx.json) is now committed to the repository and mechanically projected from the vendored manifest, so its component versions can never silently fall behind what actually ships under lib/vendor/: a build gate fails on any drift, and the published, signed SBOM is the same verified file rather than one regenerated at publish time. Upgrade to pick both up. **Added:** *Vendored-dependency SBOM is a committed, drift-guarded artifact* — sbom.vendored.cdx.json — the CycloneDX 1.6 SBOM describing what ships under lib/vendor/ — is now committed to the repository and generated as a deterministic projection of lib/vendor/MANIFEST.json (the source of truth for the vendored version, license, and source). The vendor refresh regenerates it automatically, and a build gate fails if the committed SBOM drifts from the vendored manifest, so its component versions can never silently fall behind the vendored tree — the same protection the changelog, the release-notes rollup, and the asset-manifest already carry. The signed SBOM published as a release asset is now this same verified file rather than one regenerated at publish time, so the artifact downstream scanners consume is byte-reproducible from the committed source. **Changed:** *Vendored blamejs framework refreshed to 0.15.19* — The vendored framework moves from 0.15.16 to 0.15.19. The changes are upstream correctness and confidentiality fixes that ride inside the primitives the store already uses, with no API or configuration change: a rejection on an established encrypted-API session is now encrypted inside the session envelope rather than returned as a cleartext reason that disclosed which check failed; stapled-OCSP response-freshness enforcement (RFC 6960) is restored after a parsing regression had it reject every response; the DPoP htu reconstruction and the new request-host resolver only honor forwarded scheme/host headers from a declared trusted proxy; the bundled Mozilla public-suffix list (used for cookie-scope and same-site decisions) is refreshed to current upstream; and the credential/certificate validity-window verifiers continue to refuse a present-but-unparseable timestamp instead of skipping the check.
|
|
12
14
|
|
|
13
15
|
- v0.4.87 (2026-06-22) — **Adding a bundle now charges the advertised bundle price even when a member item is already in the cart.** Fixes a pricing bug when adding a product bundle to a cart that already contains one of the bundle's items. Adding a bundle allocates the discounted bundle price across its members and writes each member to the cart at its allocated per-unit price. But when a member variant was already in the cart on its own, the add merged into the existing line and kept that line's standalone list price, so the cart subtotal came out above the advertised bundle price — the shopper was quoted the discounted bundle but charged closer to the full sum of parts. Adding a bundle now adds the bundle's units at the bundle price while preserving any units already in the cart: a fresh bundle add realizes exactly the advertised bundle price, and a bundle added on top of a standalone item keeps that item and adds the bundle at its discounted price (rather than dropping the item or extending the discount to it). No configuration changes; upgrade to pick it up. **Fixed:** *Bundle add honors the bundle price without disturbing existing cart units* — Adding a bundle now adds each member's units at the bundle's allocated per-unit price. On a fresh line the realized subtotal is exactly the advertised bundle price. When the variant is already in the cart, its existing units are kept at their value and the bundle's units are added at the bundle price, so the shopper is neither overcharged (the previous behavior charged the standalone list price for the bundle unit) nor has their existing quantity dropped. Adding the same bundle twice adds two bundles' worth of units. Direct add-to-cart of a standalone item is unchanged.
|
package/SECURITY.md
CHANGED
|
@@ -319,15 +319,20 @@ 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
|
|
323
|
-
Every ledger entry — credits, debits, and
|
|
324
|
-
its predecessor through a
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
chain
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
322
|
+
- **The gift-card and store-credit ledgers are hash-chained, fork-proof,
|
|
323
|
+
and verifiable.** Every ledger entry — credits, debits, and
|
|
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
|
|
331
|
+
disputed; pass a trusted anchor (a row count + head hash from an earlier
|
|
332
|
+
snapshot) to also catch a truncation that deletes the most-recent rows,
|
|
333
|
+
which an append-only chain can't detect on its own. The overdraft
|
|
334
|
+
refusal stays inside the same guarded insert, so the integrity device
|
|
335
|
+
never weakens the balance gate.
|
|
331
336
|
- **Privacy exports hold the whole record; erasure states a basis per
|
|
332
337
|
domain.** A subject-access export walks every table that keys a row
|
|
333
338
|
by the customer — identity, orders, subscriptions, addresses, saved
|
package/lib/asset-manifest.json
CHANGED
package/lib/gift-card-ledger.js
CHANGED
|
@@ -242,15 +242,14 @@ function create(opts) {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
// How many times a writer re-derives the chain tip when the parent fence
|
|
245
|
-
// refuses its INSERT (another write landed first). Each
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
245
|
+
// refuses its INSERT (another write landed first). Each fence round lets
|
|
246
|
+
// exactly ONE racing writer win, so a burst of N concurrent writes to the
|
|
247
|
+
// SAME card needs up to N rounds — the cap is sized well beyond any
|
|
248
|
+
// realistic same-card fan-in so a legitimate burst is never dropped with
|
|
249
|
+
// GIFT_CARD_LEDGER_CONTENTION. A genuine non-collision insert error
|
|
250
|
+
// re-throws on the first attempt (see _attemptChainedWrite), so a high cap
|
|
251
|
+
// never spins on a real failure.
|
|
252
|
+
var CHAIN_WRITE_ATTEMPTS = 64;
|
|
254
253
|
|
|
255
254
|
// One fenced write attempt against a tip the caller just read. EVERY
|
|
256
255
|
// event kind writes through here so every row participates in the
|
|
@@ -295,7 +294,16 @@ function create(opts) {
|
|
|
295
294
|
if (Number(res.rowCount || 0) === 0) return { refused: true };
|
|
296
295
|
return { written: { id: id, balance_after_minor: d.after, occurred_at: d.ts } };
|
|
297
296
|
} catch (e) {
|
|
298
|
-
|
|
297
|
+
// State-agnostic collision detection — never error-message matching:
|
|
298
|
+
// the production D1 service-binding redacts the SQLite "UNIQUE
|
|
299
|
+
// constraint failed" text to a generic "HTTP 500", so a message regex
|
|
300
|
+
// is blind in prod (it would only match under node:sqlite, where the
|
|
301
|
+
// full text survives). Re-read the tip — if it advanced past the
|
|
302
|
+
// parent we tried to chain off, a competing write claimed our slot (a
|
|
303
|
+
// genuine fence collision) and we retry; if the tip is unchanged the
|
|
304
|
+
// insert failed for another reason, so re-throw.
|
|
305
|
+
var after = await _readLatest(giftCardId);
|
|
306
|
+
if (after.row_hash !== latest.row_hash) return { collided: true };
|
|
299
307
|
throw e;
|
|
300
308
|
}
|
|
301
309
|
}
|
|
@@ -483,8 +491,26 @@ function create(opts) {
|
|
|
483
491
|
// leaves behind) fails: an all-NULL chain is unanchored, not valid.
|
|
484
492
|
// An empty ledger (zero rows) is the only no-anchor case that passes.
|
|
485
493
|
// O(n) per card; operator-audit use, not hot-path.
|
|
486
|
-
verifyChain: async function (giftCardId) {
|
|
494
|
+
verifyChain: async function (giftCardId, opts) {
|
|
487
495
|
_uuid(giftCardId, "gift_card_id");
|
|
496
|
+
opts = opts || {};
|
|
497
|
+
// Optional trusted anchor (opts.anchor = { count, head } from a prior
|
|
498
|
+
// snapshot's total_rows + last_hash) — the ONLY way to detect a TAIL
|
|
499
|
+
// TRUNCATION or whole-chain replacement, since an append-only chain
|
|
500
|
+
// stays internally consistent after its tail is removed. The row at the
|
|
501
|
+
// snapshot's position must still carry the snapshot's head hash (it
|
|
502
|
+
// survives later valid appends). Without an anchor, truncation is not
|
|
503
|
+
// ruled out and anchor_checked:false is reported (operator-audit-log
|
|
504
|
+
// closes the same gap with signed checkpoints).
|
|
505
|
+
var anchor = null;
|
|
506
|
+
if (opts.anchor != null) {
|
|
507
|
+
if (typeof opts.anchor !== "object"
|
|
508
|
+
|| typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
|
|
509
|
+
|| typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
|
|
510
|
+
throw new TypeError("giftCardLedger.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
|
|
511
|
+
}
|
|
512
|
+
anchor = opts.anchor;
|
|
513
|
+
}
|
|
488
514
|
var r = await query(
|
|
489
515
|
"SELECT * FROM gift_card_ledger WHERE gift_card_id = ?1 " +
|
|
490
516
|
"ORDER BY occurred_at ASC, id ASC",
|
|
@@ -551,11 +577,42 @@ function create(opts) {
|
|
|
551
577
|
reason: "unanchored chain (no hashed row in a populated ledger)",
|
|
552
578
|
};
|
|
553
579
|
}
|
|
580
|
+
// Internal walk is clean. With a trusted anchor, also rule out a tail
|
|
581
|
+
// truncation below the snapshot: the row at the snapshot's position
|
|
582
|
+
// must still carry the snapshot's head hash, and the chain must not be
|
|
583
|
+
// shorter than the snapshot.
|
|
584
|
+
if (anchor) {
|
|
585
|
+
if (rows.length < anchor.count) {
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
rows_verified: rows.length - legacyPrefix,
|
|
589
|
+
legacy_prefix: legacyPrefix,
|
|
590
|
+
anchor_checked: true,
|
|
591
|
+
reason: "row count below anchor (possible tail truncation)",
|
|
592
|
+
expected_count: anchor.count,
|
|
593
|
+
actual_count: rows.length,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
var anchorRow = rows[anchor.count - 1];
|
|
597
|
+
if (!anchorRow || anchorRow.row_hash !== anchor.head) {
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
rows_verified: rows.length - legacyPrefix,
|
|
601
|
+
legacy_prefix: legacyPrefix,
|
|
602
|
+
anchor_checked: true,
|
|
603
|
+
reason: "anchor row hash mismatch (possible tail truncation or divergence)",
|
|
604
|
+
expected: anchor.head,
|
|
605
|
+
actual: anchorRow ? anchorRow.row_hash : null,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
}
|
|
554
609
|
return {
|
|
555
|
-
ok:
|
|
556
|
-
rows_verified:
|
|
557
|
-
legacy_prefix:
|
|
558
|
-
|
|
610
|
+
ok: true,
|
|
611
|
+
rows_verified: rows.length - legacyPrefix,
|
|
612
|
+
legacy_prefix: legacyPrefix,
|
|
613
|
+
total_rows: rows.length,
|
|
614
|
+
last_hash: prevHash,
|
|
615
|
+
anchor_checked: anchor != null,
|
|
559
616
|
};
|
|
560
617
|
},
|
|
561
618
|
|
package/lib/store-credit.js
CHANGED
|
@@ -89,6 +89,12 @@ var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
|
|
|
89
89
|
var MAX_BULK_IDS = 500;
|
|
90
90
|
var MS_PER_DAY = C.TIME.days(1);
|
|
91
91
|
|
|
92
|
+
var SHA3_512_HEX_LEN = 128;
|
|
93
|
+
// Genesis anchor for a customer's first ledger row. Each customer wallet
|
|
94
|
+
// is its own independent chain — the first row links to ZERO, every
|
|
95
|
+
// subsequent row links to the prior row's row_hash for the SAME customer.
|
|
96
|
+
var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
|
|
97
|
+
|
|
92
98
|
// ---- validators ---------------------------------------------------------
|
|
93
99
|
|
|
94
100
|
function _uuid(s, label) {
|
|
@@ -137,6 +143,39 @@ function _epochMs(ts, label) {
|
|
|
137
143
|
|
|
138
144
|
function _now() { return Date.now(); }
|
|
139
145
|
|
|
146
|
+
// ---- chain hashing ------------------------------------------------------
|
|
147
|
+
//
|
|
148
|
+
// row_hash = SHA3-512(prev_hash || canonical-json(row-fields)), mirroring
|
|
149
|
+
// shop.giftCardLedger / operator-audit-log. `b.auditChain.canonicalize`
|
|
150
|
+
// (RFC 8785 walker, sorted keys, hash columns excluded) makes the preimage
|
|
151
|
+
// byte-identical across deployments + node versions. Every persisted column
|
|
152
|
+
// except the two hash columns participates, normalized so the write side
|
|
153
|
+
// and `verifyChain` produce identical preimages for the same logical row.
|
|
154
|
+
|
|
155
|
+
function _rowFieldsForHash(row) {
|
|
156
|
+
return {
|
|
157
|
+
id: row.id,
|
|
158
|
+
customer_id: row.customer_id,
|
|
159
|
+
kind: row.kind,
|
|
160
|
+
amount_minor: Number(row.amount_minor),
|
|
161
|
+
source: row.source == null ? null : row.source,
|
|
162
|
+
source_ref: row.source_ref == null ? null : row.source_ref,
|
|
163
|
+
order_id: row.order_id == null ? null : row.order_id,
|
|
164
|
+
balance_after_minor: Number(row.balance_after_minor),
|
|
165
|
+
expires_at: row.expires_at == null ? null : Number(row.expires_at),
|
|
166
|
+
occurred_at: Number(row.occurred_at),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _computeRowHash(prevHash, rowFields) {
|
|
171
|
+
var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
|
|
172
|
+
var preimage = Buffer.concat([
|
|
173
|
+
Buffer.from(prevHash, "hex"),
|
|
174
|
+
Buffer.from(canonical, "utf8"),
|
|
175
|
+
]);
|
|
176
|
+
return b.crypto.sha3Hash(preimage);
|
|
177
|
+
}
|
|
178
|
+
|
|
140
179
|
// ---- factory ------------------------------------------------------------
|
|
141
180
|
|
|
142
181
|
function create(opts) {
|
|
@@ -151,16 +190,24 @@ function create(opts) {
|
|
|
151
190
|
// aggregation at read time. Falls through to 0 when no rows exist
|
|
152
191
|
// (a customer that has never had a ledger row has zero credit). The id
|
|
153
192
|
// tie-break keeps any legacy same-millisecond rows deterministic; new
|
|
154
|
-
// writes can't tie —
|
|
155
|
-
//
|
|
193
|
+
// writes can't tie — _resolveOccurredAt bumps a colliding timestamp to
|
|
194
|
+
// prior+1 app-side before the fenced INSERT. Also returns the tip's
|
|
195
|
+
// row_hash so the write path can chain off it (see _attemptChainedWrite).
|
|
156
196
|
async function _readLatest(customerId) {
|
|
157
197
|
var r = await query(
|
|
158
|
-
"SELECT balance_after_minor, occurred_at FROM store_credit_ledger " +
|
|
198
|
+
"SELECT balance_after_minor, occurred_at, row_hash FROM store_credit_ledger " +
|
|
159
199
|
"WHERE customer_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 1",
|
|
160
200
|
[customerId],
|
|
161
201
|
);
|
|
162
|
-
if (!r.rows.length) return { balance: 0, occurred_at: null };
|
|
163
|
-
return {
|
|
202
|
+
if (!r.rows.length) return { balance: 0, occurred_at: null, row_hash: ZERO_HASH };
|
|
203
|
+
return {
|
|
204
|
+
balance: r.rows[0].balance_after_minor,
|
|
205
|
+
occurred_at: r.rows[0].occurred_at,
|
|
206
|
+
// A legacy pre-chain row carries NULL row_hash — chain forward from
|
|
207
|
+
// ZERO so the first hashed row anchors the chain; verifyChain treats
|
|
208
|
+
// the unhashed legacy prefix as unverifiable rather than trusting it.
|
|
209
|
+
row_hash: r.rows[0].row_hash == null ? ZERO_HASH : r.rows[0].row_hash,
|
|
210
|
+
};
|
|
164
211
|
}
|
|
165
212
|
|
|
166
213
|
async function _currentBalance(customerId) {
|
|
@@ -168,84 +215,101 @@ function create(opts) {
|
|
|
168
215
|
return latest.balance;
|
|
169
216
|
}
|
|
170
217
|
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
218
|
+
// Strictly-monotonic per-customer occurred_at: a write at or before the
|
|
219
|
+
// prior row's timestamp is bumped to prior+1, so the denormalized
|
|
220
|
+
// balance_after snapshot is unambiguous on read. A backdated operator
|
|
221
|
+
// write still lands at the requested time when there's no collision.
|
|
222
|
+
function _resolveOccurredAt(requestedTs, latestTs) {
|
|
223
|
+
if (latestTs == null) return requestedTs;
|
|
224
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
225
|
+
return latestTs + 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// How many times a writer re-derives the chain tip when the parent fence
|
|
229
|
+
// refuses its INSERT (another write landed first). Each fence round lets
|
|
230
|
+
// exactly ONE racing writer win, so a burst of N concurrent writes to the
|
|
231
|
+
// SAME customer needs up to N rounds — the cap is sized well beyond any
|
|
232
|
+
// realistic same-wallet fan-in (an admin grant + the scheduled expiry
|
|
233
|
+
// sweep + a checkout debit racing at once) so a legitimate burst is never
|
|
234
|
+
// dropped with STORE_CREDIT_CONTENTION. A genuine non-collision insert
|
|
235
|
+
// error re-throws on the first attempt (see _attemptChainedWrite), so a
|
|
236
|
+
// high cap never spins on a real failure.
|
|
237
|
+
var CHAIN_WRITE_ATTEMPTS = 64;
|
|
238
|
+
|
|
239
|
+
// One fenced write against the tip the caller just read. The live balance
|
|
240
|
+
// AND the strictly-monotonic occurred_at are computed APP-SIDE and bound
|
|
241
|
+
// explicitly, together with prev_hash + row_hash so every row participates
|
|
242
|
+
// in the per-customer hash chain. What makes the app-computed values safe
|
|
243
|
+
// is the chain-parent fence UNIQUE(customer_id, prev_hash) (migration
|
|
244
|
+
// 0236): a write derived from a STALE tip collides at the constraint
|
|
245
|
+
// instead of forking the chain or persisting a balance computed off a
|
|
246
|
+
// stale snapshot — the caller re-reads and retries. The debit overdraft
|
|
247
|
+
// gate stays INSIDE the statement (the correlated subquery, never a
|
|
248
|
+
// JS-side check): with the fence it is defense-in-depth, and it is what
|
|
249
|
+
// turns a genuine overdraft into a zero-row refusal rather than a retry
|
|
250
|
+
// loop.
|
|
251
|
+
//
|
|
252
|
+
// Collision detection is STATE-AGNOSTIC, never error-message matching:
|
|
253
|
+
// the production D1 service-binding redacts the SQLite "UNIQUE constraint
|
|
254
|
+
// failed" text to a generic "HTTP 500", so on ANY insert error we re-read
|
|
255
|
+
// the tip — if it advanced past the parent we tried to chain off, a
|
|
256
|
+
// competing write claimed our slot (a genuine fence collision) and we
|
|
257
|
+
// retry; if the tip is unchanged the insert failed for another reason, so
|
|
258
|
+
// we re-throw. Returns one of { written }, { refused } (the guard said no
|
|
259
|
+
// against the live tip), or { collided } (tip moved — retry).
|
|
260
|
+
async function _attemptChainedWrite(customerId, kind, latest, d) {
|
|
199
261
|
var id = b.uuid.v7();
|
|
200
262
|
var storedKind = kind === "sweep" ? "expire" : kind;
|
|
263
|
+
var rowHash = _computeRowHash(latest.row_hash, _rowFieldsForHash({
|
|
264
|
+
id: id,
|
|
265
|
+
customer_id: customerId,
|
|
266
|
+
kind: storedKind,
|
|
267
|
+
amount_minor: d.amount,
|
|
268
|
+
source: d.source,
|
|
269
|
+
source_ref: d.sourceRef,
|
|
270
|
+
order_id: d.orderId,
|
|
271
|
+
balance_after_minor: d.after,
|
|
272
|
+
expires_at: d.expiresAt,
|
|
273
|
+
occurred_at: d.ts,
|
|
274
|
+
}));
|
|
201
275
|
var balSub = "COALESCE((SELECT balance_after_minor FROM store_credit_ledger " +
|
|
202
276
|
"WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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";
|
|
226
|
-
} else { // expire — burn MIN(amount, live balance)
|
|
227
|
-
amountExpr = "CASE WHEN " + balSub + " < ?3 THEN " + balSub + " ELSE ?3 END";
|
|
228
|
-
afterExpr = "CASE WHEN " + balSub + " < ?3 THEN 0 ELSE " + balSub + " - ?3 END";
|
|
229
|
-
guard = balSub + " > 0";
|
|
277
|
+
try {
|
|
278
|
+
var res = await query(
|
|
279
|
+
"INSERT INTO store_credit_ledger " +
|
|
280
|
+
"(id, customer_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, expires_at, occurred_at, prev_hash, row_hash) " +
|
|
281
|
+
"SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12 " +
|
|
282
|
+
"WHERE " + (d.guarded ? balSub + " >= ?4" : "1"),
|
|
283
|
+
[id, customerId, storedKind, d.amount, d.source, d.sourceRef, d.orderId, d.after, d.expiresAt, d.ts, latest.row_hash, rowHash],
|
|
284
|
+
);
|
|
285
|
+
if (Number(res.rowCount || 0) === 0) return { refused: true };
|
|
286
|
+
return { written: { id: id, amount_minor: d.amount, balance_after_minor: d.after, occurred_at: d.ts } };
|
|
287
|
+
} catch (e) {
|
|
288
|
+
var after = await _readLatest(customerId);
|
|
289
|
+
if (after.row_hash !== latest.row_hash) return { collided: true };
|
|
290
|
+
throw e;
|
|
230
291
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Run a chained write with bounded tip-contention retries. `derive` maps
|
|
295
|
+
// the freshly-read tip to the attempt's concrete fields (it may be async —
|
|
296
|
+
// the sweep reads its prior-burn sum inside), or returns { noop: true }
|
|
297
|
+
// for a write that degrades gracefully on an empty / already-settled
|
|
298
|
+
// wallet. Each fence collision re-reads the tip and re-derives, so the
|
|
299
|
+
// values that land are always consistent with the parent they chain off.
|
|
300
|
+
async function _writeChained(customerId, kind, derive) {
|
|
301
|
+
for (var attempt = 0; attempt < CHAIN_WRITE_ATTEMPTS; attempt += 1) {
|
|
302
|
+
var latest = await _readLatest(customerId);
|
|
303
|
+
var d = await derive(latest);
|
|
304
|
+
if (d.noop) return { noop: true, balance: latest.balance };
|
|
305
|
+
var r = await _attemptChainedWrite(customerId, kind, latest, d);
|
|
306
|
+
if (r.collided) continue;
|
|
307
|
+
if (r.refused) return { refused: true };
|
|
308
|
+
return r.written;
|
|
309
|
+
}
|
|
310
|
+
var contention = new Error("storeCredit." + kind + ": persistent chain-tip contention — retry the write");
|
|
311
|
+
contention.code = "STORE_CREDIT_CONTENTION";
|
|
312
|
+
throw contention;
|
|
249
313
|
}
|
|
250
314
|
|
|
251
315
|
return {
|
|
@@ -264,7 +328,18 @@ function create(opts) {
|
|
|
264
328
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
265
329
|
if (requested == null) requested = _now();
|
|
266
330
|
|
|
267
|
-
var w = await
|
|
331
|
+
var w = await _writeChained(customerId, "credit", function (latest) {
|
|
332
|
+
return {
|
|
333
|
+
amount: amount,
|
|
334
|
+
source: source,
|
|
335
|
+
sourceRef: sourceRef,
|
|
336
|
+
orderId: null,
|
|
337
|
+
expiresAt: expiresAt,
|
|
338
|
+
after: latest.balance + amount,
|
|
339
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
340
|
+
guarded: false,
|
|
341
|
+
};
|
|
342
|
+
});
|
|
268
343
|
|
|
269
344
|
return {
|
|
270
345
|
id: w.id,
|
|
@@ -289,11 +364,23 @@ function create(opts) {
|
|
|
289
364
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
290
365
|
if (requested == null) requested = _now();
|
|
291
366
|
|
|
292
|
-
// The balance gate lives INSIDE the insert
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
367
|
+
// The balance gate lives INSIDE the insert (defense-in-depth behind
|
|
368
|
+
// the chain-parent fence) — a refused write covers both "always
|
|
369
|
+
// insufficient" and "a concurrent debit drained it first", with no
|
|
370
|
+
// window between check and write.
|
|
371
|
+
var w = await _writeChained(customerId, "debit", function (latest) {
|
|
372
|
+
return {
|
|
373
|
+
amount: amount,
|
|
374
|
+
source: null,
|
|
375
|
+
sourceRef: null,
|
|
376
|
+
orderId: orderId,
|
|
377
|
+
expiresAt: null,
|
|
378
|
+
after: latest.balance - amount,
|
|
379
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
380
|
+
guarded: true,
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
if (w.refused) {
|
|
297
384
|
var insufficient = new Error("storeCredit.debit: amount exceeds available balance");
|
|
298
385
|
insufficient.code = "STORE_CREDIT_INSUFFICIENT_BALANCE";
|
|
299
386
|
throw insufficient;
|
|
@@ -334,8 +421,24 @@ function create(opts) {
|
|
|
334
421
|
// already at zero) is the structured no-op below, never a throw —
|
|
335
422
|
// by design: a no-op expire is a valid outcome of a bulk sweep,
|
|
336
423
|
// and a zero-amount row would violate CHECK(amount_minor > 0).
|
|
337
|
-
var w = await
|
|
338
|
-
|
|
424
|
+
var w = await _writeChained(customerId, "expire", function (latest) {
|
|
425
|
+
var burn = amount > latest.balance ? latest.balance : amount;
|
|
426
|
+
// An empty wallet is the structured no-op below, never a throw —
|
|
427
|
+
// a zero-amount row would violate CHECK(amount_minor > 0), and a
|
|
428
|
+
// no-op expire is a valid outcome of a bulk sweep.
|
|
429
|
+
if (burn === 0) return { noop: true };
|
|
430
|
+
return {
|
|
431
|
+
amount: burn,
|
|
432
|
+
source: null,
|
|
433
|
+
sourceRef: reason,
|
|
434
|
+
orderId: null,
|
|
435
|
+
expiresAt: null,
|
|
436
|
+
after: latest.balance - burn,
|
|
437
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
438
|
+
guarded: false,
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
if (w.noop) {
|
|
339
442
|
return {
|
|
340
443
|
id: null,
|
|
341
444
|
customer_id: customerId,
|
|
@@ -343,7 +446,7 @@ function create(opts) {
|
|
|
343
446
|
amount_minor: 0,
|
|
344
447
|
requested_minor: amount,
|
|
345
448
|
reason: reason,
|
|
346
|
-
balance_after_minor:
|
|
449
|
+
balance_after_minor: w.balance,
|
|
347
450
|
occurred_at: requested,
|
|
348
451
|
noop: true,
|
|
349
452
|
};
|
|
@@ -368,6 +471,124 @@ function create(opts) {
|
|
|
368
471
|
return { customer_id: customerId, balance_minor: bal };
|
|
369
472
|
},
|
|
370
473
|
|
|
474
|
+
// Recompute one customer's hash chain and flag the first divergence —
|
|
475
|
+
// the tamper-evidence READ side of the chain the writers maintain.
|
|
476
|
+
// Walks the wallet's rows in (occurred_at, id) order; rows from before
|
|
477
|
+
// the chain columns existed (NULL row_hash) are tolerated as an
|
|
478
|
+
// unverifiable legacy PREFIX, but once a hashed row anchors, every
|
|
479
|
+
// later row must link — an unhashed row after the anchor, a prev_hash
|
|
480
|
+
// that doesn't name its parent, or a row_hash that doesn't recompute is
|
|
481
|
+
// a break. A populated ledger that never anchors (every row NULL-hashed
|
|
482
|
+
// — the shape a full-ledger rewrite leaves behind) fails as unanchored;
|
|
483
|
+
// an empty ledger is the only no-anchor case that passes. O(n) per
|
|
484
|
+
// customer; operator-audit use, not hot-path.
|
|
485
|
+
//
|
|
486
|
+
// Optional trusted anchor (opts.anchor = { count, head }) — the ONLY
|
|
487
|
+
// way to detect a TAIL TRUNCATION (deleting the most-recent rows) or a
|
|
488
|
+
// whole-chain replacement, since an append-only chain stays internally
|
|
489
|
+
// consistent after its tail is removed (a deleted latest debit makes
|
|
490
|
+
// balance() revert while the remaining prefix still links). The caller
|
|
491
|
+
// supplies a count + head captured from a trusted prior snapshot of this
|
|
492
|
+
// same result (count = total_rows, head = last_hash) — a periodic
|
|
493
|
+
// operator checkpoint or an out-of-band monitor. The chain row at index
|
|
494
|
+
// count-1 must still carry that head hash, so a truncation BELOW the
|
|
495
|
+
// snapshot is caught even after later valid appends. Without an anchor
|
|
496
|
+
// this verifies INTERNAL consistency only and reports
|
|
497
|
+
// anchor_checked:false so the caller knows truncation was not ruled out
|
|
498
|
+
// (operator-audit-log closes the same gap with signed checkpoints).
|
|
499
|
+
verifyChain: async function (customerId, opts) {
|
|
500
|
+
_uuid(customerId, "customer_id");
|
|
501
|
+
opts = opts || {};
|
|
502
|
+
var anchor = null;
|
|
503
|
+
if (opts.anchor != null) {
|
|
504
|
+
if (typeof opts.anchor !== "object"
|
|
505
|
+
|| typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
|
|
506
|
+
|| typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
|
|
507
|
+
throw new TypeError("storeCredit.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
|
|
508
|
+
}
|
|
509
|
+
anchor = opts.anchor;
|
|
510
|
+
}
|
|
511
|
+
var r = await query(
|
|
512
|
+
"SELECT * FROM store_credit_ledger WHERE customer_id = ?1 " +
|
|
513
|
+
"ORDER BY occurred_at ASC, id ASC",
|
|
514
|
+
[customerId],
|
|
515
|
+
);
|
|
516
|
+
var rows = r.rows;
|
|
517
|
+
var legacyPrefix = 0;
|
|
518
|
+
var anchored = false;
|
|
519
|
+
var prevHash = ZERO_HASH;
|
|
520
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
521
|
+
var row = rows[i];
|
|
522
|
+
if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
|
|
523
|
+
anchored = true;
|
|
524
|
+
var breakBase = {
|
|
525
|
+
ok: false,
|
|
526
|
+
rows_verified: i - legacyPrefix,
|
|
527
|
+
legacy_prefix: legacyPrefix,
|
|
528
|
+
break_at: i,
|
|
529
|
+
break_row_id: row.id,
|
|
530
|
+
};
|
|
531
|
+
if (row.row_hash == null) {
|
|
532
|
+
return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
|
|
533
|
+
}
|
|
534
|
+
if (row.prev_hash !== prevHash) {
|
|
535
|
+
return Object.assign(breakBase, { reason: "prev_hash mismatch", expected: prevHash, actual: row.prev_hash });
|
|
536
|
+
}
|
|
537
|
+
var computed = _computeRowHash(prevHash, _rowFieldsForHash(row));
|
|
538
|
+
if (computed !== row.row_hash) {
|
|
539
|
+
return Object.assign(breakBase, { reason: "row_hash mismatch", expected: computed, actual: row.row_hash });
|
|
540
|
+
}
|
|
541
|
+
prevHash = row.row_hash;
|
|
542
|
+
}
|
|
543
|
+
if (!anchored && rows.length > 0) {
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
rows_verified: 0,
|
|
547
|
+
legacy_prefix: legacyPrefix,
|
|
548
|
+
break_at: 0,
|
|
549
|
+
break_row_id: rows[0].id,
|
|
550
|
+
reason: "unanchored chain (no hashed row in a populated ledger)",
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
// Internal walk is clean. With a trusted anchor, also rule out a tail
|
|
554
|
+
// truncation below the snapshot: the row at the snapshot's position
|
|
555
|
+
// must still carry the snapshot's head hash (it survives later valid
|
|
556
|
+
// appends), and the chain must not be shorter than the snapshot.
|
|
557
|
+
if (anchor) {
|
|
558
|
+
if (rows.length < anchor.count) {
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
rows_verified: rows.length - legacyPrefix,
|
|
562
|
+
legacy_prefix: legacyPrefix,
|
|
563
|
+
anchor_checked: true,
|
|
564
|
+
reason: "row count below anchor (possible tail truncation)",
|
|
565
|
+
expected_count: anchor.count,
|
|
566
|
+
actual_count: rows.length,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
var anchorRow = rows[anchor.count - 1];
|
|
570
|
+
if (!anchorRow || anchorRow.row_hash !== anchor.head) {
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
rows_verified: rows.length - legacyPrefix,
|
|
574
|
+
legacy_prefix: legacyPrefix,
|
|
575
|
+
anchor_checked: true,
|
|
576
|
+
reason: "anchor row hash mismatch (possible tail truncation or divergence)",
|
|
577
|
+
expected: anchor.head,
|
|
578
|
+
actual: anchorRow ? anchorRow.row_hash : null,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
rows_verified: rows.length - legacyPrefix,
|
|
585
|
+
legacy_prefix: legacyPrefix,
|
|
586
|
+
total_rows: rows.length,
|
|
587
|
+
last_hash: prevHash,
|
|
588
|
+
anchor_checked: anchor != null,
|
|
589
|
+
};
|
|
590
|
+
},
|
|
591
|
+
|
|
371
592
|
history: async function (input) {
|
|
372
593
|
if (!input || typeof input !== "object") {
|
|
373
594
|
throw new TypeError("storeCredit.history: input object required");
|
|
@@ -639,8 +860,38 @@ function create(opts) {
|
|
|
639
860
|
// expired credits are "first-out" from the operator's POV — the
|
|
640
861
|
// schema doesn't track FIFO at row level, so the audit trail
|
|
641
862
|
// reflects what was actually burned.
|
|
642
|
-
var w = await
|
|
643
|
-
|
|
863
|
+
var w = await _writeChained(customerId, "sweep", async function (latest) {
|
|
864
|
+
// Pending burn = this customer's expired-credit total minus the
|
|
865
|
+
// sweep's OWN prior output (expire rows stamped SWEEP_SOURCE_REF),
|
|
866
|
+
// capped at the live balance. The prior-burn sum is read INSIDE
|
|
867
|
+
// the derive so a fence retry recomputes it against the committed
|
|
868
|
+
// tip: a racing second sweep collides, re-reads a now-nonzero
|
|
869
|
+
// swept sum, sees zero pending, and no-ops — never burning the
|
|
870
|
+
// still-valid (non-expired) credit the first sweep left behind.
|
|
871
|
+
// Operator expires + debits already reduced the balance; the
|
|
872
|
+
// balance cap is what keeps the sweep from over-burning a partly
|
|
873
|
+
// drained wallet, so they must not also be netted out of the
|
|
874
|
+
// expired pool (only this sweep's own output is).
|
|
875
|
+
var sweptRow = (await query(
|
|
876
|
+
"SELECT COALESCE(SUM(amount_minor), 0) AS swept FROM store_credit_ledger " +
|
|
877
|
+
"WHERE customer_id = ?1 AND kind = 'expire' AND source_ref = ?2",
|
|
878
|
+
[customerId, SWEEP_SOURCE_REF],
|
|
879
|
+
)).rows[0];
|
|
880
|
+
var pending = expiredTotal - Number(sweptRow.swept || 0);
|
|
881
|
+
if (pending <= 0 || latest.balance <= 0) return { noop: true };
|
|
882
|
+
var burn = pending > latest.balance ? latest.balance : pending;
|
|
883
|
+
return {
|
|
884
|
+
amount: burn,
|
|
885
|
+
source: null,
|
|
886
|
+
sourceRef: SWEEP_SOURCE_REF,
|
|
887
|
+
orderId: null,
|
|
888
|
+
expiresAt: null,
|
|
889
|
+
after: latest.balance - burn,
|
|
890
|
+
ts: _resolveOccurredAt(now, latest.occurred_at),
|
|
891
|
+
guarded: false,
|
|
892
|
+
};
|
|
893
|
+
});
|
|
894
|
+
if (!w || w.noop || w.refused) continue;
|
|
644
895
|
processed.push({
|
|
645
896
|
id: w.id,
|
|
646
897
|
customer_id: customerId,
|
package/package.json
CHANGED