Files
instantadpay/fraud.js
T
martbost e95f9df13a Approved exceptions: people you have okayed to hold more than one account
Marty approves specific people for multiple accounts (partners, staff, a spouse on a
shared machine) and needed a way to say so without the guard fighting him.

Admin > Members > Duplicate signals now carries an "Approved exceptions" list: add an
email with a note, see who is on it and when, remove one. It sits directly under the
signals so the two are read together.

An exception can be added against the address they ALREADY have, not just the new one.
That matters because the usual case is approving a person before their second address
exists, and at sign-up time the new address is unknown to us. checkSignup now tracks
every account the sign-up collided with, and clears the block if either side is approved.

Clearing the HARD flags matters as much as clearing the block. Those flags are what
silently drop an account off the leaderboard and bar it from adopting out of the holding
tank, so an approved person would have been "allowed" in name only. They now keep both.
The account is tagged 'allowlisted' instead, so the admin sees why it went through, and
the server logs the exception by name.

Suspension still wins. An exception is permission to hold several accounts, not immunity
from being suspended for something else.

qa/fraud-allow.mjs (12 assertions) boots its own server and walks the real flow: first
account created, second blocked with the Qualified Start redirect, exception added,
second account now created, no hard flag left on it, exception visible in the admin
report, removing it blocks again, malformed address refused, endpoint admin-only.
qa/sponsor-note.mjs 5, qa/run.sh member 0 bugs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:42:31 -05:00

203 lines
15 KiB
JavaScript

// InstantAdPay anti-fraud signals (Marty, 2026-09-16, after @megamol created megamol2/megamol3 under
// his own link and bought $20 on each to fake his two qualifying buyers).
//
// Per account: sign-up IP + user agent + browser device id (first-party cookie iap.dev), and the
// last-seen IP/UA/device on every sign-in. At sign-up:
// HARD BLOCK dup-device another account already used this browser
// HARD BLOCK ip-burst more than fraudMaxSignupsPerIpDay accounts from this IP in 24h
// HARD BLOCK sponsor-device the sponsor's account used this same browser
// FLAG sponsor-ip the sponsor signed up from / was last seen on this IP (households are legal, so flag only)
// FLAG shared-ip another account used this IP in the last 30 days
// Flagged accounts stay usable but never count on the leaderboard, cannot adopt from the holding tank,
// and show in Admin > Members. Suspended accounts (admin switch) cannot sign in at all.
// The contract's on-chain payments are outside all of this and are never reversed.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const db = require('./db');
let DATA_DIR = null;
const FILE = () => path.join(DATA_DIR, 'account-signals.json');
const J = { db: null, load() { try { this.db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { this.db = {}; } }, save() { try { fs.writeFileSync(FILE(), JSON.stringify(this.db)); } catch (e) {} } };
let suspendedSet = new Set(); // refreshed on init and on every suspend/unsuspend
let flaggedSet = new Set(); // accounts carrying a hard flag (excluded from leaderboard / adoption)
const QS = ' To hold more than one position, sign in to your account and use Qualified Start on the Buy packages tab: link another wallet of your own as a position under your account. Each linked wallet that buys a $20 or larger package counts as one of YOUR qualifying buyers, its credits pool with yours, and half of that purchase comes straight back to your main wallet. That is the built-in way to self-qualify.';
const HARD = new Set(['dup-device', 'ip-burst', 'sponsor-device', 'sponsor-ip', 'multi-account']);
let seenAt = new Map(); // email -> ts of last recordSeen (throttle writes)
function norm(e) { return String(e || '').trim().toLowerCase(); }
function ipOf(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim().replace(/^::ffff:/, '').slice(0, 45); }
function uaOf(req) { return String(req.headers['user-agent'] || '').slice(0, 200); }
function cookies(req) { const out = {}; String(req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return out; }
function deviceOf(req) { const d = cookies(req)['iap.dev'] || ''; return /^[a-f0-9]{32}$/.test(d) ? d : ''; }
function newDeviceId() { return crypto.randomBytes(16).toString('hex'); }
function deviceCookie(id, isProd) { return 'iap.dev=' + id + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (400 * 86400) + (isProd ? '; Secure' : ''); }
// ---- exception list ----
// Marty approves specific people to hold more than one account (partners, staff, a spouse
// on a shared machine). Their sign-ups must not be blocked, must not carry a hard flag,
// and must not be quietly dropped from the leaderboard or barred from adopting. Kept in
// its own small file so it works the same in DB mode and JSON mode, and so an allow entry
// can be added BEFORE the second account exists.
const ALLOW_FILE = () => path.join(DATA_DIR, 'fraud-allow.json');
let allowDb = null; // { "<email>": { note, at, by } }
function allowLoad() { if (allowDb) return allowDb; try { allowDb = JSON.parse(fs.readFileSync(ALLOW_FILE(), 'utf8')); } catch (e) { allowDb = {}; } if (!allowDb || typeof allowDb !== 'object') allowDb = {}; return allowDb; }
function allowSave() { try { fs.writeFileSync(ALLOW_FILE(), JSON.stringify(allowLoad())); } catch (e) { console.error('fraud allow save', e.message); } }
function isAllowed(email) { const e = norm(email); return !!(e && allowLoad()[e]); }
function allowList() { const d = allowLoad(); return Object.keys(d).sort().map(e => ({ email: e, note: d[e].note || '', at: d[e].at || 0, by: d[e].by || '' })); }
function allowAdd(email, note, by) {
const e = norm(email);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return { error: 'That email address does not look right.' };
allowLoad()[e] = { note: String(note || '').slice(0, 200), at: Date.now(), by: String(by || '').slice(0, 120) };
allowSave();
return { ok: true, list: allowList() };
}
function allowRemove(email) { const e = norm(email); const d = allowLoad(); if (d[e]) { delete d[e]; allowSave(); } return { ok: true, list: allowList() }; }
async function init(opts) {
DATA_DIR = opts.dataDir;
if (db.enabled()) {
await db.q(`CREATE TABLE IF NOT EXISTS account_signals (
email VARCHAR(190) PRIMARY KEY,
signup_ip VARCHAR(45) NULL, signup_ua VARCHAR(200) NULL, device_id CHAR(32) NULL, signup_at BIGINT NULL,
last_ip VARCHAR(45) NULL, last_ua VARCHAR(200) NULL, last_device CHAR(32) NULL, last_at BIGINT NULL,
flags VARCHAR(400) NULL, suspended TINYINT NOT NULL DEFAULT 0, suspended_reason VARCHAR(200) NULL, suspended_at BIGINT NULL,
INDEX (signup_ip), INDEX (device_id), INDEX (last_ip), INDEX (last_device), INDEX (suspended)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
} else J.load();
await refreshSets();
}
async function refreshSets() {
const rows = await all();
suspendedSet = new Set(rows.filter(r => r.suspended).map(r => r.email));
flaggedSet = new Set(rows.filter(r => (r.flags || []).some(f => HARD.has(f))).map(r => r.email));
}
async function all() {
if (db.enabled()) return (await db.q('SELECT * FROM account_signals')).map(rowPub);
if (!J.db) J.load();
return Object.values(J.db).map(pubJ);
}
const rowPub = r => ({ email: r.email, signupIp: r.signup_ip || '', signupUa: r.signup_ua || '', deviceId: r.device_id || '', signupAt: Number(r.signup_at || 0),
lastIp: r.last_ip || '', lastUa: r.last_ua || '', lastDevice: r.last_device || '', lastAt: Number(r.last_at || 0),
flags: parseFlags(r.flags), suspended: !!r.suspended, suspendedReason: r.suspended_reason || '', suspendedAt: Number(r.suspended_at || 0) });
const pubJ = a => ({ email: a.email, signupIp: a.signupIp || '', signupUa: a.signupUa || '', deviceId: a.deviceId || '', signupAt: a.signupAt || 0,
lastIp: a.lastIp || '', lastUa: a.lastUa || '', lastDevice: a.lastDevice || '', lastAt: a.lastAt || 0,
flags: a.flags || [], suspended: !!a.suspended, suspendedReason: a.suspendedReason || '', suspendedAt: a.suspendedAt || 0 });
function parseFlags(s) { try { const v = JSON.parse(s || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } }
async function get(email) {
const e = norm(email); if (!e) return null;
if (db.enabled()) { const r = await db.q('SELECT * FROM account_signals WHERE email=?', [e]); return r.length ? rowPub(r[0]) : null; }
if (!J.db) J.load(); return J.db[e] ? pubJ(J.db[e]) : null;
}
async function upsert(email, fields) {
const e = norm(email); if (!e) return;
if (db.enabled()) {
const cur = await get(e);
const v = Object.assign({ signupIp: null, signupUa: null, deviceId: null, signupAt: null, lastIp: null, lastUa: null, lastDevice: null, lastAt: null, flags: [], suspended: false, suspendedReason: null, suspendedAt: null }, cur || {}, fields);
await db.q(`INSERT INTO account_signals (email,signup_ip,signup_ua,device_id,signup_at,last_ip,last_ua,last_device,last_at,flags,suspended,suspended_reason,suspended_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE signup_ip=VALUES(signup_ip), signup_ua=VALUES(signup_ua), device_id=VALUES(device_id), signup_at=VALUES(signup_at),
last_ip=VALUES(last_ip), last_ua=VALUES(last_ua), last_device=VALUES(last_device), last_at=VALUES(last_at), flags=VALUES(flags), suspended=VALUES(suspended), suspended_reason=VALUES(suspended_reason), suspended_at=VALUES(suspended_at)`,
[e, v.signupIp || null, v.signupUa || null, v.deviceId || null, v.signupAt || null, v.lastIp || null, v.lastUa || null, v.lastDevice || null, v.lastAt || null, JSON.stringify(v.flags || []), v.suspended ? 1 : 0, v.suspendedReason || null, v.suspendedAt || null]);
return;
}
if (!J.db) J.load();
J.db[e] = Object.assign({ email: e }, J.db[e] || {}, fields); J.save();
}
// Has this BROWSER ever had an account? Used to stop the sign-in page telling an existing
// member "You're joining the line of @someone" just because they once clicked an invite
// link and the 30-day last-touch cookie is still around (Marty, 2026-09-17).
async function hasAccountOnDevice(req) {
const dev = deviceOf(req);
if (!dev) return false;
try { const rows = await all(); return rows.some(r => r.deviceId === dev || r.lastDevice === dev); }
catch (e) { return false; }
}
// ---- sign-up time ----
async function checkSignup(req, sponsorAccount, cfg, email) {
const ip = ipOf(req), dev = deviceOf(req); const flags = []; let block = null;
const rows = await all(); const now = Date.now();
const maxPerDay = Math.max(1, Number(cfg && cfg.fraudMaxSignupsPerIpDay) || 2);
const blockDevice = !(cfg && String(cfg.fraudBlockSharedDevice) === 'off');
// Everyone this sign-up collides with, so an approved exception can clear it below.
const collided = [];
if (dev) {
const same = rows.filter(r => r.deviceId === dev || r.lastDevice === dev);
if (same.length) { collided.push(...same.map(r => r.email)); flags.push('dup-device'); if (blockDevice) block = 'This browser already has an InstantAdPay account (' + mask(same[0].email) + '), and it is one account per person.' + QS + ' Shared computer at work or a library? Contact support.'; }
}
if (ip) {
const burst = rows.filter(r => r.signupIp === ip && now - (r.signupAt || 0) < 86400000);
if (burst.length >= maxPerDay) { flags.push('ip-burst'); block = block || 'Too many new accounts from this connection today, and it is one account per person.' + QS + ' If these are different people on one connection, try again tomorrow or contact support.'; }
const shared = rows.filter(r => (r.signupIp === ip || r.lastIp === ip) && now - Math.max(r.signupAt || 0, r.lastAt || 0) < 30 * 86400000);
if (shared.length) flags.push('shared-ip');
}
if (sponsorAccount && sponsorAccount.email) {
const sp = await get(sponsorAccount.email);
if (sp) {
if (dev && (sp.deviceId === dev || sp.lastDevice === dev)) { collided.push(sp.email); flags.push('sponsor-device'); if (blockDevice) block = block || 'The invite link you used belongs to an account on this same browser, so this would be a second account for the same person, which the Terms do not allow.' + QS; }
if (ip && (sp.signupIp === ip || sp.lastIp === ip)) flags.push('sponsor-ip');
}
}
// APPROVED EXCEPTION (Marty, 2026-09-17). Allow either the address signing up, or any
// account it collided with: whitelisting somebody's existing account is the usual case,
// because their second address is not known yet. Clearing the HARD flags matters as much
// as clearing the block, since those are what drop an account off the leaderboard and
// out of the holding tank. 'allowlisted' is recorded so the admin sees why it went through.
const okEmail = isAllowed(email);
const okOther = collided.some(e => isAllowed(e));
if (okEmail || okOther) {
block = null;
const kept = flags.filter(f => !HARD.has(f));
kept.push('allowlisted');
return { block: null, flags: [...new Set(kept)], ip, device: dev, allowlisted: true,
allowedBy: okEmail ? norm(email) : collided.find(e => isAllowed(e)) };
}
return { block, flags: [...new Set(flags)], ip, device: dev };
}
async function recordSignup(email, req, flags) {
const now = Date.now();
await upsert(email, { signupIp: ipOf(req), signupUa: uaOf(req), deviceId: deviceOf(req) || null, signupAt: now, lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || null, lastAt: now, flags: flags || [] });
if ((flags || []).some(f => HARD.has(f))) flaggedSet.add(norm(email));
}
async function recordSeen(email, req) {
const e = norm(email); if (!e) return;
const last = seenAt.get(e) || 0; if (Date.now() - last < 10 * 60 * 1000) return; seenAt.set(e, Date.now());
if (seenAt.size > 20000) seenAt.clear();
const cur = await get(e);
const fields = { lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || (cur ? cur.lastDevice : null), lastAt: Date.now() };
if (!cur) Object.assign(fields, { signupIp: null, signupUa: null, deviceId: deviceOf(req) || null, signupAt: null, flags: [] });
await upsert(e, fields);
}
// ---- admin ----
async function addFlags(email, flags) {
const cur = (await get(email)) || { flags: [] };
const merged = [...new Set([...(cur.flags || []), ...(flags || [])])];
await upsert(email, { flags: merged });
if (merged.some(f => HARD.has(f))) flaggedSet.add(norm(email)); else flaggedSet.delete(norm(email));
return merged;
}
async function clearFlags(email) { await upsert(email, { flags: [] }); flaggedSet.delete(norm(email)); }
async function suspend(email, reason) { await upsert(email, { suspended: true, suspendedReason: String(reason || '').slice(0, 200), suspendedAt: Date.now() }); suspendedSet.add(norm(email)); }
async function unsuspend(email) { await upsert(email, { suspended: false, suspendedReason: null, suspendedAt: null }); suspendedSet.delete(norm(email)); }
function isSuspended(email) { return suspendedSet.has(norm(email)); }
// Suspension still wins: an allow entry is permission to hold several accounts, not
// immunity from being suspended for something else.
function excluded(email) { const e = norm(email); if (suspendedSet.has(e)) return true; if (isAllowed(e)) return false; return flaggedSet.has(e); } // leaderboard / adoption
async function report() {
const rows = await all();
const groups = (key) => { const m = new Map(); for (const r of rows) { const k = r[key]; if (!k) continue; if (!m.has(k)) m.set(k, []); m.get(k).push(r.email); } return [...m.entries()].filter(([, v]) => new Set(v).size > 1).map(([k, v]) => ({ key: k, emails: [...new Set(v)] })); };
const byDevice = groups('deviceId').concat(groups('lastDevice'));
const byIp = groups('signupIp').concat(groups('lastIp'));
const dedupe = (list) => { const seen = new Set(); return list.filter(g => { const k = g.emails.slice().sort().join('|'); if (seen.has(k)) return false; seen.add(k); return true; }); };
return { flagged: rows.filter(r => (r.flags || []).length).map(r => ({ email: r.email, flags: r.flags, signupIp: r.signupIp, lastIp: r.lastIp, suspended: r.suspended })),
suspended: rows.filter(r => r.suspended).map(r => ({ email: r.email, reason: r.suspendedReason, at: r.suspendedAt })),
sharedDevice: dedupe(byDevice), sharedIp: dedupe(byIp), total: rows.length };
}
function mask(e) { return String(e || '').replace(/^(.{2}).*(@.*)$/, '$1***$2'); }
module.exports = { init, checkSignup, recordSignup, recordSeen, addFlags, clearFlags, suspend, unsuspend, isSuspended, excluded, report, get, deviceOf, hasAccountOnDevice, newDeviceId, deviceCookie, ipOf, HARD,
isAllowed, allowList, allowAdd, allowRemove };