Anti-fraud: one account per person enforced at sign-up (device cookie + IP), flags, admin duplicate signals, suspend switch

Marty, 2026-09-16, after @megamol created megamol2/megamol3 under his own link and bought $20 on each
to fake his two qualifying buyers. fraud.js records sign-up IP/UA/browser id (iap.dev cookie set with
the code request) and last-seen on sign-in. New accounts: dup-device (browser already has an account)
and sponsor-device are refused, ip-burst (> fraudMaxSignupsPerIpDay, default 2, per 24h) is refused;
sponsor-ip and shared-ip are flagged only. Flagged/suspended accounts never count on the leaderboard
and cannot adopt from the tank; suspended accounts are signed out everywhere (auth.fromRequest
wrapper) and refused at sign-in. Admin > Members: Duplicate signals card (shared browser / IP,
flagged, suspended), flags badge, Suspend/Unsuspend; GET /api/admin/fraud; PATCH members {suspend,
reason, flags}. Telegram admin alert on every block/flag. Privacy page + chatbot prompt updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-16 14:39:13 -05:00
parent 125be174e3
commit 34c62b9d3a
7 changed files with 224 additions and 9 deletions
+22 -3
View File
@@ -276,7 +276,7 @@
} catch (e) {}
}
async function loadMembers() {
loadTank();
loadTank(); loadFraud();
const r = await api('/api/admin/members');
allMembers = r.members || [];
drawMembers();
@@ -290,10 +290,29 @@
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + (a.sponsorName ? esc(a.sponsorName) + (a.sponsorVia === 'code' ? ' <span class="muted small" title="joined through this share code">via code ' + esc(a.sponsorRef) + '</span>' : a.sponsorVia === 'member #' ? ' <span class="muted small">via #' + esc(a.sponsorRef) + '</span>' : '') : a.sponsorRef ? '<span class="badge amber" title="this token points at nobody; the member will move to the holding tank">dead link: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none</span>') + '</td><td class="small" title="linked Qualified Start positions' + (a.positionIds && a.positionIds.length ? ': #' + a.positionIds.join(', #') : '') + '">' + (a.positions ? a.positions : '<span class="muted">0</span>') + '</td><td class="small muted">' + esc(a.joinedVia || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
+ '<td class="small muted when">' + when(a.created) + '</td>'
+ '<td class="act"><button class="btn small sec" data-mcopen="' + esc(a.email) + '">Open</button> <button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
+ '<td class="small muted when">' + when(a.created) + (a.suspended ? ' <span class="badge bad" title="suspended">suspended</span>' : '') + ((a.flags || []).length ? ' <span class="badge amber" title="' + esc((a.flags || []).join(', ')) + '">' + esc((a.flags || []).join(' ')) + '</span>' : '') + '</td>'
+ '<td class="act"><button class="btn small sec" data-mcopen="' + esc(a.email) + '">Open</button> <button class="btn small sec" data-susp="' + esc(a.email) + '" data-on="' + (a.suspended ? '1' : '0') + '">' + (a.suspended ? 'Unsuspend' : 'Suspend') + '</button> <button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
}
$('memFilter').addEventListener('input', drawMembers);
document.addEventListener('click', async e => {
const b = e.target.closest('[data-susp]'); if (!b) return;
const on = b.dataset.on === '1';
if (on) { if (!await IAP.confirmBox('Unsuspend ' + b.dataset.susp + '? They can sign in again.', { title: 'Unsuspend', ok: 'Unsuspend', cancel: 'Cancel' })) return; await api('/api/admin/members', { email: b.dataset.susp, suspend: false }, 'PATCH'); }
else { const why = await IAP.ask({ title: 'Suspend ' + b.dataset.susp, text: 'They will be signed out everywhere and cannot sign in. Reason (shown to admins only):', value: 'duplicate account', ok: 'Suspend' }); if (why === null || why === undefined) return; await api('/api/admin/members', { email: b.dataset.susp, suspend: true, reason: why, flags: ['multi-account'] }, 'PATCH'); }
loadMembers();
});
async function loadFraud() {
const box = $('fraudBox'); if (!box) return;
try {
const r = await api('/api/admin/fraud');
const grp = (title, list) => list.length ? '<p class="small" style="margin:6px 0 2px"><b>' + title + '</b></p>' + list.map(g => '<div class="small">' + esc(g.key) + ': ' + g.emails.map(x => esc(x)).join(', ') + '</div>').join('') : '';
box.innerHTML = '<h4 style="margin:0 0 4px">Duplicate signals</h4><p class="small muted" style="margin:0 0 6px">' + r.total + ' accounts with sign-in signals recorded (since 2026-09-16). Shared browser = same device cookie; shared IP within 30 days. Households are legal; two buying accounts on one browser are not.</p>'
+ 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>' : '');
} catch (e) { box.innerHTML = '<p class="small bad">' + esc(e.message) + '</p>'; }
}
document.addEventListener('click', async e => {
const b = e.target.closest('[data-spon]'); if (!b) return;
const v = await IAP.ask({ title: 'Sponsor for ' + b.dataset.spon, text: 'Username, share code, or member #. Leave blank to clear.', value: b.dataset.cur, ok: 'Save' });