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
+3 -2
View File
@@ -263,7 +263,8 @@
</div>
<div class="card">
<div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div>
<p style="margin:0 0 10px"><input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p>
<p style="margin:0 0 10px"><div class="card" id="fraudBox" style="margin-bottom:12px"><p class="small muted">Loading duplicate signals…</p></div>
<input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p>
<p class="small muted">Sponsor = the token the account joined under (username, share code, or member #). Editing it re-points free referrals and future purchases. On-chain sponsorship is permanent once activated.</p>
<div class="tablewrap"><table class="adm-table" id="memTable"></table></div>
</div>
@@ -487,6 +488,6 @@
</div>
<script src="/assets/common.js?v=20260916a"></script>
<script src="/assets/admin.js?v=20260916b"></script>
<script src="/assets/admin.js?v=20260916c"></script>
</body>
</html>
+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' });
+2 -1
View File
@@ -19,7 +19,8 @@
<h3>How we use it</h3>
<p class="muted small">To run your account, deliver and measure ads, attribute referrals, send service and notification emails (payouts, messages, onboarding), and keep the Platform secure. You can set your email and chat notification preferences in your dashboard.</p>
<h3>Cookies</h3>
<p class="muted small">We use a session cookie to keep you signed in and a referral cookie to credit the sponsor whose link you arrived through. That is it. No third-party ad-tracking cookies.</p>
<p class="muted small">We use a session cookie to keep you signed in, a referral cookie to credit the sponsor whose link you arrived through, and a browser identifier cookie used only to enforce one account per person. No third-party ad-tracking cookies.</p>
<p class="muted small"><strong>Abuse prevention.</strong> When you create an account and when you sign in we record your IP address, browser type and the browser identifier. We use them to detect duplicate accounts and self-referral, which the Terms prohibit, and for nothing else. They are visible to the site administrator only and are not sold or shared.</p>
<h3>Sharing</h3>
<p class="muted small">Your username, public profile, and public wall are visible to others by design, and on-chain transactions are public by nature. We share data with infrastructure providers (hosting, email delivery) only as needed to operate the service, and when required by law.</p>
<h3>Your choices</h3>