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:
martbost
2026-09-17 08:41:46 -05:00
parent f27388a322
commit 2dfccf0039
3 changed files with 244 additions and 153 deletions
+162 -152
View File
@@ -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 };