Approved exceptions: people you have okayed to hold more than one account

Marty approves specific people for multiple accounts (partners, staff, a spouse on a
shared machine) and needed a way to say so without the guard fighting him.

Admin > Members > Duplicate signals now carries an "Approved exceptions" list: add an
email with a note, see who is on it and when, remove one. It sits directly under the
signals so the two are read together.

An exception can be added against the address they ALREADY have, not just the new one.
That matters because the usual case is approving a person before their second address
exists, and at sign-up time the new address is unknown to us. checkSignup now tracks
every account the sign-up collided with, and clears the block if either side is approved.

Clearing the HARD flags matters as much as clearing the block. Those flags are what
silently drop an account off the leaderboard and bar it from adopting out of the holding
tank, so an approved person would have been "allowed" in name only. They now keep both.
The account is tagged 'allowlisted' instead, so the admin sees why it went through, and
the server logs the exception by name.

Suspension still wins. An exception is permission to hold several accounts, not immunity
from being suspended for something else.

qa/fraud-allow.mjs (12 assertions) boots its own server and walks the real flow: first
account created, second blocked with the Qualified Start redirect, exception added,
second account now created, no hard flag left on it, exception visible in the admin
report, removing it blocks again, malformed address refused, endpoint admin-only.
qa/sponsor-note.mjs 5, qa/run.sh member 0 bugs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-17 10:42:31 -05:00
parent 2dfccf0039
commit e95f9df13a
5 changed files with 4364 additions and 4188 deletions
+45 -5
View File
@@ -34,6 +34,27 @@ function deviceOf(req) { const d = cookies(req)['iap.dev'] || ''; return /^[a-f0
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' : ''); }
// ---- exception list ----
// Marty approves specific people to hold more than one account (partners, staff, a spouse
// on a shared machine). Their sign-ups must not be blocked, must not carry a hard flag,
// and must not be quietly dropped from the leaderboard or barred from adopting. Kept in
// its own small file so it works the same in DB mode and JSON mode, and so an allow entry
// can be added BEFORE the second account exists.
const ALLOW_FILE = () => path.join(DATA_DIR, 'fraud-allow.json');
let allowDb = null; // { "<email>": { note, at, by } }
function allowLoad() { if (allowDb) return allowDb; try { allowDb = JSON.parse(fs.readFileSync(ALLOW_FILE(), 'utf8')); } catch (e) { allowDb = {}; } if (!allowDb || typeof allowDb !== 'object') allowDb = {}; return allowDb; }
function allowSave() { try { fs.writeFileSync(ALLOW_FILE(), JSON.stringify(allowLoad())); } catch (e) { console.error('fraud allow save', e.message); } }
function isAllowed(email) { const e = norm(email); return !!(e && allowLoad()[e]); }
function allowList() { const d = allowLoad(); return Object.keys(d).sort().map(e => ({ email: e, note: d[e].note || '', at: d[e].at || 0, by: d[e].by || '' })); }
function allowAdd(email, note, by) {
const e = norm(email);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return { error: 'That email address does not look right.' };
allowLoad()[e] = { note: String(note || '').slice(0, 200), at: Date.now(), by: String(by || '').slice(0, 120) };
allowSave();
return { ok: true, list: allowList() };
}
function allowRemove(email) { const e = norm(email); const d = allowLoad(); if (d[e]) { delete d[e]; allowSave(); } return { ok: true, list: allowList() }; }
async function init(opts) {
DATA_DIR = opts.dataDir;
if (db.enabled()) {
@@ -95,14 +116,16 @@ async function hasAccountOnDevice(req) {
}
// ---- sign-up time ----
async function checkSignup(req, sponsorAccount, cfg) {
async function checkSignup(req, sponsorAccount, cfg, email) {
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');
// Everyone this sign-up collides with, so an approved exception can clear it below.
const collided = [];
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 (same.length) { collided.push(...same.map(r => r.email)); flags.push('dup-device'); if (blockDevice) block = 'This browser already has an InstantAdPay account (' + mask(same[0].email) + '), and it is one account per person.' + QS + ' Shared computer at work or a library? Contact support.'; }
}
if (ip) {
const burst = rows.filter(r => r.signupIp === ip && now - (r.signupAt || 0) < 86400000);
@@ -113,10 +136,24 @@ async function checkSignup(req, sponsorAccount, cfg) {
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 (dev && (sp.deviceId === dev || sp.lastDevice === dev)) { collided.push(sp.email); flags.push('sponsor-device'); if (blockDevice) block = block || 'The invite link you used belongs to an account on this same browser, so this would be a second account for the same person, which the Terms do not allow.' + QS; }
if (ip && (sp.signupIp === ip || sp.lastIp === ip)) flags.push('sponsor-ip');
}
}
// APPROVED EXCEPTION (Marty, 2026-09-17). Allow either the address signing up, or any
// account it collided with: whitelisting somebody's existing account is the usual case,
// because their second address is not known yet. Clearing the HARD flags matters as much
// as clearing the block, since those are what drop an account off the leaderboard and
// out of the holding tank. 'allowlisted' is recorded so the admin sees why it went through.
const okEmail = isAllowed(email);
const okOther = collided.some(e => isAllowed(e));
if (okEmail || okOther) {
block = null;
const kept = flags.filter(f => !HARD.has(f));
kept.push('allowlisted');
return { block: null, flags: [...new Set(kept)], ip, device: dev, allowlisted: true,
allowedBy: okEmail ? norm(email) : collided.find(e => isAllowed(e)) };
}
return { block, flags: [...new Set(flags)], ip, device: dev };
}
async function recordSignup(email, req, flags) {
@@ -146,7 +183,9 @@ async function clearFlags(email) { await upsert(email, { flags: [] }); flaggedSe
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
// Suspension still wins: an allow entry is permission to hold several accounts, not
// immunity from being suspended for something else.
function excluded(email) { const e = norm(email); if (suspendedSet.has(e)) return true; if (isAllowed(e)) return false; return flaggedSet.has(e); } // leaderboard / adoption
async function report() {
const rows = await all();
const groups = (key) => { const m = new Map(); for (const r of rows) { const k = r[key]; if (!k) continue; if (!m.has(k)) m.set(k, []); m.get(k).push(r.email); } return [...m.entries()].filter(([, v]) => new Set(v).size > 1).map(([k, v]) => ({ key: k, emails: [...new Set(v)] })); };
@@ -159,4 +198,5 @@ async function report() {
}
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 };
module.exports = { init, checkSignup, recordSignup, recordSeen, addFlags, clearFlags, suspend, unsuspend, isSuspended, excluded, report, get, deviceOf, hasAccountOnDevice, newDeviceId, deviceCookie, ipOf, HARD,
isAllowed, allowList, allowAdd, allowRemove };
+1 -1
View File
@@ -488,6 +488,6 @@
</div>
<script src="/assets/common.js?v=20260916a"></script>
<script src="/assets/admin.js?v=20260917a"></script>
<script src="/assets/admin.js?v=20260917b"></script>
</body>
</html>
+35 -1
View File
@@ -310,7 +310,41 @@
+ grp('Shared browser', r.sharedDevice) + grp('Shared IP', r.sharedIp)
+ (r.flagged.length ? '<p class="small" style="margin:6px 0 2px"><b>Flagged</b></p>' + r.flagged.map(f => '<div class="small">' + esc(f.email) + ' [' + esc(f.flags.join(', ')) + ']' + (f.suspended ? ' suspended' : '') + '</div>').join('') : '')
+ (r.suspended.length ? '<p class="small" style="margin:6px 0 2px"><b>Suspended</b></p>' + r.suspended.map(x => '<div class="small">' + esc(x.email) + ' (' + esc(x.reason || '') + ', ' + when(x.at) + ')</div>').join('') : '')
+ (!r.sharedDevice.length && !r.sharedIp.length && !r.flagged.length && !r.suspended.length ? '<p class="small muted">Nothing shared or flagged yet.</p>' : '');
+ (!r.sharedDevice.length && !r.sharedIp.length && !r.flagged.length && !r.suspended.length ? '<p class="small muted">Nothing shared or flagged yet.</p>' : '')
// Approved exceptions (Marty, 2026-09-17): people okayed to hold more than one
// account. Lives right under the signals so the two are read together.
+ '<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px">'
+ '<h4 style="margin:0 0 4px">Approved exceptions</h4>'
+ '<p class="small muted" style="margin:0 0 8px">People you have okayed to hold more than one account. They are never blocked at sign-up, never hard-flagged, and keep their leaderboard and adoption rights. Add the address of the account they ALREADY have, or the new one, either works. Suspension still overrides this.</p>'
+ '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px">'
+ '<input id="allowEmail" type="email" placeholder="them@example.com" style="min-width:230px">'
+ '<input id="allowNote" type="text" placeholder="Why (shown only here)" maxlength="200" style="min-width:230px;flex:1">'
+ '<button id="allowAdd" class="btn">Add exception</button></div>'
+ '<div id="allowMsg" class="small" style="margin-bottom:8px"></div>'
+ ((r.allow || []).length
? (r.allow || []).map(a => '<div class="small" style="display:flex;gap:8px;align-items:baseline;padding:3px 0">'
+ '<b>' + esc(a.email) + '</b>'
+ (a.note ? '<span class="muted">' + esc(a.note) + '</span>' : '')
+ '<span class="muted">' + when(a.at) + '</span>'
+ '<a href="#" data-allow-rm="' + esc(a.email) + '" style="margin-left:auto">remove</a></div>').join('')
: '<p class="small muted">No exceptions yet. Everyone is held to one account per person.</p>')
+ '</div>';
const say = (t, bad) => { const m = $('allowMsg'); if (m) { m.textContent = t; m.style.color = bad ? 'var(--bad)' : 'var(--mint)'; } };
const addBtn = $('allowAdd');
if (addBtn) addBtn.addEventListener('click', async () => {
const em = ($('allowEmail').value || '').trim(); const note = ($('allowNote').value || '').trim();
if (!em) return say('Enter an email address.', true);
addBtn.disabled = true; say('Saving…');
try { await api('/api/admin/fraud/allow', { email: em, note }); say('Added.'); loadFraud(); }
catch (err) { say(err.message, true); addBtn.disabled = false; }
});
box.querySelectorAll('[data-allow-rm]').forEach(a => a.addEventListener('click', async ev => {
ev.preventDefault();
const em = a.getAttribute('data-allow-rm');
if (!(await IAP.confirmBox(em + ' goes back to the normal one-account-per-person checks. Existing accounts are not touched.', { title: 'Remove exception', ok: 'Remove', cancel: 'Cancel' }))) return;
try { await api('/api/admin/fraud/allow', { email: em, remove: true }); loadFraud(); }
catch (err) { say(err.message, true); }
}));
} catch (e) { box.innerHTML = '<p class="small bad">' + esc(e.message) + '</p>'; }
}
document.addEventListener('click', async e => {
+88
View File
@@ -0,0 +1,88 @@
// Approved exceptions: people Marty has okayed to hold more than one account.
//
// The guard blocks a second sign-up from a browser that already has an account. For an
// approved person it must let them through, must NOT leave a hard flag (that is what
// silently drops an account off the leaderboard and out of the holding tank), and must
// still respect suspension.
//
// node qa/fraud-allow.mjs
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const PORT = 8801;
const B = 'http://127.0.0.1:' + PORT;
const DATA = path.join(os.tmpdir(), 'iap-fraud-allow-' + Date.now());
fs.mkdirSync(DATA, { recursive: true });
const ok = [], bad = [];
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
const srv = spawn(process.execPath, ['server.js'], {
env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, ADMIN_PASSWORD: 'localtest', ADMIN_EMAIL: 'admin@example.com', NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe']
});
const bye = c => { try { srv.kill(); } catch (e) {} try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {} process.exit(c); };
for (let i = 0; i < 60; i++) { try { if ((await fetch(B + '/api/stats')).ok) break; } catch (e) {} await new Promise(r => setTimeout(r, 500)); }
const DEV = 'dededededededededededededededede';
const post = (p, body, hdrs) => fetch(B + p, { method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, hdrs || {}), body: JSON.stringify(body) })
.then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
// isAdmin() accepts a bearer token, which is far simpler here than a portal session
const ADMIN = { Authorization: 'Bearer localtest' };
// create the first account on this browser
// NOTE: the duplicate checks run at VERIFY, not at start. Starting a code is harmless;
// creating the account is the act that gets blocked.
const signUp = async (email) => {
const s = await post('/api/auth/email/start', { email, fts: Date.now() - 20000 }, { Cookie: 'iap.dev=' + DEV });
if (s.status !== 200 || !s.body.devCode) return s;
return await post('/api/auth/email/verify', { email, code: s.body.devCode }, { Cookie: 'iap.dev=' + DEV });
};
const first = await signUp('primary@example.com');
t('the first account on a browser is created', first.status === 200 && !first.body.error, JSON.stringify(first.body).slice(0, 140));
// a SECOND account on the same browser is blocked, as designed
const blocked = await signUp('second@example.com');
t('a second account on the same browser is blocked by default', blocked.status === 403, blocked.status + ' ' + JSON.stringify(blocked.body).slice(0, 120));
t('and the refusal points at Qualified Start', /Qualified Start/i.test(String(blocked.body.error || '')), String(blocked.body.error || '').slice(0, 90));
// admin approves the exception on the EXISTING account, which is the usual case
const added = await post('/api/admin/fraud/allow', { email: 'primary@example.com', note: 'business partner, approved' }, ADMIN);
t('the exception is accepted', added.status === 200 && Array.isArray(added.body.list), JSON.stringify(added.body).slice(0, 140));
t('and it is listed with its note', (added.body.list || []).some(a => a.email === 'primary@example.com' && /partner/.test(a.note)), JSON.stringify(added.body.list));
// now the same second sign-up goes through
const allowed = await signUp('second@example.com');
t('the approved person can now create the second account', allowed.status === 200 && !allowed.body.error,
allowed.status + ' ' + JSON.stringify(allowed.body).slice(0, 140));
// and it must not be left carrying a hard flag
const rep = await fetch(B + '/api/admin/fraud', { headers: ADMIN }).then(r => r.json());
// Only the HARD flags matter: those are what drop an account off the leaderboard and out
// of the holding tank. 'shared-ip' is informational and carries no penalty, so an approved
// person may legitimately still show it.
const HARD = ['dup-device', 'ip-burst', 'sponsor-device', 'sponsor-ip', 'multi-account'];
const row = (rep.flagged || []).find(f => f.email === 'second@example.com');
const hard = ((row && row.flags) || []).filter(f => HARD.includes(f));
t('the new account carries NO hard flag', hard.length === 0, JSON.stringify(row || {}));
t('and it is marked as allowlisted so the admin sees why', !row || (row.flags || []).includes('allowlisted'), JSON.stringify(row || {}));
t('the exception shows in the admin report', (rep.allow || []).some(a => a.email === 'primary@example.com'), JSON.stringify(rep.allow));
// removing the exception restores the normal rule
await post('/api/admin/fraud/allow', { email: 'primary@example.com', remove: true }, ADMIN);
const reBlocked = await signUp('third@example.com');
t('removing the exception blocks again', reBlocked.status === 403, reBlocked.status + ' ' + JSON.stringify(reBlocked.body).slice(0, 110));
// a bad address is rejected rather than stored
const badAdd = await post('/api/admin/fraud/allow', { email: 'not-an-email', note: '' }, ADMIN);
t('a malformed address is refused', badAdd.status === 400, JSON.stringify(badAdd.body).slice(0, 100));
// and the endpoint is admin-only
const noAuth = await post('/api/admin/fraud/allow', { email: 'x@example.com' });
t('the endpoint requires admin', noAuth.status === 401, String(noAuth.status));
console.log('PASS ' + ok.length);
for (const b of bad) console.log('FAIL ' + b);
bye(bad.length ? 1 : 0);
+16 -2
View File
@@ -1272,8 +1272,11 @@ const server = http.createServer(async (req, res) => {
const existing = await accounts.byEmail(e);
if (!existing) { // anti-fraud checks apply to NEW accounts only (Marty, 2026-09-16: one account per person)
let spAcct = null; if (ref) { try { spAcct = await accounts.byCode(String(ref).toLowerCase()); if (!spAcct) spAcct = await accounts.byUsername(String(ref).toLowerCase()); } catch (err) {} }
const fc = await fraud.checkSignup(req, spAcct, siteConfig());
const fc = await fraud.checkSignup(req, spAcct, siteConfig(), e);
fraudFlags = fc.flags;
// an approved exception is worth a log line: it is the difference between "the guard
// is broken" and "Marty said yes to this person"
if (fc.allowlisted) console.log('signup allowed by exception list', e.replace(/^(.).*(@.*)$/, '$1***$2'), 'via', fc.allowedBy);
if (fc.block) { console.log('signup blocked', fc.flags.join(','), clientIp(req), e.replace(/^(.).*(@.*)$/, '$1***$2')); fraudAlert(e, fc, spAcct, true); return json(res, 403, { error: fc.block }); }
}
const r = await accounts.ensure(e, ref, via, joinedRef); // first touch wins; existing accounts unchanged
@@ -2810,7 +2813,18 @@ const server = http.createServer(async (req, res) => {
}
if (p === '/api/admin/fraud' && req.method === 'GET') { // duplicate signals: shared browsers / IPs, flagged and suspended accounts
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await fraud.report());
return json(res, 200, Object.assign(await fraud.report(), { allow: fraud.allowList() }));
}
// Approved exceptions: people Marty has okayed to hold more than one account. Adding an
// address here stops the duplicate checks blocking or hard-flagging them, and stops them
// being dropped from the leaderboard or barred from adopting.
if (p === '/api/admin/fraud/allow' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = b.remove ? fraud.allowRemove(b.email) : fraud.allowAdd(b.email, b.note, ADMIN_EMAIL || 'admin');
if (r.error) return json(res, 400, r);
console.log('fraud exception ' + (b.remove ? 'removed' : 'added') + ': ' + String(b.email || '').replace(/^(.).*(@.*)$/, '$1***$2'));
return json(res, 200, r);
}
if (p === '/api/admin/campaigns' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });