Files
martbost 39572c0ff5 Sign-up deny list beside the allow list
fraud-allow.json says "never block this person". There was nothing for the opposite case.
fraud-block.json: an address on it cannot open an account through /api/signup or the
email-code door, and the admin fraud report carries the list with a matching
/api/admin/fraud/block route. Existing accounts are untouched; Suspend covers those.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 05:46:04 -05:00

219 lines
16 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() }; }
// The opposite list. An address here can never open an account, through any door. Same
// shape and file pattern as the allow list so it behaves identically in DB and JSON mode.
const BLOCK_FILE = () => path.join(DATA_DIR, 'fraud-block.json');
let blockDb = null; // { "<email>": { note, at, by } }
function blockLoad() { if (blockDb) return blockDb; try { blockDb = JSON.parse(fs.readFileSync(BLOCK_FILE(), 'utf8')); } catch (e) { blockDb = {}; } if (!blockDb || typeof blockDb !== 'object') blockDb = {}; return blockDb; }
function blockSave() { try { fs.writeFileSync(BLOCK_FILE(), JSON.stringify(blockLoad())); } catch (e) { console.error('fraud block save', e.message); } }
function isBlocked(email) { const e = norm(email); return !!(e && blockLoad()[e]); }
function blockList() { const d = blockLoad(); return Object.keys(d).sort().map(e => ({ email: e, note: d[e].note || '', at: d[e].at || 0, by: d[e].by || '' })); }
function blockAdd(email, note, by) {
const e = norm(email); if (!e || !e.includes('@')) return { error: 'Enter an email address.' };
blockLoad()[e] = { note: String(note || '').slice(0, 200), at: Date.now(), by: String(by || '').slice(0, 120) };
blockSave();
return { ok: true, list: blockList() };
}
function blockRemove(email) { const e = norm(email); const d = blockLoad(); if (d[e]) { delete d[e]; blockSave(); } return { ok: true, list: blockList() }; }
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, isBlocked, blockList, blockAdd, blockRemove };