Files
instantadpay/fraud.js
T
martbost 34c62b9d3a Anti-fraud: one account per person enforced at sign-up (device cookie + IP), flags, admin duplicate signals, suspend switch
Marty, 2026-09-16, after @megamol created megamol2/megamol3 under his own link and bought $20 on each
to fake his two qualifying buyers. fraud.js records sign-up IP/UA/browser id (iap.dev cookie set with
the code request) and last-seen on sign-in. New accounts: dup-device (browser already has an account)
and sponsor-device are refused, ip-burst (> fraudMaxSignupsPerIpDay, default 2, per 24h) is refused;
sponsor-ip and shared-ip are flagged only. Flagged/suspended accounts never count on the leaderboard
and cannot adopt from the tank; suspended accounts are signed out everywhere (auth.fromRequest
wrapper) and refused at sign-in. Admin > Members: Duplicate signals card (shared browser / IP,
flagged, suspended), flags badge, Suspend/Unsuspend; GET /api/admin/fraud; PATCH members {suspend,
reason, flags}. Telegram admin alert on every block/flag. Privacy page + chatbot prompt updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 14:39:13 -05:00

152 lines
11 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 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' : ''); }
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();
}
// ---- sign-up time ----
async function checkSignup(req, sponsorAccount, cfg) {
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');
if (dev) {
const same = rows.filter(r => r.deviceId === dev || r.lastDevice === dev);
if (same.length) { flags.push('dup-device'); if (blockDevice) block = 'This browser already has an InstantAdPay account (' + mask(same[0].email) + '). One account per person: sign in to that one instead. If this is a shared computer, 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. One account per person. 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)) { flags.push('sponsor-device'); if (blockDevice) block = block || 'The invite link you used belongs to an account on this same browser. One account per person, and you cannot refer yourself.'; }
if (ip && (sp.signupIp === ip || sp.lastIp === ip)) flags.push('sponsor-ip');
}
}
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)); }
function excluded(email) { const e = norm(email); return suspendedSet.has(e) || 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, newDeviceId, deviceCookie, ipOf, HARD };