Sign-up deny list beside the allow list

fraud-allow.json says "never block this person". There was nothing for the opposite case.
fraud-block.json: an address on it cannot open an account through /api/signup or the
email-code door, and the admin fraud report carries the list with a matching
/api/admin/fraud/block route. Existing accounts are untouched; Suspend covers those.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 05:46:04 -05:00
parent b061053ccf
commit 39572c0ff5
2 changed files with 25 additions and 2 deletions
+17 -1
View File
@@ -55,6 +55,22 @@ function allowAdd(email, note, by) {
}
function allowRemove(email) { const e = norm(email); const d = allowLoad(); if (d[e]) { delete d[e]; allowSave(); } return { ok: true, list: allowList() }; }
// The opposite list. An address here can never open an account, through any door. Same
// shape and file pattern as the allow list so it behaves identically in DB and JSON mode.
const BLOCK_FILE = () => path.join(DATA_DIR, 'fraud-block.json');
let blockDb = null; // { "<email>": { note, at, by } }
function blockLoad() { if (blockDb) return blockDb; try { blockDb = JSON.parse(fs.readFileSync(BLOCK_FILE(), 'utf8')); } catch (e) { blockDb = {}; } if (!blockDb || typeof blockDb !== 'object') blockDb = {}; return blockDb; }
function blockSave() { try { fs.writeFileSync(BLOCK_FILE(), JSON.stringify(blockLoad())); } catch (e) { console.error('fraud block save', e.message); } }
function isBlocked(email) { const e = norm(email); return !!(e && blockLoad()[e]); }
function blockList() { const d = blockLoad(); return Object.keys(d).sort().map(e => ({ email: e, note: d[e].note || '', at: d[e].at || 0, by: d[e].by || '' })); }
function blockAdd(email, note, by) {
const e = norm(email); if (!e || !e.includes('@')) return { error: 'Enter an email address.' };
blockLoad()[e] = { note: String(note || '').slice(0, 200), at: Date.now(), by: String(by || '').slice(0, 120) };
blockSave();
return { ok: true, list: blockList() };
}
function blockRemove(email) { const e = norm(email); const d = blockLoad(); if (d[e]) { delete d[e]; blockSave(); } return { ok: true, list: blockList() }; }
async function init(opts) {
DATA_DIR = opts.dataDir;
if (db.enabled()) {
@@ -199,4 +215,4 @@ 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,
isAllowed, allowList, allowAdd, allowRemove };
isAllowed, allowList, allowAdd, allowRemove, isBlocked, blockList, blockAdd, blockRemove };
+8 -1
View File
@@ -1214,6 +1214,7 @@ const server = http.createServer(async (req, res) => {
// out only at purchase / payout-activation time and gets linked then)
if (p === '/api/signup' && req.method === 'POST') {
const b = await readBody(req);
if (fraud.isBlocked(b.email)) return json(res, 403, { error: 'This address cannot open an account.' });
const ref = parseCookies(req)['iap.sponsor'] || ''; // last-touch attribution, locked at account creation
const r = await accounts.signup(b.email, b.password, ref);
if (r.error) return json(res, 400, r);
@@ -1239,6 +1240,7 @@ const server = http.createServer(async (req, res) => {
const e = String(b.email || '').trim().toLowerCase();
const devHdr = fraud.deviceOf(req) ? undefined : { 'Set-Cookie': fraud.deviceCookie(fraud.newDeviceId(), IS_PROD) }; // browser id for one-account-per-person checks
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
if (fraud.isBlocked(e)) return json(res, 403, { error: 'This address cannot open an account.' });
const prev = emailCodes.get(e);
if (prev && Date.now() < prev.nextAt) { console.log('signup-guard cooldown', clientIp(req), e.replace(/^(.).*(@.*)$/, '$1***$2')); return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' }, devHdr); }
const guard = codeGuard(req, b); // honeypot, form age, per-IP + global limits, icon check once limited
@@ -2817,11 +2819,16 @@ 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, Object.assign(await fraud.report(), { allow: fraud.allowList() }));
return json(res, 200, Object.assign(await fraud.report(), { allow: fraud.allowList(), block: fraud.blockList() }));
}
// 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/block' && req.method === 'POST') {
const b = await readBody(req);
const r = b.remove ? fraud.blockRemove(b.email) : fraud.blockAdd(b.email, b.note, ADMIN_EMAIL || 'admin');
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/fraud/allow' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);