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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,152 +1,162 @@
|
|||||||
// 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) {
|
async function init(opts) {
|
||||||
DATA_DIR = opts.dataDir;
|
DATA_DIR = opts.dataDir;
|
||||||
if (db.enabled()) {
|
if (db.enabled()) {
|
||||||
await db.q(`CREATE TABLE IF NOT EXISTS account_signals (
|
await db.q(`CREATE TABLE IF NOT EXISTS account_signals (
|
||||||
email VARCHAR(190) PRIMARY KEY,
|
email VARCHAR(190) PRIMARY KEY,
|
||||||
signup_ip VARCHAR(45) NULL, signup_ua VARCHAR(200) NULL, device_id CHAR(32) NULL, signup_at BIGINT NULL,
|
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,
|
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,
|
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)
|
INDEX (signup_ip), INDEX (device_id), INDEX (last_ip), INDEX (last_device), INDEX (suspended)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
} else J.load();
|
} else J.load();
|
||||||
await refreshSets();
|
await refreshSets();
|
||||||
}
|
}
|
||||||
async function refreshSets() {
|
async function refreshSets() {
|
||||||
const rows = await all();
|
const rows = await all();
|
||||||
suspendedSet = new Set(rows.filter(r => r.suspended).map(r => r.email));
|
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));
|
flaggedSet = new Set(rows.filter(r => (r.flags || []).some(f => HARD.has(f))).map(r => r.email));
|
||||||
}
|
}
|
||||||
async function all() {
|
async function all() {
|
||||||
if (db.enabled()) return (await db.q('SELECT * FROM account_signals')).map(rowPub);
|
if (db.enabled()) return (await db.q('SELECT * FROM account_signals')).map(rowPub);
|
||||||
if (!J.db) J.load();
|
if (!J.db) J.load();
|
||||||
return Object.values(J.db).map(pubJ);
|
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),
|
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),
|
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) });
|
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,
|
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,
|
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 });
|
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 []; } }
|
function parseFlags(s) { try { const v = JSON.parse(s || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } }
|
||||||
async function get(email) {
|
async function get(email) {
|
||||||
const e = norm(email); if (!e) return null;
|
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 (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;
|
if (!J.db) J.load(); return J.db[e] ? pubJ(J.db[e]) : null;
|
||||||
}
|
}
|
||||||
async function upsert(email, fields) {
|
async function upsert(email, fields) {
|
||||||
const e = norm(email); if (!e) return;
|
const e = norm(email); if (!e) return;
|
||||||
if (db.enabled()) {
|
if (db.enabled()) {
|
||||||
const cur = await get(e);
|
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);
|
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)
|
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),
|
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)`,
|
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]);
|
[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;
|
return;
|
||||||
}
|
}
|
||||||
if (!J.db) J.load();
|
if (!J.db) J.load();
|
||||||
J.db[e] = Object.assign({ email: e }, J.db[e] || {}, fields); J.save();
|
J.db[e] = Object.assign({ email: e }, J.db[e] || {}, fields); J.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- sign-up time ----
|
// Has this BROWSER ever had an account? Used to stop the sign-in page telling an existing
|
||||||
async function checkSignup(req, sponsorAccount, cfg) {
|
// member "You're joining the line of @someone" just because they once clicked an invite
|
||||||
const ip = ipOf(req), dev = deviceOf(req); const flags = []; let block = null;
|
// link and the 30-day last-touch cookie is still around (Marty, 2026-09-17).
|
||||||
const rows = await all(); const now = Date.now();
|
async function hasAccountOnDevice(req) {
|
||||||
const maxPerDay = Math.max(1, Number(cfg && cfg.fraudMaxSignupsPerIpDay) || 2);
|
const dev = deviceOf(req);
|
||||||
const blockDevice = !(cfg && String(cfg.fraudBlockSharedDevice) === 'off');
|
if (!dev) return false;
|
||||||
if (dev) {
|
try { const rows = await all(); return rows.some(r => r.deviceId === dev || r.lastDevice === dev); }
|
||||||
const same = rows.filter(r => r.deviceId === dev || r.lastDevice === dev);
|
catch (e) { return false; }
|
||||||
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) {
|
// ---- sign-up time ----
|
||||||
const burst = rows.filter(r => r.signupIp === ip && now - (r.signupAt || 0) < 86400000);
|
async function checkSignup(req, sponsorAccount, cfg) {
|
||||||
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 ip = ipOf(req), dev = deviceOf(req); const flags = []; let block = null;
|
||||||
const shared = rows.filter(r => (r.signupIp === ip || r.lastIp === ip) && now - Math.max(r.signupAt || 0, r.lastAt || 0) < 30 * 86400000);
|
const rows = await all(); const now = Date.now();
|
||||||
if (shared.length) flags.push('shared-ip');
|
const maxPerDay = Math.max(1, Number(cfg && cfg.fraudMaxSignupsPerIpDay) || 2);
|
||||||
}
|
const blockDevice = !(cfg && String(cfg.fraudBlockSharedDevice) === 'off');
|
||||||
if (sponsorAccount && sponsorAccount.email) {
|
if (dev) {
|
||||||
const sp = await get(sponsorAccount.email);
|
const same = rows.filter(r => r.deviceId === dev || r.lastDevice === dev);
|
||||||
if (sp) {
|
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 (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');
|
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.'; }
|
||||||
return { block, flags: [...new Set(flags)], ip, device: dev };
|
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');
|
||||||
async function recordSignup(email, req, flags) {
|
}
|
||||||
const now = Date.now();
|
if (sponsorAccount && sponsorAccount.email) {
|
||||||
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 || [] });
|
const sp = await get(sponsorAccount.email);
|
||||||
if ((flags || []).some(f => HARD.has(f))) flaggedSet.add(norm(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; }
|
||||||
async function recordSeen(email, req) {
|
if (ip && (sp.signupIp === ip || sp.lastIp === ip)) flags.push('sponsor-ip');
|
||||||
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();
|
return { block, flags: [...new Set(flags)], ip, device: dev };
|
||||||
const cur = await get(e);
|
}
|
||||||
const fields = { lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || (cur ? cur.lastDevice : null), lastAt: Date.now() };
|
async function recordSignup(email, req, flags) {
|
||||||
if (!cur) Object.assign(fields, { signupIp: null, signupUa: null, deviceId: deviceOf(req) || null, signupAt: null, flags: [] });
|
const now = Date.now();
|
||||||
await upsert(e, fields);
|
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));
|
||||||
|
}
|
||||||
// ---- admin ----
|
async function recordSeen(email, req) {
|
||||||
async function addFlags(email, flags) {
|
const e = norm(email); if (!e) return;
|
||||||
const cur = (await get(email)) || { flags: [] };
|
const last = seenAt.get(e) || 0; if (Date.now() - last < 10 * 60 * 1000) return; seenAt.set(e, Date.now());
|
||||||
const merged = [...new Set([...(cur.flags || []), ...(flags || [])])];
|
if (seenAt.size > 20000) seenAt.clear();
|
||||||
await upsert(email, { flags: merged });
|
const cur = await get(e);
|
||||||
if (merged.some(f => HARD.has(f))) flaggedSet.add(norm(email)); else flaggedSet.delete(norm(email));
|
const fields = { lastIp: ipOf(req), lastUa: uaOf(req), lastDevice: deviceOf(req) || (cur ? cur.lastDevice : null), lastAt: Date.now() };
|
||||||
return merged;
|
if (!cur) Object.assign(fields, { signupIp: null, signupUa: null, deviceId: deviceOf(req) || null, signupAt: null, flags: [] });
|
||||||
}
|
await upsert(e, fields);
|
||||||
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)); }
|
// ---- admin ----
|
||||||
function isSuspended(email) { return suspendedSet.has(norm(email)); }
|
async function addFlags(email, flags) {
|
||||||
function excluded(email) { const e = norm(email); return suspendedSet.has(e) || flaggedSet.has(e); } // leaderboard / adoption
|
const cur = (await get(email)) || { flags: [] };
|
||||||
async function report() {
|
const merged = [...new Set([...(cur.flags || []), ...(flags || [])])];
|
||||||
const rows = await all();
|
await upsert(email, { flags: merged });
|
||||||
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)] })); };
|
if (merged.some(f => HARD.has(f))) flaggedSet.add(norm(email)); else flaggedSet.delete(norm(email));
|
||||||
const byDevice = groups('deviceId').concat(groups('lastDevice'));
|
return merged;
|
||||||
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; }); };
|
async function clearFlags(email) { await upsert(email, { flags: [] }); flaggedSet.delete(norm(email)); }
|
||||||
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 })),
|
async function suspend(email, reason) { await upsert(email, { suspended: true, suspendedReason: String(reason || '').slice(0, 200), suspendedAt: Date.now() }); suspendedSet.add(norm(email)); }
|
||||||
suspended: rows.filter(r => r.suspended).map(r => ({ email: r.email, reason: r.suspendedReason, at: r.suspendedAt })),
|
async function unsuspend(email) { await upsert(email, { suspended: false, suspendedReason: null, suspendedAt: null }); suspendedSet.delete(norm(email)); }
|
||||||
sharedDevice: dedupe(byDevice), sharedIp: dedupe(byIp), total: rows.length };
|
function isSuspended(email) { return suspendedSet.has(norm(email)); }
|
||||||
}
|
function excluded(email) { const e = norm(email); return suspendedSet.has(e) || flaggedSet.has(e); } // leaderboard / adoption
|
||||||
function mask(e) { return String(e || '').replace(/^(.{2}).*(@.*)$/, '$1***$2'); }
|
async function report() {
|
||||||
|
const rows = await all();
|
||||||
module.exports = { init, checkSignup, recordSignup, recordSeen, addFlags, clearFlags, suspend, unsuspend, isSuspended, excluded, report, get, deviceOf, newDeviceId, deviceCookie, ipOf, HARD };
|
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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1184,7 +1184,17 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (!a && /^\d+$/.test(nameTok)) a = await accounts.byMemberId(Number(nameTok));
|
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) {} }
|
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') {
|
if (p === '/api/stats' && req.method === 'GET') {
|
||||||
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
|
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user