@blamejs/blamejs-shop 0.4.87 → 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 +4 -0
- package/SECURITY.md +22 -9
- package/lib/asset-manifest.json +1 -1
- package/lib/gift-card-ledger.js +72 -15
- package/lib/store-credit.js +340 -89
- package/lib/vendor/MANIFEST.json +53 -31
- package/lib/vendor/blamejs/.clusterfuzzlite/Dockerfile +7 -2
- package/lib/vendor/blamejs/.github/dependabot.yml +12 -0
- package/lib/vendor/blamejs/.github/workflows/ci.yml +16 -12
- package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +3 -1
- package/lib/vendor/blamejs/.github/workflows/release-container.yml +23 -0
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/examples/wiki/Dockerfile +19 -2
- package/lib/vendor/blamejs/lib/atomic-file.js +32 -9
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +105 -40
- package/lib/vendor/blamejs/lib/middleware/dpop.js +54 -31
- package/lib/vendor/blamejs/lib/middleware/span-http-server.js +7 -0
- package/lib/vendor/blamejs/lib/network-tls.js +12 -2
- package/lib/vendor/blamejs/lib/queue-local.js +123 -46
- package/lib/vendor/blamejs/lib/queue.js +13 -9
- package/lib/vendor/blamejs/lib/request-helpers.js +90 -0
- package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +69 -7
- package/lib/vendor/blamejs/lib/self-update.js +11 -2
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +11 -11
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +6 -2
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +689 -688
- package/lib/vendor/blamejs/oss-fuzz/projects/blamejs/Dockerfile +5 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.17.json +52 -0
- package/lib/vendor/blamejs/release-notes/v0.15.18.json +49 -0
- package/lib/vendor/blamejs/release-notes/v0.15.19.json +18 -0
- package/lib/vendor/blamejs/scripts/check-vendor-currency.js +24 -0
- package/lib/vendor/blamejs/test/integration/queue-cluster-mysql.test.js +419 -0
- package/lib/vendor/blamejs/test/integration/queue-cluster-pg.test.js +471 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt-rejection-envelope.test.js +309 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-fd-read-errorfor-bypass.test.js +138 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dpop-htu-peergating.test.js +227 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue-flow-repeat.test.js +83 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/self-update-poll-asset-digest.test.js +90 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/self-update-standalone-verifier-ecdsa-encoding.test.js +274 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/tls-ocsp-freshness.test.js +192 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vendor-currency-classify.test.js +36 -0
- package/package.json +1 -1
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,
|