@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 +4 -0
- package/SECURITY.md +16 -11
- package/lib/affiliates.js +225 -25
- package/lib/asset-manifest.json +1 -1
- package/package.json +1 -1
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
|
|
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/package.json
CHANGED