From 2dfccf0039e88c9889ab06ab32ec7251b0d430f5 Mon Sep 17 00:00:00 2001 From: martbost Date: Thu, 17 Sep 2026 08:41:46 -0500 Subject: [PATCH] Sign-in no longer tells an existing member they are joining somebody's line Marty signed in and was told "You're joining the line of @bliss". He is member #1. Cause: the last-touch sponsor cookie lives 30 days, and /api/sponsor set invited purely from that cookie. So any member who had ever clicked a teammate's invite link was greeted on the sign-in screen as though logging in would place them under that person. Untrue, and alarming in exactly the wrong place: their sponsor locked at their first purchase and nothing on that screen can move it. Anyone seeing that would reasonably worry their line was about to change. The greeting now shows when someone actually arrived through a link (?ref= in the URL), or when the cookie is present AND this browser has never had an account, which is the genuine "came back later to finish joining" case. A browser that already has an account, or a signed-in session, never sees it. Attribution is deliberately untouched: the cookie still resolves, the sponsor id is still returned, and placement still works exactly as before. Only the greeting changed. fraud.hasAccountOnDevice(req) is the new signal, reusing the device cookie the one-account-per-person checks already set. qa/sponsor-note.mjs (5 assertions) boots its own throwaway server and creates a REAL account so the case is proven rather than assumed: still greeted with ?ref=, still greeted from the cookie on a browser with no account, NOT greeted on the browser that has one, and attribution still resolving. qa/run.sh member: 0 bugs. Co-Authored-By: Claude Opus 5 (1M context) --- fraud.js | 314 +++++++++++++++++++++++--------------------- qa/sponsor-note.mjs | 71 ++++++++++ server.js | 12 +- 3 files changed, 244 insertions(+), 153 deletions(-) create mode 100644 qa/sponsor-note.mjs 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) {}