@blamejs/blamejs-shop 0.4.127 → 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 +6 -0
- package/SECURITY.md +16 -11
- package/lib/affiliates.js +225 -25
- package/lib/asset-manifest.json +1 -1
- package/lib/customer-portal.js +38 -8
- package/lib/storefront.js +22 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,14 @@ 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
|
|
|
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.
|
|
16
|
+
|
|
11
17
|
- v0.4.127 (2026-06-27) — **Customer-segment-restricted automatic discounts now work instead of breaking the discount quote for signed-in shoppers.** An automatic-discount rule limited to a customer segment (for example a members-only offer) never functioned: the discount engine was constructed without the customer-segments handle it needs to test membership, so the moment any segment-restricted rule existed, computing the cart and checkout discount threw an error for every signed-in shopper — breaking the discount quote for authenticated customers — while the discount applied to no one. Guests were unaffected because a segment rule is skipped for a shopper with no account. The engine is now wired with the segments handle, so a segment-restricted rule correctly applies to shoppers who are in the segment, is skipped for shoppers who are not, and is skipped for guests; rules that are not segment-restricted are entirely unaffected. **Fixed:** *Customer-segment-restricted automatic discounts apply correctly and no longer break the quote* — The automatic-discount engine is now created with the customer-segments handle. Previously the storefront and checkout wired the engine without it, so evaluating a rule carrying a customer-segment restriction threw the moment a signed-in shopper reached the cart or checkout — the discount engine requires the handle to answer "is this customer in the segment?" and had none. The result was that a segment-restricted offer applied to nobody and broke the discount calculation for every authenticated shopper whenever such a rule was active. With the handle wired, a segment-restricted rule applies to shoppers in the segment, is cleanly skipped for shoppers outside it and for guests, and rules without a segment restriction behave exactly as before.
|
|
12
18
|
|
|
13
19
|
- v0.4.126 (2026-06-27) — **Storefront error responses no longer echo internal error detail, and order/account pages render images from the configured asset prefix.** Two storefront correctness fixes. First, around fifteen storefront routes (cart line edit/remove, product compare, saved-for-later, account actions and more) returned the raw error message to the client on an unexpected server error — a string that can carry an internal database column, a vault/crypto detail, or other implementation internals. Those routes now distinguish a client-shape validation error (a 400 that still shows its operator-authored message) from an unexpected server error (a 500 that logs the real error server-side, correlated by the request id, and returns only a generic message plus that id) — the same redaction the WebAuthn ceremony routes already used. Second, the order-detail and account-dashboard pages were rendering product thumbnails from the default "/assets/" path instead of the operator's configured asset prefix, so on a deployment that serves assets from a CDN or object-store prefix those two pages showed broken images; both pages now pass the configured prefix like every other page. **Fixed:** *Storefront routes no longer echo the internal error message on a 500* — About fifteen storefront routes — cart line update/remove, product-compare add/clear, saved-for-later move/remove, and several account actions — handled a caught error with `status(TypeError ? 400 : 500)` and then returned the raw error message for both cases, so an unexpected (non-validation) server error sent its internal message string to the client. These routes now return that message only for a client-shape validation error (a 400); any other error is a 500 that records the real error server-side against the request id and returns a generic "Something went wrong" message plus that id for support correlation. This matches the redaction the WebAuthn register/login routes already applied. · *Order-detail and account pages render images from the configured asset prefix* — The order-detail (`/orders/:id`) and account-dashboard (`/account`) renderers were not passed the configured asset prefix, so they fell back to the default `/assets/` path for product thumbnails while every other page used the operator's prefix. On a deployment that serves static assets from a CDN or object-store prefix, those two pages showed broken images. Both now pass the configured prefix, so their images load from the same place as the rest of the storefront.
|
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
|
|
323
|
-
fork-proof, and verifiable.** Every ledger entry — credits,
|
|
324
|
-
expirations alike — links to its predecessor through a SHA3-512
|
|
325
|
-
keyed per card (gift cards)
|
|
326
|
-
editing a row or deleting one from the middle
|
|
327
|
-
uniqueness fence (one child per chain tip) makes
|
|
328
|
-
serialize rather than fork the chain, and each loyalty /
|
|
329
|
-
store-credit balance change is written in that same single guarded
|
|
330
|
-
so a points or balance mutation is exactly-once even on a backend without
|
|
331
|
-
interactive transactions.
|
|
332
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
package/lib/asset-manifest.json
CHANGED
package/lib/customer-portal.js
CHANGED
|
@@ -191,14 +191,44 @@ function create(opts) {
|
|
|
191
191
|
var now = _now();
|
|
192
192
|
var expiresAt = now + (ttl * 1000); // allow:raw-time-literal — ttl is a runtime seconds value; *1000 converts to ms
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
194
|
+
// Optional per-customer mint throttle. When `min_interval_seconds` is set,
|
|
195
|
+
// the session is minted ONLY if no session was created for this customer
|
|
196
|
+
// within that interval — enforced as ONE conditional statement
|
|
197
|
+
// (INSERT ... SELECT ... WHERE NOT EXISTS), so concurrent callers can't
|
|
198
|
+
// each read an empty window before any of them inserts. The public email
|
|
199
|
+
// sign-in-link route passes this to bound inbox-flood / token-mint abuse
|
|
200
|
+
// of a registered address; a rowCount of 0 means a recent session already
|
|
201
|
+
// exists, so this call mints nothing and returns `{ throttled: true }`
|
|
202
|
+
// (no token). Callers that omit the option mint unconditionally as before.
|
|
203
|
+
var minInterval = input.min_interval_seconds;
|
|
204
|
+
if (minInterval != null && (typeof minInterval !== "number" || !isFinite(minInterval) || minInterval < 0)) {
|
|
205
|
+
throw new TypeError("customer-portal.createSession: min_interval_seconds must be a non-negative number");
|
|
206
|
+
}
|
|
207
|
+
if (minInterval) {
|
|
208
|
+
var sinceMs = now - Math.floor(minInterval * 1000); // allow:raw-time-literal — minInterval is a runtime seconds value; *1000 converts to ms
|
|
209
|
+
var ins = await query(
|
|
210
|
+
"INSERT INTO customer_portal_sessions " +
|
|
211
|
+
"(id, customer_id, token_hash, scope, status, " +
|
|
212
|
+
" consumed_at, revoked_at, revoke_reason, ip_hash, ua_class, " +
|
|
213
|
+
" created_at, expires_at) " +
|
|
214
|
+
"SELECT ?1, ?2, ?3, ?4, 'issued', NULL, NULL, NULL, ?5, ?6, ?7, ?8 " +
|
|
215
|
+
"WHERE NOT EXISTS (SELECT 1 FROM customer_portal_sessions " +
|
|
216
|
+
"WHERE customer_id = ?2 AND created_at >= ?9)",
|
|
217
|
+
[id, customerId, tokenHash, scope, ipHash, uaClass, now, expiresAt, sinceMs],
|
|
218
|
+
);
|
|
219
|
+
if (Number(ins.rowCount || 0) === 0) {
|
|
220
|
+
return { throttled: true };
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
await query(
|
|
224
|
+
"INSERT INTO customer_portal_sessions " +
|
|
225
|
+
"(id, customer_id, token_hash, scope, status, " +
|
|
226
|
+
" consumed_at, revoked_at, revoke_reason, ip_hash, ua_class, " +
|
|
227
|
+
" created_at, expires_at) " +
|
|
228
|
+
"VALUES (?1, ?2, ?3, ?4, 'issued', NULL, NULL, NULL, ?5, ?6, ?7, ?8)",
|
|
229
|
+
[id, customerId, tokenHash, scope, ipHash, uaClass, now, expiresAt],
|
|
230
|
+
);
|
|
231
|
+
}
|
|
202
232
|
|
|
203
233
|
// The plaintext leaves this function exactly once. The row
|
|
204
234
|
// above is the only durable handle to the session; the hash
|
package/lib/storefront.js
CHANGED
|
@@ -10212,6 +10212,14 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
10212
10212
|
" RAW_LOGIN_SCRIPT\n" +
|
|
10213
10213
|
"</section>\n";
|
|
10214
10214
|
|
|
10215
|
+
// Per-recipient cooldown for the email magic-link: at most one sign-in link
|
|
10216
|
+
// (and one minted single-use token) per registered address per window. The
|
|
10217
|
+
// per-IP limiter doesn't bound a DISTRIBUTED attacker POSTing a victim's
|
|
10218
|
+
// address from many IPs, so without this each POST mints a token + sends a
|
|
10219
|
+
// mail — an inbox-flood + unbounded-token-mint vector. Enforced AFTER the
|
|
10220
|
+
// enumeration-safe 303, so it never changes the response.
|
|
10221
|
+
var MAGIC_LINK_RESEND_COOLDOWN_MS = b.constants.TIME.minutes(1);
|
|
10222
|
+
|
|
10215
10223
|
// The inline email magic-link path on the unified login screen. A distinct
|
|
10216
10224
|
// server-rendered <form> (its own email field) that POSTs to
|
|
10217
10225
|
// /account/login/link with no JavaScript — the `_injectCsrfFields` wrap
|
|
@@ -16145,10 +16153,22 @@ function mount(router, deps) {
|
|
|
16145
16153
|
|
|
16146
16154
|
if (customerId) {
|
|
16147
16155
|
Promise.resolve().then(async function () {
|
|
16156
|
+
// Per-recipient cooldown enforced ATOMICALLY in the mint itself
|
|
16157
|
+
// (createSession with min_interval_seconds is a single conditional
|
|
16158
|
+
// INSERT): at most one sign-in link + one minted token per registered
|
|
16159
|
+
// address per window, even when a distributed attacker fires many
|
|
16160
|
+
// POSTs for the same address concurrently (the per-IP limiter can't
|
|
16161
|
+
// bound that, and a separate read-then-insert check would let the
|
|
16162
|
+
// concurrent burst all pass before any insert). Runs off the
|
|
16163
|
+
// response's critical path, so it never restores the account-
|
|
16164
|
+
// existence oracle. A throttled call mints nothing and returns no
|
|
16165
|
+
// token → no link is sent this window.
|
|
16148
16166
|
var minted = await deps.customerPortal.createSession({
|
|
16149
|
-
customer_id:
|
|
16150
|
-
scope:
|
|
16167
|
+
customer_id: customerId,
|
|
16168
|
+
scope: "full",
|
|
16169
|
+
min_interval_seconds: MAGIC_LINK_RESEND_COOLDOWN_MS / 1000,
|
|
16151
16170
|
});
|
|
16171
|
+
if (!minted || !minted.plaintext_token) return;
|
|
16152
16172
|
var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
|
|
16153
16173
|
// The customer's plaintext address: the portal flow needs a
|
|
16154
16174
|
// deliverable address. The customers store keeps only the hash,
|
package/package.json
CHANGED