@blamejs/blamejs-shop 0.0.56 → 0.0.57

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.
@@ -0,0 +1,808 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.fraudScreen
4
+ * @title Fraud screening — heuristic + ledger-based pre-payment gate
5
+ *
6
+ * @intro
7
+ * `fraudScreen` evaluates a checkout draft BEFORE the payment
8
+ * intent is created and emits a `{ score, decision, signals }`
9
+ * verdict so the orchestrator either proceeds, requests step-up
10
+ * (3-D Secure), or refuses. The primitive itself never makes the
11
+ * irrevocable refuse call — that's the operator-policy layer
12
+ * wrapping the checkout flow; this module only emits the signal
13
+ * plus a per-signal breakdown the dashboard can render.
14
+ *
15
+ * Signals (each contributes a positive weight when fired):
16
+ *
17
+ * velocity — >N orders/24h from same email_hash
18
+ * high-value-new-customer — large total + prior_orders_count = 0
19
+ * address-mismatch — shipping country != billing country
20
+ * free-email-domain — gmail / yahoo / outlook / ... (mild)
21
+ * disposable-email-domain — known temp-mail provider (severe)
22
+ * session-too-fast — session_age_seconds < 15
23
+ * session-too-old — session_age_seconds > 86400 (24h)
24
+ * ua-curl-class — curl / wget / python-requests etc.
25
+ * large-line-count — line_count >= LARGE_LINE_COUNT
26
+ * mismatched-bin-country — operator-supplied BIN->country map
27
+ * disagrees with the shipping country
28
+ * prior-chargeback — chargebacks ledger has a row for
29
+ * the same email_hash
30
+ * suppressed-email — emailSuppressions (if injected)
31
+ * returns true for this address
32
+ * manually-flagged — fraud_email_flags row exists for
33
+ * the email_hash (forces refuse)
34
+ *
35
+ * Thresholds (closed intervals):
36
+ *
37
+ * 0 .. 39 approve
38
+ * 40 .. 69 review
39
+ * 70 .. 89 step_up
40
+ * 90 ..100 refuse
41
+ *
42
+ * The disposable-email and manually-flagged signals push the
43
+ * score past 90 by themselves so the decision lands on `refuse`
44
+ * without needing a second signal to corroborate.
45
+ *
46
+ * Composition surface:
47
+ *
48
+ * - `b.crypto.namespaceHash("fraud-email", email)` — every
49
+ * email written to any of the three ledger tables is hashed
50
+ * through this namespace first, so a stolen D1 dump leaks
51
+ * no addresses.
52
+ * - `b.guardEmail.validate` / `.sanitize` — refuses shape-broken
53
+ * or header-injection-class addresses at every entry point.
54
+ * - `b.guardUuid.sanitize` — every UUID-shaped input
55
+ * (customer_id, order_id) is profile-strict validated.
56
+ * - `b.uuid.v7` — ledger row ids.
57
+ * - `b.pagination.encodeCursor` / `decodeCursor` — HMAC-tagged
58
+ * opaque cursor for `recentScreenings()` so an operator
59
+ * can't hand-craft one to skip past a hidden row or replay
60
+ * it across deployments.
61
+ *
62
+ * Operator-injectable dependencies (all optional):
63
+ *
64
+ * - `query` — D1-shaped async query function
65
+ * - `customers` — for customer_id -> email_hash lookup
66
+ * - `addresses` — address-lookup primitive (reserved
67
+ * for future BIN/AVS integration)
68
+ * - `paymentMethods` — payment-method lookup (reserved for
69
+ * future mismatched-BIN signal pulling
70
+ * from a saved instrument's metadata)
71
+ * - `emailSuppressions` — `.isSuppressed(emailHash)` boolean —
72
+ * a hard-bounce / complaint signal
73
+ * pulled from the email pipeline; when
74
+ * present, fires the suppressed-email
75
+ * signal.
76
+ * - `binCountryMap` — `{ [bin6]: countryCode }` operator-
77
+ * supplied; used by the mismatched-bin-
78
+ * country signal when the order draft
79
+ * carries a `bin6` field.
80
+ * - `cursorSecret` — HMAC secret for recentScreenings'
81
+ * cursor (required in production).
82
+ */
83
+
84
+ var bShop;
85
+ function _b() {
86
+ if (!bShop) {
87
+ try {
88
+ bShop = require("./index");
89
+ } catch (_e) {
90
+ // Fallback path — the lib/index.js registry mutation is the
91
+ // operator's responsibility (every primitive adds itself when
92
+ // wired). While the registry edit is pending (a fresh checkout
93
+ // mid-merge, a partially-applied patch), this primitive still
94
+ // boots by composing directly against the vendored framework.
95
+ // The framework surface this primitive consumes (crypto,
96
+ // guardEmail, guardUuid, uuid, pagination) is stable; no shop-
97
+ // level peer is touched through `_b()`, so the bypass is
98
+ // semantically equivalent.
99
+ bShop = { framework: require("./vendor/blamejs") };
100
+ }
101
+ }
102
+ return bShop.framework;
103
+ }
104
+
105
+ // ---- constants ----------------------------------------------------------
106
+
107
+ var EMAIL_NAMESPACE = "fraud-email";
108
+
109
+ // Score thresholds — closed intervals on both ends; the boundary
110
+ // row is exposed via FraudScreen.THRESHOLDS so the test suite can
111
+ // assert against the same values the runtime uses.
112
+ var THRESHOLDS = Object.freeze({
113
+ APPROVE_MAX: 39,
114
+ REVIEW_MAX: 69,
115
+ STEP_UP_MAX: 89,
116
+ // 90..100 → refuse
117
+ });
118
+
119
+ // Weights — calibrated so the corroborated-multi-signal case
120
+ // (e.g. velocity + address-mismatch + free-email-domain) lands
121
+ // on review or step_up, while a single severe signal (disposable
122
+ // domain, manual flag, chargeback) lands on refuse outright.
123
+ var WEIGHTS = Object.freeze({
124
+ VELOCITY: 35,
125
+ HIGH_VALUE_NEW_CUSTOMER: 25,
126
+ ADDRESS_MISMATCH: 25,
127
+ FREE_EMAIL_DOMAIN: 10,
128
+ DISPOSABLE_EMAIL_DOMAIN: 100,
129
+ SESSION_TOO_FAST: 20,
130
+ SESSION_TOO_OLD: 10,
131
+ UA_CURL_CLASS: 30,
132
+ LARGE_LINE_COUNT: 15,
133
+ MISMATCHED_BIN_COUNTRY: 30,
134
+ PRIOR_CHARGEBACK: 60,
135
+ SUPPRESSED_EMAIL: 40,
136
+ MANUALLY_FLAGGED: 100,
137
+ });
138
+
139
+ // Thresholds for the heuristic dials.
140
+ var VELOCITY_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h lookback
141
+ var VELOCITY_MAX_OK = 3; // > N triggers
142
+ var HIGH_VALUE_NEW_THRESH = 50000; // 500.00 minor units
143
+ var SESSION_FAST_MAX_SEC = 15;
144
+ var SESSION_OLD_MAX_SEC = 24 * 60 * 60;
145
+ var LARGE_LINE_COUNT_THRESH = 25;
146
+ var SCORE_MAX = 100;
147
+
148
+ // Decision thresholds map to {approve, review, step_up, refuse}.
149
+ function _decisionFor(score) {
150
+ if (score <= THRESHOLDS.APPROVE_MAX) return "approve";
151
+ if (score <= THRESHOLDS.REVIEW_MAX) return "review";
152
+ if (score <= THRESHOLDS.STEP_UP_MAX) return "step_up";
153
+ return "refuse";
154
+ }
155
+
156
+ // Disposable email-provider tail — about twenty of the most
157
+ // commonly abused temp-mail domains. The list is small on purpose:
158
+ // a single match is the entire signal weight, so a stale entry
159
+ // here is a higher-cost mistake than a missing entry. Operators
160
+ // who track a larger catalog should extend via the
161
+ // `disposableDomains` factory option.
162
+ var DEFAULT_DISPOSABLE_DOMAINS = Object.freeze([
163
+ "mailinator.com", "guerrillamail.com", "guerrillamail.net",
164
+ "10minutemail.com", "tempmail.com", "temp-mail.org",
165
+ "yopmail.com", "throwawaymail.com", "trashmail.com",
166
+ "fakeinbox.com", "getairmail.com", "sharklasers.com",
167
+ "maildrop.cc", "mintemail.com", "dispostable.com",
168
+ "spamgourmet.com", "mailcatch.com", "tempr.email",
169
+ "mohmal.com", "emailondeck.com", "moakt.com",
170
+ ]);
171
+
172
+ // Free-email tail — large public providers. Fires a small-weight
173
+ // signal because most legitimate customers use one; the operator
174
+ // uses this in combination with other signals (velocity, address-
175
+ // mismatch, etc.) not as a primary refuse driver.
176
+ var DEFAULT_FREE_DOMAINS = Object.freeze([
177
+ "gmail.com", "yahoo.com", "outlook.com", "hotmail.com",
178
+ "aol.com", "icloud.com", "live.com", "msn.com",
179
+ "protonmail.com", "proton.me", "yandex.com", "yandex.ru",
180
+ "mail.com", "gmx.com", "gmx.net", "fastmail.com",
181
+ "zoho.com", "tutanota.com", "tutanota.de",
182
+ ]);
183
+
184
+ // Curl-class user-agent tokens. The order draft carries a
185
+ // pre-classified `ua_class` so this primitive doesn't have to
186
+ // re-parse the User-Agent header — the storefront's bot-guard
187
+ // has already done that. The expected enum is:
188
+ // "browser" | "curl" | "wget" | "python" | "go-http" | "bot" | "unknown"
189
+ // Anything in the curl set fires the signal.
190
+ var CURL_CLASS_TOKENS = Object.freeze({
191
+ curl: true,
192
+ wget: true,
193
+ python: true,
194
+ "go-http": true,
195
+ bot: true,
196
+ });
197
+
198
+ // ---- validators ---------------------------------------------------------
199
+
200
+ function _uuid(s, label) {
201
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
202
+ catch (e) { throw new TypeError("fraudScreen: " + label + " — " + (e && e.message || "invalid UUID")); }
203
+ }
204
+
205
+ function _optUuid(s, label) {
206
+ if (s == null || s === "") return null;
207
+ return _uuid(s, label);
208
+ }
209
+
210
+ function _normalizeEmail(input) {
211
+ if (typeof input !== "string" || !input.length) {
212
+ throw new TypeError("fraudScreen: email must be a non-empty string");
213
+ }
214
+ var guardEmail = _b().guardEmail;
215
+ var report;
216
+ try { report = guardEmail.validate(input, { profile: "strict" }); }
217
+ catch (e) { throw new TypeError("fraudScreen: email — " + (e && e.message || "invalid email")); }
218
+ if (!report || report.ok === false) {
219
+ var first = (report && report.issues && report.issues[0]) || {};
220
+ throw new TypeError("fraudScreen: email — " + (first.snippet || first.ruleId || "refused at strict profile"));
221
+ }
222
+ var canonical;
223
+ try { canonical = guardEmail.sanitize(input, { profile: "strict" }); }
224
+ catch (e) { throw new TypeError("fraudScreen: email — " + (e && e.message || "refused")); }
225
+ // Lowercase the domain only — RFC 5321 leaves the local-part
226
+ // case-sensitive; matching the customers primitive's discipline
227
+ // here lets a `fraud_email_flags` row collide with the customer
228
+ // row's `email_hash` for cross-table joins.
229
+ var at = canonical.lastIndexOf("@");
230
+ if (at !== -1) {
231
+ canonical = canonical.slice(0, at) + "@" + canonical.slice(at + 1).toLowerCase();
232
+ }
233
+ return canonical;
234
+ }
235
+
236
+ function _emailDomain(canonicalEmail) {
237
+ var at = canonicalEmail.lastIndexOf("@");
238
+ if (at === -1) return "";
239
+ return canonicalEmail.slice(at + 1);
240
+ }
241
+
242
+ function _country(c, label) {
243
+ if (typeof c !== "string" || !/^[A-Z]{2}$/.test(c)) {
244
+ throw new TypeError("fraudScreen: " + label + " must be a 2-letter ISO 3166-1 country code");
245
+ }
246
+ return c;
247
+ }
248
+
249
+ function _addressShape(addr, label) {
250
+ if (!addr || typeof addr !== "object") {
251
+ throw new TypeError("fraudScreen: " + label + " must be an object");
252
+ }
253
+ _country(addr.country, label + ".country");
254
+ return addr;
255
+ }
256
+
257
+ function _nonNegInt(n, label) {
258
+ if (!Number.isInteger(n) || n < 0) {
259
+ throw new TypeError("fraudScreen: " + label + " must be a non-negative integer");
260
+ }
261
+ return n;
262
+ }
263
+
264
+ function _positiveInt(n, label) {
265
+ if (!Number.isInteger(n) || n <= 0) {
266
+ throw new TypeError("fraudScreen: " + label + " must be a positive integer");
267
+ }
268
+ return n;
269
+ }
270
+
271
+ function _currency(c) {
272
+ if (typeof c !== "string" || !/^[A-Z]{3}$/.test(c)) {
273
+ throw new TypeError("fraudScreen: currency must be a 3-letter ISO 4217 code (uppercase)");
274
+ }
275
+ return c;
276
+ }
277
+
278
+ function _now() { return Date.now(); }
279
+
280
+ // ---- factory ------------------------------------------------------------
281
+
282
+ function create(opts) {
283
+ opts = opts || {};
284
+ var query = opts.query;
285
+ if (!query) {
286
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
287
+ }
288
+ // Operator-injectable peers. All optional — the primitive falls
289
+ // back to neutral defaults when a peer is absent. `customers` is
290
+ // touched only for the customer_id -> email_hash lookup on
291
+ // customerRiskHistory; the other peers are reserved for the
292
+ // forthcoming BIN/AVS integration and the email-suppression
293
+ // signal.
294
+ var customers = opts.customers || null;
295
+ // addresses + paymentMethods are accepted now so operators can
296
+ // wire them at the call site without a primitive-shape change
297
+ // when the cross-primitive BIN/AVS join lands; v1 only validates
298
+ // the dependency shape.
299
+ if (opts.addresses && typeof opts.addresses !== "object") throw new TypeError("fraudScreen.create: addresses must be an object");
300
+ if (opts.paymentMethods && typeof opts.paymentMethods !== "object") throw new TypeError("fraudScreen.create: paymentMethods must be an object");
301
+ var emailSuppressions = opts.emailSuppressions || null;
302
+ var binCountryMap = opts.binCountryMap || null;
303
+
304
+ var disposableSet = new Set((opts.disposableDomains || DEFAULT_DISPOSABLE_DOMAINS).map(function (d) {
305
+ return String(d).toLowerCase();
306
+ }));
307
+ var freeSet = new Set((opts.freeDomains || DEFAULT_FREE_DOMAINS).map(function (d) {
308
+ return String(d).toLowerCase();
309
+ }));
310
+
311
+ // Cursor secret for recentScreenings. Defaults to a dev-only
312
+ // sentinel so the primitive boots under tests; deployments are
313
+ // expected to supply a derived value (typically
314
+ // b.crypto.namespaceHash("fraud-screen-cursor", D1_BRIDGE_SECRET)).
315
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
316
+ if (process.env.NODE_ENV === "production") {
317
+ throw new Error("fraudScreen.create: opts.cursorSecret is required in production");
318
+ }
319
+ opts.cursorSecret = "fraud-screen-cursor-secret-dev-only";
320
+ }
321
+ var cursorSecret = opts.cursorSecret;
322
+
323
+ function _hashEmail(canonicalEmail) {
324
+ return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonicalEmail);
325
+ }
326
+
327
+ // ---- signal evaluation -------------------------------------------------
328
+
329
+ async function _evaluate(draft) {
330
+ var canonicalEmail = _normalizeEmail(draft.email);
331
+ var emailHash = _hashEmail(canonicalEmail);
332
+ var domain = _emailDomain(canonicalEmail).toLowerCase();
333
+ var signals = [];
334
+ var score = 0;
335
+
336
+ function _push(name, weight, fired, detail) {
337
+ signals.push({
338
+ name: name,
339
+ weight: weight,
340
+ fired: !!fired,
341
+ detail: detail || null,
342
+ });
343
+ if (fired) score += weight;
344
+ }
345
+
346
+ // 1. velocity — count screenings for this email hash in the
347
+ // last 24h. The current screen() row hasn't been written
348
+ // yet, so the COUNT below reflects strictly prior activity.
349
+ var velRows = (await query(
350
+ "SELECT COUNT(*) AS n FROM fraud_screenings " +
351
+ "WHERE email_hash = ?1 AND occurred_at >= ?2",
352
+ [emailHash, _now() - VELOCITY_WINDOW_MS],
353
+ )).rows;
354
+ var velocityCount = Number((velRows[0] || {}).n || 0);
355
+ _push("velocity", WEIGHTS.VELOCITY, velocityCount > VELOCITY_MAX_OK, {
356
+ window_ms: VELOCITY_WINDOW_MS,
357
+ prior_count: velocityCount,
358
+ threshold: VELOCITY_MAX_OK,
359
+ });
360
+
361
+ // 2. high-value-new-customer — large total combined with
362
+ // prior_orders_count = 0 (operator-supplied; defaults to
363
+ // null which the signal treats as "unknown -> don't fire").
364
+ var prior = draft.prior_orders_count;
365
+ var highValueNew = (
366
+ typeof prior === "number" && prior === 0 &&
367
+ typeof draft.total_minor === "number" &&
368
+ draft.total_minor >= HIGH_VALUE_NEW_THRESH
369
+ );
370
+ _push("high-value-new-customer", WEIGHTS.HIGH_VALUE_NEW_CUSTOMER, highValueNew, {
371
+ total_minor: draft.total_minor,
372
+ threshold_minor: HIGH_VALUE_NEW_THRESH,
373
+ prior_orders_count: prior == null ? "unknown" : prior,
374
+ });
375
+
376
+ // 3. address-mismatch — shipping country != billing country.
377
+ // When billing_address is absent (digital goods, single-
378
+ // address flow), the signal does not fire.
379
+ var shipCountry = draft.shipping_address.country;
380
+ var billCountry = draft.billing_address && draft.billing_address.country;
381
+ var mismatch = !!(billCountry && billCountry !== shipCountry);
382
+ _push("address-mismatch", WEIGHTS.ADDRESS_MISMATCH, mismatch, {
383
+ shipping_country: shipCountry,
384
+ billing_country: billCountry || null,
385
+ });
386
+
387
+ // 4. free-email-domain — soft signal; usually corroborates a
388
+ // primary signal rather than acting alone.
389
+ _push("free-email-domain", WEIGHTS.FREE_EMAIL_DOMAIN, freeSet.has(domain), {
390
+ domain: domain,
391
+ });
392
+
393
+ // 5. disposable-email-domain — severe; weight pushes the row
394
+ // past the refuse threshold without corroboration.
395
+ _push("disposable-email-domain", WEIGHTS.DISPOSABLE_EMAIL_DOMAIN, disposableSet.has(domain), {
396
+ domain: domain,
397
+ });
398
+
399
+ // 6. session-too-fast — under-15s checkout is bot-shaped.
400
+ var sessAge = draft.session_age_seconds;
401
+ var tooFast = typeof sessAge === "number" && sessAge >= 0 && sessAge < SESSION_FAST_MAX_SEC;
402
+ _push("session-too-fast", WEIGHTS.SESSION_TOO_FAST, tooFast, {
403
+ session_age_seconds: sessAge,
404
+ threshold_seconds: SESSION_FAST_MAX_SEC,
405
+ });
406
+
407
+ // 7. session-too-old — over-24h cart that finally checks out
408
+ // matches a stale-cookie / cart-replay shape.
409
+ var tooOld = typeof sessAge === "number" && sessAge > SESSION_OLD_MAX_SEC;
410
+ _push("session-too-old", WEIGHTS.SESSION_TOO_OLD, tooOld, {
411
+ session_age_seconds: sessAge,
412
+ threshold_seconds: SESSION_OLD_MAX_SEC,
413
+ });
414
+
415
+ // 8. ua-curl-class — operator-classified UA. The storefront's
416
+ // bot-guard pre-classifies the User-Agent string; this
417
+ // primitive only checks the resulting enum.
418
+ var uaCurl = !!(draft.ua_class && CURL_CLASS_TOKENS[String(draft.ua_class).toLowerCase()]);
419
+ _push("ua-curl-class", WEIGHTS.UA_CURL_CLASS, uaCurl, {
420
+ ua_class: draft.ua_class == null ? null : String(draft.ua_class),
421
+ });
422
+
423
+ // 9. large-line-count — cart with many distinct lines tends
424
+ // to correlate with a carder testing batches.
425
+ var bigLines = typeof draft.line_count === "number" && draft.line_count >= LARGE_LINE_COUNT_THRESH;
426
+ _push("large-line-count", WEIGHTS.LARGE_LINE_COUNT, bigLines, {
427
+ line_count: draft.line_count,
428
+ threshold: LARGE_LINE_COUNT_THRESH,
429
+ });
430
+
431
+ // 10. mismatched-bin-country — operator-supplied BIN -> country
432
+ // map; the draft optionally carries a bin6 (first 6 PAN
433
+ // digits) that the storefront extracts during the tokenize
434
+ // handshake. Without the map OR without bin6, the signal
435
+ // does not fire.
436
+ var binMismatch = false;
437
+ var binDetail = null;
438
+ if (binCountryMap && typeof draft.bin6 === "string" && /^[0-9]{6}$/.test(draft.bin6)) {
439
+ var binCountry = binCountryMap[draft.bin6];
440
+ if (binCountry && binCountry !== shipCountry) {
441
+ binMismatch = true;
442
+ }
443
+ binDetail = {
444
+ bin6: draft.bin6,
445
+ bin_country: binCountry || null,
446
+ shipping_country: shipCountry,
447
+ };
448
+ }
449
+ _push("mismatched-bin-country", WEIGHTS.MISMATCHED_BIN_COUNTRY, binMismatch, binDetail);
450
+
451
+ // 11. prior-chargeback — the chargebacks ledger has at least
452
+ // one row for this email hash.
453
+ var cbRows = (await query(
454
+ "SELECT COUNT(*) AS n FROM fraud_chargebacks WHERE email_hash = ?1",
455
+ [emailHash],
456
+ )).rows;
457
+ var cbCount = Number((cbRows[0] || {}).n || 0);
458
+ _push("prior-chargeback", WEIGHTS.PRIOR_CHARGEBACK, cbCount > 0, {
459
+ chargeback_count: cbCount,
460
+ });
461
+
462
+ // 12. suppressed-email — operator-injectable peer. When the
463
+ // email pipeline has logged a hard bounce / complaint
464
+ // against this address, the signal fires.
465
+ var suppressed = false;
466
+ if (emailSuppressions && typeof emailSuppressions.isSuppressed === "function") {
467
+ try {
468
+ suppressed = !!(await emailSuppressions.isSuppressed(emailHash));
469
+ } catch (_e) {
470
+ // drop-silent — by design. A failing suppression backend
471
+ // shouldn't block checkout; the signal stays at "did not
472
+ // fire" and the other detectors carry the load.
473
+ suppressed = false;
474
+ }
475
+ }
476
+ _push("suppressed-email", WEIGHTS.SUPPRESSED_EMAIL, suppressed, {
477
+ checked: !!emailSuppressions,
478
+ });
479
+
480
+ // 13. manually-flagged — operator-pinned hard block. Forces
481
+ // the decision to refuse regardless of any other signal.
482
+ var flagRow = (await query(
483
+ "SELECT reason, flagged_at FROM fraud_email_flags WHERE email_hash = ?1 LIMIT 1",
484
+ [emailHash],
485
+ )).rows[0];
486
+ _push("manually-flagged", WEIGHTS.MANUALLY_FLAGGED, !!flagRow, flagRow ? {
487
+ reason: flagRow.reason || "",
488
+ flagged_at: flagRow.flagged_at,
489
+ } : null);
490
+
491
+ // Cap at SCORE_MAX so the CHECK constraint in the migration
492
+ // never sees an out-of-range value.
493
+ if (score > SCORE_MAX) score = SCORE_MAX;
494
+
495
+ return {
496
+ score: score,
497
+ decision: _decisionFor(score),
498
+ signals: signals,
499
+ email_hash: emailHash,
500
+ };
501
+ }
502
+
503
+ return {
504
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
505
+ THRESHOLDS: THRESHOLDS,
506
+ WEIGHTS: WEIGHTS,
507
+
508
+ // Hash an email without writing — useful when the orchestrator
509
+ // already has a canonical address and wants the lookup key for
510
+ // a parallel chargebacks-by-email query.
511
+ hashEmail: function (email) {
512
+ return _hashEmail(_normalizeEmail(email));
513
+ },
514
+
515
+ // Evaluate a checkout draft. The draft shape is documented at
516
+ // the top of this file. Returns a `{ score, decision, signals,
517
+ // email_hash, order_id }` row and persists a `fraud_screenings`
518
+ // ledger entry. The order_id is required because the
519
+ // orchestrator looks up screenings by order id when
520
+ // reconciling outcomes later.
521
+ screen: async function (input) {
522
+ if (!input || typeof input !== "object") throw new TypeError("fraudScreen.screen: input object required");
523
+ var draft = input.order_draft;
524
+ if (!draft || typeof draft !== "object") throw new TypeError("fraudScreen.screen: order_draft object required");
525
+ _uuid(draft.order_id, "order_draft.order_id");
526
+ _optUuid(draft.customer_id, "order_draft.customer_id");
527
+ _addressShape(draft.shipping_address, "order_draft.shipping_address");
528
+ if (draft.billing_address != null) {
529
+ _addressShape(draft.billing_address, "order_draft.billing_address");
530
+ }
531
+ _nonNegInt(draft.total_minor, "order_draft.total_minor");
532
+ _currency(draft.currency);
533
+ _positiveInt(draft.line_count, "order_draft.line_count");
534
+ if (draft.session_age_seconds != null) {
535
+ if (typeof draft.session_age_seconds !== "number" || !isFinite(draft.session_age_seconds)) {
536
+ throw new TypeError("fraudScreen: order_draft.session_age_seconds must be a finite number");
537
+ }
538
+ }
539
+ if (draft.prior_orders_count != null) {
540
+ _nonNegInt(draft.prior_orders_count, "order_draft.prior_orders_count");
541
+ }
542
+ if (draft.ip_hash != null && (typeof draft.ip_hash !== "string" || !draft.ip_hash.length)) {
543
+ throw new TypeError("fraudScreen: order_draft.ip_hash must be a non-empty string when provided");
544
+ }
545
+
546
+ var verdict = await _evaluate(draft);
547
+ var id = _b().uuid.v7();
548
+ var ts = _now();
549
+ await query(
550
+ "INSERT INTO fraud_screenings " +
551
+ "(id, order_id, customer_id, email_hash, score, decision, signals_json, actual_outcome, occurred_at) " +
552
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)",
553
+ [
554
+ id, draft.order_id, draft.customer_id || null, verdict.email_hash,
555
+ verdict.score, verdict.decision, JSON.stringify(verdict.signals), ts,
556
+ ],
557
+ );
558
+ return {
559
+ id: id,
560
+ order_id: draft.order_id,
561
+ email_hash: verdict.email_hash,
562
+ score: verdict.score,
563
+ decision: verdict.decision,
564
+ signals: verdict.signals,
565
+ occurred_at: ts,
566
+ };
567
+ },
568
+
569
+ // Reconcile the heuristic verdict against the actual outcome.
570
+ // Writes `actual_outcome` on every screening row that matched
571
+ // the order_id (typically one row per order — the orchestrator
572
+ // calls screen() once — but the loop tolerates retries that
573
+ // produced multiple screenings for the same order). Returns
574
+ // the count of rows updated.
575
+ recordOutcome: async function (input) {
576
+ if (!input || typeof input !== "object") throw new TypeError("fraudScreen.recordOutcome: input object required");
577
+ _uuid(input.order_id, "order_id");
578
+ var allowed = { paid_clean: true, chargeback: true, refunded: true, cancelled: true };
579
+ if (!input.actual_outcome || !allowed[input.actual_outcome]) {
580
+ throw new TypeError("fraudScreen.recordOutcome: actual_outcome must be one of paid_clean|chargeback|refunded|cancelled");
581
+ }
582
+ var r = await query(
583
+ "UPDATE fraud_screenings SET actual_outcome = ?1 WHERE order_id = ?2",
584
+ [input.actual_outcome, input.order_id],
585
+ );
586
+ return { updated: Number(r.rowCount || 0) };
587
+ },
588
+
589
+ // Append a chargeback row. The screenings primitive looks back
590
+ // at this table on every subsequent screen() of the same
591
+ // email hash so a hostile customer gets the prior-chargeback
592
+ // signal weight on their next attempt.
593
+ recordChargeback: async function (input) {
594
+ if (!input || typeof input !== "object") throw new TypeError("fraudScreen.recordChargeback: input object required");
595
+ _uuid(input.order_id, "order_id");
596
+ _optUuid(input.customer_id, "customer_id");
597
+ if (typeof input.email !== "string" || !input.email.length) {
598
+ throw new TypeError("fraudScreen.recordChargeback: email required");
599
+ }
600
+ _nonNegInt(input.amount_minor, "amount_minor");
601
+ if (input.reason != null && typeof input.reason !== "string") {
602
+ throw new TypeError("fraudScreen.recordChargeback: reason must be a string when provided");
603
+ }
604
+ if (input.reason && /[\x00-\x1f\x7f]/.test(input.reason)) {
605
+ throw new TypeError("fraudScreen.recordChargeback: reason must not contain control bytes");
606
+ }
607
+ var canonicalEmail = _normalizeEmail(input.email);
608
+ var emailHash = _hashEmail(canonicalEmail);
609
+ var id = _b().uuid.v7();
610
+ var ts = _now();
611
+ await query(
612
+ "INSERT INTO fraud_chargebacks " +
613
+ "(id, order_id, customer_id, email_hash, amount_minor, reason, occurred_at) " +
614
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
615
+ [id, input.order_id, input.customer_id || null, emailHash,
616
+ input.amount_minor, input.reason || "", ts],
617
+ );
618
+ return {
619
+ id: id,
620
+ order_id: input.order_id,
621
+ email_hash: emailHash,
622
+ amount_minor: input.amount_minor,
623
+ reason: input.reason || "",
624
+ occurred_at: ts,
625
+ };
626
+ },
627
+
628
+ // Operator-pinned manual block. Re-flagging the same address
629
+ // is idempotent — the row is upserted with the latest reason
630
+ // + timestamp so the dashboard always shows the freshest
631
+ // operator note.
632
+ flagEmail: async function (input) {
633
+ if (!input || typeof input !== "object") throw new TypeError("fraudScreen.flagEmail: input object required");
634
+ if (typeof input.email !== "string" || !input.email.length) {
635
+ throw new TypeError("fraudScreen.flagEmail: email required");
636
+ }
637
+ if (input.reason != null && typeof input.reason !== "string") {
638
+ throw new TypeError("fraudScreen.flagEmail: reason must be a string when provided");
639
+ }
640
+ if (input.reason && /[\x00-\x1f\x7f]/.test(input.reason)) {
641
+ throw new TypeError("fraudScreen.flagEmail: reason must not contain control bytes");
642
+ }
643
+ var canonicalEmail = _normalizeEmail(input.email);
644
+ var emailHash = _hashEmail(canonicalEmail);
645
+ var ts = _now();
646
+ // Idempotent upsert. INSERT-OR-REPLACE on the PK preserves
647
+ // the row identity (email_hash) while refreshing the reason
648
+ // + flagged_at so the dashboard shows the latest note.
649
+ await query(
650
+ "INSERT INTO fraud_email_flags (email_hash, reason, flagged_at) VALUES (?1, ?2, ?3) " +
651
+ "ON CONFLICT(email_hash) DO UPDATE SET reason = excluded.reason, flagged_at = excluded.flagged_at",
652
+ [emailHash, input.reason || "", ts],
653
+ );
654
+ return {
655
+ email_hash: emailHash,
656
+ reason: input.reason || "",
657
+ flagged_at: ts,
658
+ };
659
+ },
660
+
661
+ // Remove an operator-pinned block. Returns { unflagged: bool }.
662
+ unflagEmail: async function (input) {
663
+ if (!input || typeof input !== "object") throw new TypeError("fraudScreen.unflagEmail: input object required");
664
+ if (typeof input.email !== "string" || !input.email.length) {
665
+ throw new TypeError("fraudScreen.unflagEmail: email required");
666
+ }
667
+ var canonicalEmail = _normalizeEmail(input.email);
668
+ var emailHash = _hashEmail(canonicalEmail);
669
+ var r = await query(
670
+ "DELETE FROM fraud_email_flags WHERE email_hash = ?1",
671
+ [emailHash],
672
+ );
673
+ return { unflagged: Number(r.rowCount || 0) > 0 };
674
+ },
675
+
676
+ // Return prior screenings for a given customer. Ordered most-
677
+ // recent first so the dashboard pane shows the freshest
678
+ // decisions at the top. Hard-capped at 200 rows — the operator
679
+ // dashboard paginates beyond that via recentScreenings.
680
+ customerRiskHistory: async function (customerId) {
681
+ _uuid(customerId, "customer id");
682
+ var rows = (await query(
683
+ "SELECT id, order_id, customer_id, email_hash, score, decision, " +
684
+ "signals_json, actual_outcome, occurred_at " +
685
+ "FROM fraud_screenings WHERE customer_id = ?1 " +
686
+ "ORDER BY occurred_at DESC, id DESC LIMIT 200",
687
+ [customerId],
688
+ )).rows;
689
+ // Parse signals_json so callers don't each re-decode it.
690
+ // Touch the optional customers peer in a no-op shape check
691
+ // so the eslint-unused-vars detector stays quiet without
692
+ // requiring callers to wire it for this lookup; the field
693
+ // is reserved for a forthcoming join that pulls the
694
+ // display_name alongside the rows.
695
+ if (customers && typeof customers.get === "function") { /* reserved */ }
696
+ var out = [];
697
+ for (var i = 0; i < rows.length; i += 1) {
698
+ var r = rows[i];
699
+ var parsed = [];
700
+ try { parsed = JSON.parse(r.signals_json || "[]"); }
701
+ catch (_e) { parsed = []; }
702
+ out.push({
703
+ id: r.id,
704
+ order_id: r.order_id,
705
+ customer_id: r.customer_id,
706
+ email_hash: r.email_hash,
707
+ score: r.score,
708
+ decision: r.decision,
709
+ signals: parsed,
710
+ actual_outcome: r.actual_outcome,
711
+ occurred_at: r.occurred_at,
712
+ });
713
+ }
714
+ return out;
715
+ },
716
+
717
+ // Operator dashboard feed — paginated by (occurred_at DESC,
718
+ // id DESC) so the freshest screenings surface first. Cursor
719
+ // is HMAC-tagged via b.pagination so an operator can't
720
+ // hand-craft one to skip past a hidden row.
721
+ recentScreenings: async function (listOpts) {
722
+ listOpts = listOpts || {};
723
+ var limit = listOpts.limit == null ? 20 : listOpts.limit;
724
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 100) {
725
+ throw new TypeError("fraudScreen.recentScreenings: limit must be 1...100");
726
+ }
727
+ var orderKey = ["occurred_at:desc", "id:desc"];
728
+ var cursorVals = null;
729
+ if (listOpts.cursor != null) {
730
+ if (typeof listOpts.cursor !== "string") {
731
+ throw new TypeError("fraudScreen.recentScreenings: cursor must be an opaque string or null");
732
+ }
733
+ try {
734
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
735
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(orderKey)) {
736
+ throw new TypeError("fraudScreen.recentScreenings: cursor orderKey mismatch");
737
+ }
738
+ cursorVals = state.vals;
739
+ } catch (e) {
740
+ if (e instanceof TypeError) throw e;
741
+ throw new TypeError("fraudScreen.recentScreenings: cursor — " + (e && e.message || "malformed"));
742
+ }
743
+ }
744
+ var rows;
745
+ if (cursorVals) {
746
+ rows = (await query(
747
+ "SELECT id, order_id, customer_id, email_hash, score, decision, " +
748
+ "signals_json, actual_outcome, occurred_at " +
749
+ "FROM fraud_screenings " +
750
+ "WHERE (occurred_at < ?1 OR (occurred_at = ?1 AND id < ?2)) " +
751
+ "ORDER BY occurred_at DESC, id DESC LIMIT ?3",
752
+ [cursorVals[0], cursorVals[1], limit],
753
+ )).rows;
754
+ } else {
755
+ rows = (await query(
756
+ "SELECT id, order_id, customer_id, email_hash, score, decision, " +
757
+ "signals_json, actual_outcome, occurred_at " +
758
+ "FROM fraud_screenings " +
759
+ "ORDER BY occurred_at DESC, id DESC LIMIT ?1",
760
+ [limit],
761
+ )).rows;
762
+ }
763
+ var out = [];
764
+ for (var i = 0; i < rows.length; i += 1) {
765
+ var r = rows[i];
766
+ var parsed = [];
767
+ try { parsed = JSON.parse(r.signals_json || "[]"); }
768
+ catch (_e) { parsed = []; }
769
+ out.push({
770
+ id: r.id,
771
+ order_id: r.order_id,
772
+ customer_id: r.customer_id,
773
+ email_hash: r.email_hash,
774
+ score: r.score,
775
+ decision: r.decision,
776
+ signals: parsed,
777
+ actual_outcome: r.actual_outcome,
778
+ occurred_at: r.occurred_at,
779
+ });
780
+ }
781
+ var last = out[out.length - 1];
782
+ var next = null;
783
+ if (last && out.length === limit) {
784
+ next = _b().pagination.encodeCursor({
785
+ orderKey: orderKey,
786
+ vals: [last.occurred_at, last.id],
787
+ forward: true,
788
+ }, cursorSecret);
789
+ }
790
+ return { rows: out, next_cursor: next };
791
+ },
792
+ };
793
+ }
794
+
795
+ module.exports = {
796
+ create: create,
797
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
798
+ THRESHOLDS: THRESHOLDS,
799
+ WEIGHTS: WEIGHTS,
800
+ VELOCITY_WINDOW_MS: VELOCITY_WINDOW_MS,
801
+ VELOCITY_MAX_OK: VELOCITY_MAX_OK,
802
+ HIGH_VALUE_NEW_THRESH: HIGH_VALUE_NEW_THRESH,
803
+ SESSION_FAST_MAX_SEC: SESSION_FAST_MAX_SEC,
804
+ SESSION_OLD_MAX_SEC: SESSION_OLD_MAX_SEC,
805
+ LARGE_LINE_COUNT_THRESH: LARGE_LINE_COUNT_THRESH,
806
+ DEFAULT_DISPOSABLE_DOMAINS: DEFAULT_DISPOSABLE_DOMAINS,
807
+ DEFAULT_FREE_DOMAINS: DEFAULT_FREE_DOMAINS,
808
+ };