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>
This commit is contained in:
@@ -1,162 +1,202 @@
|
|||||||
// InstantAdPay anti-fraud signals (Marty, 2026-09-16, after @megamol created megamol2/megamol3 under
|
// 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).
|
// 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
|
// 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:
|
// last-seen IP/UA/device on every sign-in. At sign-up:
|
||||||
// HARD BLOCK dup-device another account already used this browser
|
// 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 ip-burst more than fraudMaxSignupsPerIpDay accounts from this IP in 24h
|
||||||
// HARD BLOCK sponsor-device the sponsor's account used this same browser
|
// 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 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
|
// 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,
|
// 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.
|
// 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.
|
// The contract's on-chain payments are outside all of this and are never reversed.
|
||||||
'use strict';
|
'use strict';
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
let DATA_DIR = null;
|
let DATA_DIR = null;
|
||||||
const FILE = () => path.join(DATA_DIR, 'account-signals.json');
|
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) {} } };
|
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 suspendedSet = new Set(); // refreshed on init and on every suspend/unsuspend
|
||||||
let flaggedSet = new Set(); // accounts carrying a hard flag (excluded from leaderboard / adoption)
|
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 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']);
|
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)
|
let seenAt = new Map(); // email -> ts of last recordSeen (throttle writes)
|
||||||
|
|
||||||
function norm(e) { return String(e || '').trim().toLowerCase(); }
|
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 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 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 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 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 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' : ''); }
|
function deviceCookie(id, isProd) { return 'iap.dev=' + id + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (400 * 86400) + (isProd ? '; Secure' : ''); }
|
||||||
|
|
||||||
async function init(opts) {
|
// ---- exception list ----
|
||||||
DATA_DIR = opts.dataDir;
|
// Marty approves specific people to hold more than one account (partners, staff, a spouse
|
||||||
if (db.enabled()) {
|
// on a shared machine). Their sign-ups must not be blocked, must not carry a hard flag,
|
||||||
await db.q(`CREATE TABLE IF NOT EXISTS account_signals (
|
// and must not be quietly dropped from the leaderboard or barred from adopting. Kept in
|
||||||
email VARCHAR(190) PRIMARY KEY,
|
// its own small file so it works the same in DB mode and JSON mode, and so an allow entry
|
||||||
signup_ip VARCHAR(45) NULL, signup_ua VARCHAR(200) NULL, device_id CHAR(32) NULL, signup_at BIGINT NULL,
|
// can be added BEFORE the second account exists.
|
||||||
last_ip VARCHAR(45) NULL, last_ua VARCHAR(200) NULL, last_device CHAR(32) NULL, last_at BIGINT NULL,
|
const ALLOW_FILE = () => path.join(DATA_DIR, 'fraud-allow.json');
|
||||||
flags VARCHAR(400) NULL, suspended TINYINT NOT NULL DEFAULT 0, suspended_reason VARCHAR(200) NULL, suspended_at BIGINT NULL,
|
let allowDb = null; // { "<email>": { note, at, by } }
|
||||||
INDEX (signup_ip), INDEX (device_id), INDEX (last_ip), INDEX (last_device), INDEX (suspended)
|
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; }
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
function allowSave() { try { fs.writeFileSync(ALLOW_FILE(), JSON.stringify(allowLoad())); } catch (e) { console.error('fraud allow save', e.message); } }
|
||||||
} else J.load();
|
function isAllowed(email) { const e = norm(email); return !!(e && allowLoad()[e]); }
|
||||||
await refreshSets();
|
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) {
|
||||||
async function refreshSets() {
|
const e = norm(email);
|
||||||
const rows = await all();
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return { error: 'That email address does not look right.' };
|
||||||
suspendedSet = new Set(rows.filter(r => r.suspended).map(r => r.email));
|
allowLoad()[e] = { note: String(note || '').slice(0, 200), at: Date.now(), by: String(by || '').slice(0, 120) };
|
||||||
flaggedSet = new Set(rows.filter(r => (r.flags || []).some(f => HARD.has(f))).map(r => r.email));
|
allowSave();
|
||||||
}
|
return { ok: true, list: allowList() };
|
||||||
async function all() {
|
}
|
||||||
if (db.enabled()) return (await db.q('SELECT * FROM account_signals')).map(rowPub);
|
function allowRemove(email) { const e = norm(email); const d = allowLoad(); if (d[e]) { delete d[e]; allowSave(); } return { ok: true, list: allowList() }; }
|
||||||
if (!J.db) J.load();
|
|
||||||
return Object.values(J.db).map(pubJ);
|
async function init(opts) {
|
||||||
}
|
DATA_DIR = opts.dataDir;
|
||||||
const rowPub = r => ({ email: r.email, signupIp: r.signup_ip || '', signupUa: r.signup_ua || '', deviceId: r.device_id || '', signupAt: Number(r.signup_at || 0),
|
if (db.enabled()) {
|
||||||
lastIp: r.last_ip || '', lastUa: r.last_ua || '', lastDevice: r.last_device || '', lastAt: Number(r.last_at || 0),
|
await db.q(`CREATE TABLE IF NOT EXISTS account_signals (
|
||||||
flags: parseFlags(r.flags), suspended: !!r.suspended, suspendedReason: r.suspended_reason || '', suspendedAt: Number(r.suspended_at || 0) });
|
email VARCHAR(190) PRIMARY KEY,
|
||||||
const pubJ = a => ({ email: a.email, signupIp: a.signupIp || '', signupUa: a.signupUa || '', deviceId: a.deviceId || '', signupAt: a.signupAt || 0,
|
signup_ip VARCHAR(45) NULL, signup_ua VARCHAR(200) NULL, device_id CHAR(32) NULL, signup_at BIGINT NULL,
|
||||||
lastIp: a.lastIp || '', lastUa: a.lastUa || '', lastDevice: a.lastDevice || '', lastAt: a.lastAt || 0,
|
last_ip VARCHAR(45) NULL, last_ua VARCHAR(200) NULL, last_device CHAR(32) NULL, last_at BIGINT NULL,
|
||||||
flags: a.flags || [], suspended: !!a.suspended, suspendedReason: a.suspendedReason || '', suspendedAt: a.suspendedAt || 0 });
|
flags VARCHAR(400) NULL, suspended TINYINT NOT NULL DEFAULT 0, suspended_reason VARCHAR(200) NULL, suspended_at BIGINT NULL,
|
||||||
function parseFlags(s) { try { const v = JSON.parse(s || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } }
|
INDEX (signup_ip), INDEX (device_id), INDEX (last_ip), INDEX (last_device), INDEX (suspended)
|
||||||
async function get(email) {
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
const e = norm(email); if (!e) return null;
|
} else J.load();
|
||||||
if (db.enabled()) { const r = await db.q('SELECT * FROM account_signals WHERE email=?', [e]); return r.length ? rowPub(r[0]) : null; }
|
await refreshSets();
|
||||||
if (!J.db) J.load(); return J.db[e] ? pubJ(J.db[e]) : null;
|
}
|
||||||
}
|
async function refreshSets() {
|
||||||
async function upsert(email, fields) {
|
const rows = await all();
|
||||||
const e = norm(email); if (!e) return;
|
suspendedSet = new Set(rows.filter(r => r.suspended).map(r => r.email));
|
||||||
if (db.enabled()) {
|
flaggedSet = new Set(rows.filter(r => (r.flags || []).some(f => HARD.has(f))).map(r => r.email));
|
||||||
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);
|
async function all() {
|
||||||
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)
|
if (db.enabled()) return (await db.q('SELECT * FROM account_signals')).map(rowPub);
|
||||||
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),
|
if (!J.db) J.load();
|
||||||
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)`,
|
return Object.values(J.db).map(pubJ);
|
||||||
[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;
|
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),
|
||||||
if (!J.db) J.load();
|
flags: parseFlags(r.flags), suspended: !!r.suspended, suspendedReason: r.suspended_reason || '', suspendedAt: Number(r.suspended_at || 0) });
|
||||||
J.db[e] = Object.assign({ email: e }, J.db[e] || {}, fields); J.save();
|
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 });
|
||||||
// Has this BROWSER ever had an account? Used to stop the sign-in page telling an existing
|
function parseFlags(s) { try { const v = JSON.parse(s || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } }
|
||||||
// member "You're joining the line of @someone" just because they once clicked an invite
|
async function get(email) {
|
||||||
// link and the 30-day last-touch cookie is still around (Marty, 2026-09-17).
|
const e = norm(email); if (!e) return null;
|
||||||
async function hasAccountOnDevice(req) {
|
if (db.enabled()) { const r = await db.q('SELECT * FROM account_signals WHERE email=?', [e]); return r.length ? rowPub(r[0]) : null; }
|
||||||
const dev = deviceOf(req);
|
if (!J.db) J.load(); return J.db[e] ? pubJ(J.db[e]) : null;
|
||||||
if (!dev) return false;
|
}
|
||||||
try { const rows = await all(); return rows.some(r => r.deviceId === dev || r.lastDevice === dev); }
|
async function upsert(email, fields) {
|
||||||
catch (e) { return false; }
|
const e = norm(email); if (!e) return;
|
||||||
}
|
if (db.enabled()) {
|
||||||
|
const cur = await get(e);
|
||||||
// ---- sign-up time ----
|
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);
|
||||||
async function checkSignup(req, sponsorAccount, cfg) {
|
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)
|
||||||
const ip = ipOf(req), dev = deviceOf(req); const flags = []; let block = null;
|
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),
|
||||||
const rows = await all(); const now = Date.now();
|
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)`,
|
||||||
const maxPerDay = Math.max(1, Number(cfg && cfg.fraudMaxSignupsPerIpDay) || 2);
|
[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]);
|
||||||
const blockDevice = !(cfg && String(cfg.fraudBlockSharedDevice) === 'off');
|
return;
|
||||||
if (dev) {
|
}
|
||||||
const same = rows.filter(r => r.deviceId === dev || r.lastDevice === dev);
|
if (!J.db) J.load();
|
||||||
if (same.length) { 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.'; }
|
J.db[e] = Object.assign({ email: e }, J.db[e] || {}, fields); J.save();
|
||||||
}
|
}
|
||||||
if (ip) {
|
|
||||||
const burst = rows.filter(r => r.signupIp === ip && now - (r.signupAt || 0) < 86400000);
|
// Has this BROWSER ever had an account? Used to stop the sign-in page telling an existing
|
||||||
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.'; }
|
// member "You're joining the line of @someone" just because they once clicked an invite
|
||||||
const shared = rows.filter(r => (r.signupIp === ip || r.lastIp === ip) && now - Math.max(r.signupAt || 0, r.lastAt || 0) < 30 * 86400000);
|
// link and the 30-day last-touch cookie is still around (Marty, 2026-09-17).
|
||||||
if (shared.length) flags.push('shared-ip');
|
async function hasAccountOnDevice(req) {
|
||||||
}
|
const dev = deviceOf(req);
|
||||||
if (sponsorAccount && sponsorAccount.email) {
|
if (!dev) return false;
|
||||||
const sp = await get(sponsorAccount.email);
|
try { const rows = await all(); return rows.some(r => r.deviceId === dev || r.lastDevice === dev); }
|
||||||
if (sp) {
|
catch (e) { return false; }
|
||||||
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, 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');
|
|
||||||
}
|
// ---- sign-up time ----
|
||||||
}
|
async function checkSignup(req, sponsorAccount, cfg, email) {
|
||||||
return { block, flags: [...new Set(flags)], ip, device: dev };
|
const ip = ipOf(req), dev = deviceOf(req); const flags = []; let block = null;
|
||||||
}
|
const rows = await all(); const now = Date.now();
|
||||||
async function recordSignup(email, req, flags) {
|
const maxPerDay = Math.max(1, Number(cfg && cfg.fraudMaxSignupsPerIpDay) || 2);
|
||||||
const now = Date.now();
|
const blockDevice = !(cfg && String(cfg.fraudBlockSharedDevice) === 'off');
|
||||||
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 || [] });
|
// Everyone this sign-up collides with, so an approved exception can clear it below.
|
||||||
if ((flags || []).some(f => HARD.has(f))) flaggedSet.add(norm(email));
|
const collided = [];
|
||||||
}
|
if (dev) {
|
||||||
async function recordSeen(email, req) {
|
const same = rows.filter(r => r.deviceId === dev || r.lastDevice === dev);
|
||||||
const e = norm(email); if (!e) return;
|
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.'; }
|
||||||
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();
|
if (ip) {
|
||||||
const cur = await get(e);
|
const burst = rows.filter(r => r.signupIp === ip && now - (r.signupAt || 0) < 86400000);
|
||||||
const fields = { lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || (cur ? cur.lastDevice : null), lastAt: Date.now() };
|
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.'; }
|
||||||
if (!cur) Object.assign(fields, { signupIp: null, signupUa: null, deviceId: deviceOf(req) || null, signupAt: null, flags: [] });
|
const shared = rows.filter(r => (r.signupIp === ip || r.lastIp === ip) && now - Math.max(r.signupAt || 0, r.lastAt || 0) < 30 * 86400000);
|
||||||
await upsert(e, fields);
|
if (shared.length) flags.push('shared-ip');
|
||||||
}
|
}
|
||||||
|
if (sponsorAccount && sponsorAccount.email) {
|
||||||
// ---- admin ----
|
const sp = await get(sponsorAccount.email);
|
||||||
async function addFlags(email, flags) {
|
if (sp) {
|
||||||
const cur = (await get(email)) || { flags: [] };
|
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; }
|
||||||
const merged = [...new Set([...(cur.flags || []), ...(flags || [])])];
|
if (ip && (sp.signupIp === ip || sp.lastIp === ip)) flags.push('sponsor-ip');
|
||||||
await upsert(email, { flags: merged });
|
}
|
||||||
if (merged.some(f => HARD.has(f))) flaggedSet.add(norm(email)); else flaggedSet.delete(norm(email));
|
}
|
||||||
return merged;
|
// 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,
|
||||||
async function clearFlags(email) { await upsert(email, { flags: [] }); flaggedSet.delete(norm(email)); }
|
// because their second address is not known yet. Clearing the HARD flags matters as much
|
||||||
async function suspend(email, reason) { await upsert(email, { suspended: true, suspendedReason: String(reason || '').slice(0, 200), suspendedAt: Date.now() }); suspendedSet.add(norm(email)); }
|
// as clearing the block, since those are what drop an account off the leaderboard and
|
||||||
async function unsuspend(email) { await upsert(email, { suspended: false, suspendedReason: null, suspendedAt: null }); suspendedSet.delete(norm(email)); }
|
// out of the holding tank. 'allowlisted' is recorded so the admin sees why it went through.
|
||||||
function isSuspended(email) { return suspendedSet.has(norm(email)); }
|
const okEmail = isAllowed(email);
|
||||||
function excluded(email) { const e = norm(email); return suspendedSet.has(e) || flaggedSet.has(e); } // leaderboard / adoption
|
const okOther = collided.some(e => isAllowed(e));
|
||||||
async function report() {
|
if (okEmail || okOther) {
|
||||||
const rows = await all();
|
block = null;
|
||||||
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 kept = flags.filter(f => !HARD.has(f));
|
||||||
const byDevice = groups('deviceId').concat(groups('lastDevice'));
|
kept.push('allowlisted');
|
||||||
const byIp = groups('signupIp').concat(groups('lastIp'));
|
return { block: null, flags: [...new Set(kept)], ip, device: dev, allowlisted: true,
|
||||||
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; }); };
|
allowedBy: okEmail ? norm(email) : collided.find(e => isAllowed(e)) };
|
||||||
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 })),
|
return { block, flags: [...new Set(flags)], ip, device: dev };
|
||||||
sharedDevice: dedupe(byDevice), sharedIp: dedupe(byIp), total: rows.length };
|
}
|
||||||
}
|
async function recordSignup(email, req, flags) {
|
||||||
function mask(e) { return String(e || '').replace(/^(.{2}).*(@.*)$/, '$1***$2'); }
|
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 || [] });
|
||||||
module.exports = { init, checkSignup, recordSignup, recordSeen, addFlags, clearFlags, suspend, unsuspend, isSuspended, excluded, report, get, deviceOf, hasAccountOnDevice, newDeviceId, deviceCookie, ipOf, HARD };
|
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 };
|
||||||
|
|||||||
+1
-1
@@ -488,6 +488,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/common.js?v=20260916a"></script>
|
<script src="/assets/common.js?v=20260916a"></script>
|
||||||
<script src="/assets/admin.js?v=20260917a"></script>
|
<script src="/assets/admin.js?v=20260917b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+935
-901
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
// Approved exceptions: people Marty has okayed to hold more than one account.
|
||||||
|
//
|
||||||
|
// The guard blocks a second sign-up from a browser that already has an account. For an
|
||||||
|
// approved person it must let them through, must NOT leave a hard flag (that is what
|
||||||
|
// silently drops an account off the leaderboard and out of the holding tank), and must
|
||||||
|
// still respect suspension.
|
||||||
|
//
|
||||||
|
// node qa/fraud-allow.mjs
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const PORT = 8801;
|
||||||
|
const B = 'http://127.0.0.1:' + PORT;
|
||||||
|
const DATA = path.join(os.tmpdir(), 'iap-fraud-allow-' + Date.now());
|
||||||
|
fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
const ok = [], bad = [];
|
||||||
|
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
|
||||||
|
|
||||||
|
const srv = spawn(process.execPath, ['server.js'], {
|
||||||
|
env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, ADMIN_PASSWORD: 'localtest', ADMIN_EMAIL: 'admin@example.com', NODE_ENV: 'test' },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
const bye = c => { try { srv.kill(); } catch (e) {} try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {} process.exit(c); };
|
||||||
|
for (let i = 0; i < 60; i++) { try { if ((await fetch(B + '/api/stats')).ok) break; } catch (e) {} await new Promise(r => setTimeout(r, 500)); }
|
||||||
|
|
||||||
|
const DEV = 'dededededededededededededededede';
|
||||||
|
const post = (p, body, hdrs) => fetch(B + p, { method: 'POST',
|
||||||
|
headers: Object.assign({ 'Content-Type': 'application/json' }, hdrs || {}), body: JSON.stringify(body) })
|
||||||
|
.then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
|
||||||
|
// isAdmin() accepts a bearer token, which is far simpler here than a portal session
|
||||||
|
const ADMIN = { Authorization: 'Bearer localtest' };
|
||||||
|
|
||||||
|
// create the first account on this browser
|
||||||
|
// NOTE: the duplicate checks run at VERIFY, not at start. Starting a code is harmless;
|
||||||
|
// creating the account is the act that gets blocked.
|
||||||
|
const signUp = async (email) => {
|
||||||
|
const s = await post('/api/auth/email/start', { email, fts: Date.now() - 20000 }, { Cookie: 'iap.dev=' + DEV });
|
||||||
|
if (s.status !== 200 || !s.body.devCode) return s;
|
||||||
|
return await post('/api/auth/email/verify', { email, code: s.body.devCode }, { Cookie: 'iap.dev=' + DEV });
|
||||||
|
};
|
||||||
|
const first = await signUp('primary@example.com');
|
||||||
|
t('the first account on a browser is created', first.status === 200 && !first.body.error, JSON.stringify(first.body).slice(0, 140));
|
||||||
|
|
||||||
|
// a SECOND account on the same browser is blocked, as designed
|
||||||
|
const blocked = await signUp('second@example.com');
|
||||||
|
t('a second account on the same browser is blocked by default', blocked.status === 403, blocked.status + ' ' + JSON.stringify(blocked.body).slice(0, 120));
|
||||||
|
t('and the refusal points at Qualified Start', /Qualified Start/i.test(String(blocked.body.error || '')), String(blocked.body.error || '').slice(0, 90));
|
||||||
|
|
||||||
|
// admin approves the exception on the EXISTING account, which is the usual case
|
||||||
|
const added = await post('/api/admin/fraud/allow', { email: 'primary@example.com', note: 'business partner, approved' }, ADMIN);
|
||||||
|
t('the exception is accepted', added.status === 200 && Array.isArray(added.body.list), JSON.stringify(added.body).slice(0, 140));
|
||||||
|
t('and it is listed with its note', (added.body.list || []).some(a => a.email === 'primary@example.com' && /partner/.test(a.note)), JSON.stringify(added.body.list));
|
||||||
|
|
||||||
|
// now the same second sign-up goes through
|
||||||
|
const allowed = await signUp('second@example.com');
|
||||||
|
t('the approved person can now create the second account', allowed.status === 200 && !allowed.body.error,
|
||||||
|
allowed.status + ' ' + JSON.stringify(allowed.body).slice(0, 140));
|
||||||
|
|
||||||
|
// and it must not be left carrying a hard flag
|
||||||
|
const rep = await fetch(B + '/api/admin/fraud', { headers: ADMIN }).then(r => r.json());
|
||||||
|
// Only the HARD flags matter: those are what drop an account off the leaderboard and out
|
||||||
|
// of the holding tank. 'shared-ip' is informational and carries no penalty, so an approved
|
||||||
|
// person may legitimately still show it.
|
||||||
|
const HARD = ['dup-device', 'ip-burst', 'sponsor-device', 'sponsor-ip', 'multi-account'];
|
||||||
|
const row = (rep.flagged || []).find(f => f.email === 'second@example.com');
|
||||||
|
const hard = ((row && row.flags) || []).filter(f => HARD.includes(f));
|
||||||
|
t('the new account carries NO hard flag', hard.length === 0, JSON.stringify(row || {}));
|
||||||
|
t('and it is marked as allowlisted so the admin sees why', !row || (row.flags || []).includes('allowlisted'), JSON.stringify(row || {}));
|
||||||
|
t('the exception shows in the admin report', (rep.allow || []).some(a => a.email === 'primary@example.com'), JSON.stringify(rep.allow));
|
||||||
|
|
||||||
|
// removing the exception restores the normal rule
|
||||||
|
await post('/api/admin/fraud/allow', { email: 'primary@example.com', remove: true }, ADMIN);
|
||||||
|
const reBlocked = await signUp('third@example.com');
|
||||||
|
t('removing the exception blocks again', reBlocked.status === 403, reBlocked.status + ' ' + JSON.stringify(reBlocked.body).slice(0, 110));
|
||||||
|
|
||||||
|
// a bad address is rejected rather than stored
|
||||||
|
const badAdd = await post('/api/admin/fraud/allow', { email: 'not-an-email', note: '' }, ADMIN);
|
||||||
|
t('a malformed address is refused', badAdd.status === 400, JSON.stringify(badAdd.body).slice(0, 100));
|
||||||
|
|
||||||
|
// and the endpoint is admin-only
|
||||||
|
const noAuth = await post('/api/admin/fraud/allow', { email: 'x@example.com' });
|
||||||
|
t('the endpoint requires admin', noAuth.status === 401, String(noAuth.status));
|
||||||
|
|
||||||
|
console.log('PASS ' + ok.length);
|
||||||
|
for (const b of bad) console.log('FAIL ' + b);
|
||||||
|
bye(bad.length ? 1 : 0);
|
||||||
Reference in New Issue
Block a user