@blamejs/blamejs-shop 0.4.128 → 0.5.0

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
@@ -6,6 +6,10 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.5.x
10
+
11
+ - v0.5.0 (2026-06-27) — **Affiliate commissions gain a tamper-evident hash chain, joining the gift-card, store-credit, and loyalty ledgers.** The affiliate-commission ledger was the last money ledger without tamper-evidence. Each commission is now linked into a per-affiliate SHA3-512 hash chain: a row names its predecessor, a uniqueness fence allows exactly one child per chain tip (so concurrent commissions for the same affiliate serialize instead of forking), and editing a commission's money fields or deleting a row from the middle breaks the linkage. Unlike the append-only gift-card / store-credit / loyalty ledgers, an affiliate commission walks a pending → paid → voided payout lifecycle in place, so the chain hashes only the immutable money facts — order, affiliate, order total, commission, currency, and time — and leaves the lifecycle columns out, meaning a legitimate payout or void never breaks the chain while an inflated amount or a removed row still does. A new `affiliates.verifyChain(affiliateId)` recomputes an affiliate's chain end to end and reports the first divergence, and accepts a trusted anchor (row count + head hash from an earlier snapshot) to also catch a truncation that deletes the most-recent commissions. Migration 0238 adds the chain columns + fence; commissions recorded before it are tolerated as an unverifiable legacy prefix, and the chain anchors fresh from the first commission recorded afterward. **Added:** *Affiliate commissions are a verifiable, fork-proof per-affiliate hash chain* — Recording a commission now links it into the affiliate's SHA3-512 hash chain — `row_hash = SHA3-512(prev_hash || canonical-json(immutable fields))` — under a `UNIQUE(affiliate_id, prev_hash)` parent fence, so a commission derived from a stale tip collides and retries instead of forking the chain, and concurrent commissions for one affiliate serialize. Because a commission's status, paid-at, payout reference, and void reason are stamped in place over its lifetime, only the immutable money facts are hashed, so a `pending → paid → voided` transition leaves the chain valid while tampering with the order total or commission amount, or deleting a commission, is detected. `affiliates.verifyChain(affiliateId)` walks the chain and reports the first break; pass `{ anchor: { count, head } }` to also rule out a tail truncation. Commissions written before this release are tolerated as an unverifiable legacy prefix and the chain anchors fresh from the next one — no backfill required. Migration 0238 adds the columns and the fence.
12
+
9
13
  ## v0.4.x
10
14
 
11
15
  - v0.4.128 (2026-06-27) — **Email sign-in links are now rate-limited per recipient, bounding inbox-flood and token-minting abuse.** The email magic-link sign-in — the no-passkey backup on the login screen — minted a new single-use sign-in token and sent an email on every POST for a registered address, bounded only by the per-IP request limiter. An attacker submitting a victim's address from many IP addresses could therefore flood that inbox with sign-in links and mint an unbounded number of valid single-use tokens. The route now applies a per-recipient cooldown: at most one sign-in link (and one minted token) per registered address per minute. The cooldown runs after the unchanged, enumeration-safe confirmation response, so a registered and an unregistered address stay indistinguishable by the reply; a transient lookup failure falls through to sending, keeping availability ahead of the rate cap. **Fixed:** *Email sign-in links are rate-limited per recipient* — Requesting an email sign-in link minted a single-use portal token and sent a message every time it was submitted for a registered address, with only the per-IP limiter in front of it — which a distributed sender (many IPs, one victim address) sails past. The request now checks whether a sign-in link was already minted for that account within the last minute and, if so, sends nothing further. The check runs on the request's background path, after the identical confirmation response is returned, so it does not reintroduce an account-existence oracle (a registered and an unregistered address still get the same reply at the same speed); an address with no account still triggers no mint or send at all. A transient failure of the cooldown lookup falls through to sending, so a degraded datastore can't lock legitimate shoppers out of signing in.
package/SECURITY.md CHANGED
@@ -319,17 +319,22 @@ 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, store-credit, and loyalty ledgers are hash-chained,
323
- fork-proof, 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, 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)`
322
+ - **The gift-card, store-credit, loyalty, and affiliate-commission ledgers
323
+ are hash-chained, fork-proof, and verifiable.** Every ledger entry — credits,
324
+ debits, and expirations alike — links to its predecessor through a SHA3-512
325
+ chain, keyed per card (gift cards), per customer (store credit, loyalty), or
326
+ per affiliate (commissions), so editing a row or deleting one from the middle
327
+ breaks the linkage. A uniqueness fence (one child per chain tip) makes
328
+ concurrent writes serialize rather than fork the chain, and each loyalty /
329
+ gift-card / store-credit balance change is written in that same single guarded
330
+ row, so a points or balance mutation is exactly-once even on a backend without
331
+ interactive transactions. The affiliate-commission chain hashes only the
332
+ immutable money facts (order, affiliate, totals, currency, time), so a
333
+ commission walking its pending → paid → voided payout lifecycle never breaks
334
+ the chain while inflating an amount or deleting a row still does.
335
+ `giftCardLedger.verifyChain(cardId)` /
336
+ `storeCredit.verifyChain(customerId)` / `loyalty.verifyChain(customerId)` /
337
+ `affiliates.verifyChain(affiliateId)`
333
338
  recompute a ledger's chain end to end and report the first divergence — run them whenever a balance is
334
339
  disputed; pass a trusted anchor (a row count + head hash from an earlier
335
340
  snapshot) to also catch a truncation that deletes the most-recent rows,
package/lib/affiliates.js CHANGED
@@ -336,6 +336,56 @@ function _normalizeEmail(input) {
336
336
 
337
337
  function _now() { return Date.now(); }
338
338
 
339
+ // ---- commission tamper-evidence chain -----------------------------------
340
+
341
+ // SHA3-512 hex width + the all-zero genesis anchor every affiliate's chain
342
+ // links its first commission off.
343
+ var SHA3_512_HEX_LEN = 128;
344
+ var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
345
+
346
+ // The IMMUTABLE money facts a commission row's hash attests. The lifecycle
347
+ // columns (status / paid_at / voided_at / payout_reference / void_reason) are
348
+ // deliberately EXCLUDED — they are stamped in place as a commission walks
349
+ // pending -> paid -> voided, and hashing them would break the chain on every
350
+ // legitimate payout. Only the facts that must never change after the
351
+ // commission is recorded are bound.
352
+ function _commissionHashFields(row) {
353
+ return {
354
+ id: row.id,
355
+ order_id: row.order_id,
356
+ affiliate_id: row.affiliate_id,
357
+ order_total_minor: Number(row.order_total_minor),
358
+ commission_minor: Number(row.commission_minor),
359
+ currency: row.currency,
360
+ occurred_at: Number(row.occurred_at),
361
+ };
362
+ }
363
+
364
+ // row_hash = SHA3-512(prev_hash-bytes || canonical-json(immutable fields)),
365
+ // the same construction the gift-card / store-credit / loyalty ledgers use
366
+ // (b.auditChain.canonicalize is the RFC 8785 sorted-key walker).
367
+ function _commissionRowHash(prevHash, fields) {
368
+ var canonical = b.auditChain.canonicalize(fields, ["prev_hash", "row_hash"]);
369
+ var preimage = Buffer.concat([
370
+ Buffer.from(prevHash, "hex"),
371
+ Buffer.from(canonical, "utf8"),
372
+ ]);
373
+ return b.crypto.sha3Hash(preimage);
374
+ }
375
+
376
+ // How many times recordCommissionEvent re-derives the chain tip when the
377
+ // parent fence refuses its INSERT (a competing commission for the SAME
378
+ // affiliate landed first). Each fence round lets exactly one racing writer
379
+ // win, so a burst of N concurrent commissions for one affiliate needs up to N
380
+ // rounds — the cap is the max same-affiliate fan-in that fully succeeds, so it
381
+ // is sized for the PER-AFFILIATE chain: unlike a gift-card chain (one
382
+ // customer's wallet), an affiliate's chain aggregates order completions across
383
+ // every customer they referred, so a large affiliate during a campaign spike
384
+ // can drive many near-simultaneous commissions. 256 covers that with wide
385
+ // margin; AFFILIATE_COMMISSION_CONTENTION (thrown past the cap) is a retryable
386
+ // signal, never silent loss — the caller may re-invoke for that order.
387
+ var COMMISSION_CHAIN_ATTEMPTS = 256;
388
+
339
389
  // ---- code generation + canonicalization ---------------------------------
340
390
 
341
391
  function _generateCode() {
@@ -446,6 +496,28 @@ function create(opts) {
446
496
  return r.rows[0] || null;
447
497
  }
448
498
 
499
+ // The affiliate's current chain tip — the highest-chain_index commission's
500
+ // row_hash (the next commission links off it as its prev_hash) plus that
501
+ // chain_index. Ordering by chain_index (not occurred_at) keeps the read order
502
+ // equal to the prev_hash linkage WITHOUT touching the operator's timestamp.
503
+ // The `(chain_index IS NULL) ASC` key prefers any chained row over a legacy
504
+ // NULL row, so once the chain has anchored the tip is always a hashed row; an
505
+ // affiliate with only legacy pre-chain rows (NULL chain_index / NULL row_hash)
506
+ // anchors fresh from genesis (ZERO_HASH, index -1 so the first child is 0).
507
+ async function _readCommissionTip(affiliateId) {
508
+ var r = await query(
509
+ "SELECT row_hash, chain_index FROM affiliate_commissions WHERE affiliate_id = ?1 " +
510
+ "ORDER BY (chain_index IS NULL) ASC, chain_index DESC, id DESC LIMIT 1",
511
+ [affiliateId],
512
+ );
513
+ if (!r.rows.length) return { row_hash: ZERO_HASH, chain_index: -1 };
514
+ var row = r.rows[0];
515
+ if (row.chain_index == null || row.row_hash == null) {
516
+ return { row_hash: ZERO_HASH, chain_index: -1 };
517
+ }
518
+ return { row_hash: row.row_hash, chain_index: Number(row.chain_index) };
519
+ }
520
+
449
521
  // Inserting an affiliate row. Code generation retries on UNIQUE
450
522
  // violation up to a small bound; on the 32^8 space the chance of
451
523
  // running out is vanishingly low, but the retry keeps the boot
@@ -818,32 +890,56 @@ function create(opts) {
818
890
  aff.commission_kind, Number(aff.commission_value), orderTotal
819
891
  );
820
892
 
821
- var id = b.uuid.v7();
822
- try {
823
- await query(
824
- "INSERT INTO affiliate_commissions " +
825
- "(id, order_id, affiliate_id, order_total_minor, commission_minor, " +
826
- " currency, status, occurred_at, paid_at, voided_at, payout_reference, void_reason) " +
827
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, NULL, NULL, NULL, NULL)",
828
- [id, orderId, affiliateId, orderTotal, commissionMinor, currency, occurredAt],
829
- );
830
- } catch (e) {
831
- // Concurrent recordCommissionEvent for the same (order_id,
832
- // affiliate_id): the pre-read above missed a racing insert, so this
833
- // writer loses on the UNIQUE constraint. Matching on the error MESSAGE
834
- // is unreliable — the production D1 service-binding redacts the SQLite
835
- // "UNIQUE constraint failed" text to a generic "HTTP 500" — so converge
836
- // message-agnostically: re-read, and if the row now exists the failure
837
- // was the duplicate, so return it (idempotent). If no row exists the
838
- // insert failed for some other reason, so re-throw.
839
- var raced = await query(
840
- "SELECT * FROM affiliate_commissions WHERE order_id = ?1 AND affiliate_id = ?2",
841
- [orderId, affiliateId],
842
- );
843
- if (raced.rows.length) return raced.rows[0];
844
- throw e;
893
+ // Append to the affiliate's tamper-evidence chain under the parent fence
894
+ // (UNIQUE(affiliate_id, prev_hash), migration 0238): the row links off the
895
+ // affiliate's current tip, and a write derived from a STALE tip collides
896
+ // instead of forking the chain — so we re-read the tip and retry. Two
897
+ // UNIQUE constraints can refuse this INSERT, and the production D1
898
+ // service-binding redacts the SQLite "UNIQUE constraint failed" text to a
899
+ // generic "HTTP 500", so converge MESSAGE-AGNOSTICALLY: (1) re-read by
900
+ // (order_id, affiliate_id) if a row now exists, this was the idempotency
901
+ // duplicate, return it; (2) else re-read the chain tip — if it advanced
902
+ // past the parent we tried to chain off, a competing commission for this
903
+ // affiliate claimed our slot, so retry; (3) else the insert failed for an
904
+ // unrelated reason, re-throw.
905
+ for (var attempt = 0; attempt < COMMISSION_CHAIN_ATTEMPTS; attempt += 1) {
906
+ var tip = await _readCommissionTip(affiliateId);
907
+ var prevHash = tip.row_hash;
908
+ var chainIndex = tip.chain_index + 1;
909
+ var id = b.uuid.v7();
910
+ var rowHash = _commissionRowHash(prevHash, {
911
+ id: id,
912
+ order_id: orderId,
913
+ affiliate_id: affiliateId,
914
+ order_total_minor: orderTotal,
915
+ commission_minor: commissionMinor,
916
+ currency: currency,
917
+ occurred_at: occurredAt,
918
+ });
919
+ try {
920
+ await query(
921
+ "INSERT INTO affiliate_commissions " +
922
+ "(id, order_id, affiliate_id, order_total_minor, commission_minor, " +
923
+ " currency, status, occurred_at, paid_at, voided_at, payout_reference, void_reason, " +
924
+ " prev_hash, row_hash, chain_index) " +
925
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, NULL, NULL, NULL, NULL, ?8, ?9, ?10)",
926
+ [id, orderId, affiliateId, orderTotal, commissionMinor, currency, occurredAt, prevHash, rowHash, chainIndex],
927
+ );
928
+ return await _getCommissionRaw(id);
929
+ } catch (e) {
930
+ var raced = await query(
931
+ "SELECT * FROM affiliate_commissions WHERE order_id = ?1 AND affiliate_id = ?2",
932
+ [orderId, affiliateId],
933
+ );
934
+ if (raced.rows.length) return raced.rows[0];
935
+ var newTip = await _readCommissionTip(affiliateId);
936
+ if (newTip.row_hash !== prevHash) continue;
937
+ throw e;
938
+ }
845
939
  }
846
- return await _getCommissionRaw(id);
940
+ var contention = new Error("affiliates.recordCommissionEvent: persistent commission-chain contention — retry the write");
941
+ contention.code = "AFFILIATE_COMMISSION_CONTENTION";
942
+ throw contention;
847
943
  },
848
944
 
849
945
  commissionsForAffiliate: async function (listOpts) {
@@ -1004,6 +1100,110 @@ function create(opts) {
1004
1100
  return await _getCommissionRaw(id);
1005
1101
  },
1006
1102
 
1103
+ // Recompute one affiliate's commission hash chain and flag the first
1104
+ // divergence — the tamper-evidence READ side of the chain recordCommission-
1105
+ // Event maintains. Walks the affiliate's rows in (occurred_at, id) order
1106
+ // over the IMMUTABLE money fields only (lifecycle columns are not hashed, so
1107
+ // a paid/voided transition is not a break). Rows from before the chain
1108
+ // existed (NULL row_hash) are tolerated as an unverifiable legacy PREFIX and
1109
+ // counted, but once a hashed row appears every later row must link — an
1110
+ // unhashed row after the anchor, a prev_hash that doesn't name its parent,
1111
+ // or a row_hash that doesn't recompute is a break. A populated ledger that
1112
+ // never anchors (every row NULL-hashed — what a full rewrite leaves behind)
1113
+ // fails as unanchored; only a genuinely empty ledger passes with no anchor.
1114
+ // An optional trusted anchor { count, head } rules out a tail truncation.
1115
+ // O(n) per affiliate; operator-audit use, not hot-path.
1116
+ verifyChain: async function (affiliateId, opts) {
1117
+ var aff = _uuid(affiliateId, "affiliate_id");
1118
+ opts = opts || {};
1119
+ var anchor = null;
1120
+ if (opts.anchor != null) {
1121
+ if (typeof opts.anchor !== "object"
1122
+ || typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
1123
+ || typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
1124
+ throw new TypeError("affiliates.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
1125
+ }
1126
+ anchor = opts.anchor;
1127
+ }
1128
+ // Walk in chain order: the legacy NULL-chain_index prefix first (sorted by
1129
+ // id for a stable count), then the hashed chain by chain_index. This is
1130
+ // the prev_hash linkage order independent of occurred_at, so a backdated
1131
+ // commission verifies in its chain position, not its timestamp position.
1132
+ var rows = (await query(
1133
+ "SELECT * FROM affiliate_commissions WHERE affiliate_id = ?1 " +
1134
+ "ORDER BY (chain_index IS NULL) DESC, chain_index ASC, id ASC",
1135
+ [aff],
1136
+ )).rows;
1137
+ var legacyPrefix = 0;
1138
+ var anchored = false;
1139
+ var prevHash = ZERO_HASH;
1140
+ for (var i = 0; i < rows.length; i += 1) {
1141
+ var row = rows[i];
1142
+ if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
1143
+ anchored = true;
1144
+ var breakBase = {
1145
+ ok: false,
1146
+ rows_verified: i - legacyPrefix,
1147
+ legacy_prefix: legacyPrefix,
1148
+ break_at: i,
1149
+ break_row_id: row.id,
1150
+ };
1151
+ if (row.row_hash == null) {
1152
+ return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
1153
+ }
1154
+ if (row.prev_hash !== prevHash) {
1155
+ return Object.assign(breakBase, { reason: "prev_hash mismatch", expected: prevHash, actual: row.prev_hash });
1156
+ }
1157
+ var computed = _commissionRowHash(prevHash, _commissionHashFields(row));
1158
+ if (computed !== row.row_hash) {
1159
+ return Object.assign(breakBase, { reason: "row_hash mismatch", expected: computed, actual: row.row_hash });
1160
+ }
1161
+ prevHash = row.row_hash;
1162
+ }
1163
+ if (!anchored && rows.length > 0) {
1164
+ return {
1165
+ ok: false,
1166
+ rows_verified: 0,
1167
+ legacy_prefix: legacyPrefix,
1168
+ break_at: 0,
1169
+ break_row_id: rows[0].id,
1170
+ reason: "unanchored chain (no hashed row in a populated ledger)",
1171
+ };
1172
+ }
1173
+ if (anchor) {
1174
+ if (rows.length < anchor.count) {
1175
+ return {
1176
+ ok: false,
1177
+ rows_verified: rows.length - legacyPrefix,
1178
+ legacy_prefix: legacyPrefix,
1179
+ anchor_checked: true,
1180
+ reason: "row count below anchor (possible tail truncation)",
1181
+ expected_count: anchor.count,
1182
+ actual_count: rows.length,
1183
+ };
1184
+ }
1185
+ var anchorRow = rows[anchor.count - 1];
1186
+ if (!anchorRow || anchorRow.row_hash !== anchor.head) {
1187
+ return {
1188
+ ok: false,
1189
+ rows_verified: rows.length - legacyPrefix,
1190
+ legacy_prefix: legacyPrefix,
1191
+ anchor_checked: true,
1192
+ reason: "anchor row hash mismatch (possible tail truncation or divergence)",
1193
+ expected: anchor.head,
1194
+ actual: anchorRow ? anchorRow.row_hash : null,
1195
+ };
1196
+ }
1197
+ }
1198
+ return {
1199
+ ok: true,
1200
+ rows_verified: rows.length - legacyPrefix,
1201
+ legacy_prefix: legacyPrefix,
1202
+ head: prevHash,
1203
+ anchor_checked: !!anchor,
1204
+ };
1205
+ },
1206
+
1007
1207
  // Operator dashboard — which affiliates are owed at least
1008
1208
  // `min_payout_minor` as of `as_of`. Returns one row per affiliate
1009
1209
  // whose sum-of-pending commissions (occurred_at <= as_of) meets
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.128",
2
+ "version": "0.5.0",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.128",
3
+ "version": "0.5.0",
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": {