39572c0ff5
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>
219 lines
16 KiB
JavaScript
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 };
|