diff --git a/fraud.js b/fraud.js index 329cd95..84234f1 100644 --- a/fraud.js +++ b/fraud.js @@ -1,152 +1,162 @@ -// 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' : ''); } - -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) + '), 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)) { 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'); - } - } - 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 }; +// 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' : ''); } + +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) { + 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) + '), 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)) { 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'); + } + } + 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, hasAccountOnDevice, newDeviceId, deviceCookie, ipOf, HARD }; diff --git a/qa/sponsor-note.mjs b/qa/sponsor-note.mjs new file mode 100644 index 0000000..d54acb6 --- /dev/null +++ b/qa/sponsor-note.mjs @@ -0,0 +1,71 @@ +// "You're joining the line of X" must only greet someone actually about to join. +// +// The last-touch sponsor cookie lives 30 days, so an EXISTING member who once clicked a +// teammate's invite link was being told on the sign-in page that logging in would place +// them under that person (Marty, 2026-09-17). Untrue and alarming: their sponsor locked +// at their first purchase and nothing on that screen can move it. +// +// Boots its own throwaway server so the account row it needs is real, not assumed. +// node qa/sponsor-note.mjs +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const PORT = 8799; +const B = 'http://127.0.0.1:' + PORT; +const DATA = path.join(os.tmpdir(), 'iap-sponsor-note-' + 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 = code => { try { srv.kill(); } catch (e) {} process.exit(code); }; +for (let i = 0; i < 60; i++) { + try { const r = await fetch(B + '/api/stats'); if (r.ok) break; } catch (e) {} + await new Promise(r => setTimeout(r, 500)); +} + +const j = async (p, opts) => { const r = await fetch(B + p, opts); return { status: r.status, body: await r.json().catch(() => ({})), headers: r.headers }; }; +const ask = (cookie, qs) => fetch(B + '/api/sponsor' + (qs || ''), { headers: cookie ? { Cookie: cookie } : {} }).then(r => r.json()); + +// make a real member on device DEV_A, the way a person would +const DEV_A = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; +const DEV_B = '0f9e8d7c6b5a49382716f5e4d3c2b1a0'; +const EMAIL = 'returning@example.com'; +const start = await j('/api/auth/email/start', { method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: 'iap.dev=' + DEV_A }, + body: JSON.stringify({ email: EMAIL, fts: Date.now() - 20000 }) }); +const code = start.body && (start.body.devCode || start.body.code); +if (!code) { console.log('could not start sign-up (no devCode in test mode):', JSON.stringify(start.body).slice(0, 200)); bye(2); } +const fin = await j('/api/auth/email/verify', { method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: 'iap.dev=' + DEV_A }, + body: JSON.stringify({ email: EMAIL, code }) }); +t('a real account was created for the test', fin.status === 200 && !fin.body.error, JSON.stringify(fin.body).slice(0, 160)); + +const REF = 'house'; +// 1. arriving THROUGH a link: greeting is correct and wanted, even on that device +const arriving = await ask('iap.dev=' + DEV_A, '?ref=' + REF); +t('someone arriving with ?ref= is still greeted', arriving.invited === true, JSON.stringify(arriving).slice(0, 140)); + +// 2. a browser with NO account, carrying only the stored invite cookie: still greeted, +// because they genuinely came back to finish joining +const fresh = await ask('iap.sponsor=' + REF + '; iap.dev=' + DEV_B); +t('a browser with no account is still greeted from the cookie', fresh.invited === true, JSON.stringify(fresh).slice(0, 140)); + +// 3. THE BUG: a browser that already has an account, carrying a stale invite cookie +const existing = await ask('iap.sponsor=' + REF + '; iap.dev=' + DEV_A); +t('a browser that already has an account is NOT told it is joining anyone', + existing.invited === false, JSON.stringify(existing).slice(0, 180)); + +// 4. attribution itself must be untouched: the ref is still resolved and returned +t('the sponsor reference is still resolved for attribution', + existing.ref === REF || existing.sponsorId > 0, JSON.stringify(existing).slice(0, 180)); + +console.log('PASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {} +bye(bad.length ? 1 : 0); diff --git a/server.js b/server.js index 6fa4526..325d92a 100644 --- a/server.js +++ b/server.js @@ -1184,7 +1184,17 @@ const server = http.createServer(async (req, res) => { if (!a && /^\d+$/.test(nameTok)) a = await accounts.byMemberId(Number(nameTok)); if (a) { name = a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : null); avatarUrl = a.avatarUrl || null; own = !!(acct && a.email === acct.email); var bio = null, cobrand = false; try { cobrand = (await ads.milestonesOf(a.email)).includes('level3'); if (cobrand) bio = a.bio ? String(a.bio).slice(0, 220) : null; } catch (e) {} } } - return json(res, 200, { ref: tok, sponsorId, sponsorBlocked, sponsorRouted, sponsorName: spd.name || null, invited: !!(tok || showTok), name, avatarUrl, own, bio: typeof bio === 'undefined' ? null : bio, cobrand: typeof cobrand === 'undefined' ? false : cobrand }); + // "You're joining the line of X" must only greet someone who is actually about to + // join. The last-touch sponsor cookie lives 30 days, so an EXISTING member who once + // clicked a teammate's link was being told on the sign-in page that logging in would + // place them under that person (Marty, 2026-09-17). Alarming, and untrue: their + // sponsor locked at their first purchase and nothing here can move it. + // Show it when they just arrived through a link (?ref= in the URL), or when the cookie + // is there AND this browser has never had an account. Attribution itself is untouched. + let invited = !!showTok; + if (!invited && tok) { try { invited = !(await fraud.hasAccountOnDevice(req)); } catch (e) { invited = true; } } + if (acct) invited = false; // already a member: they are not joining anybody's line + return json(res, 200, { ref: tok, sponsorId, sponsorBlocked, sponsorRouted, sponsorName: spd.name || null, invited, name, avatarUrl, own, bio: typeof bio === 'undefined' ? null : bio, cobrand: typeof cobrand === 'undefined' ? false : cobrand }); } if (p === '/api/stats' && req.method === 'GET') { let members = 0; try { members = await chain.memberCount(); } catch (e) {}