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:
@@ -98,6 +98,7 @@ FACTS:
|
|||||||
- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward), but NOT someone they adopted less than 3 days ago: an adoption is a commitment, and a dropped adoption still counts toward that person's two-adoption lifetime limit. Releases are posted to the feed and Telegram like pickups. Admin sees the tank under Members.
|
- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward), but NOT someone they adopted less than 3 days ago: an adoption is a commitment, and a dropped adoption still counts toward that person's two-adoption lifetime limit. Releases are posted to the feed and Telegram like pickups. Admin sees the tank under Members.
|
||||||
- DAILY CLAIM STREAK: finishing the daily ad set and claiming pays 5 credits on day 1, 7 on day 2, 10 from day 3, and 25 on every 7th consecutive day; miss a day and it restarts. After the set, verified visits (up to 20 a day, 1 credit each) keep earning, and the credits are meant to be spent on a campaign.
|
- DAILY CLAIM STREAK: finishing the daily ad set and claiming pays 5 credits on day 1, 7 on day 2, 10 from day 3, and 25 on every 7th consecutive day; miss a day and it restarts. After the set, verified visits (up to 20 a day, 1 credit each) keep earning, and the credits are meant to be spent on a campaign.
|
||||||
- FOUR-MINUTE OVERVIEW VIDEO (2026-09-16): 'InstantAdPay in four minutes' is the first card on Members > Training (group 'Start here') and embedded on the home page at instantadpay.com/#overview-video (hero button 'Watch the 4-minute overview'). Send it to anyone asking what the platform is; the tab-by-tab walkthrough videos on Training go deeper. It is evergreen: no dates, no launch talk.
|
- FOUR-MINUTE OVERVIEW VIDEO (2026-09-16): 'InstantAdPay in four minutes' is the first card on Members > Training (group 'Start here') and embedded on the home page at instantadpay.com/#overview-video (hero button 'Watch the 4-minute overview'). Send it to anyone asking what the platform is; the tab-by-tab walkthrough videos on Training go deeper. It is evergreen: no dates, no launch talk.
|
||||||
|
- ANTI-FRAUD CHECKS (2026-09-16): at sign-up the site records IP, browser type and a browser cookie id. A second account from a browser that already has one is refused, so is a burst of new accounts from one connection in a day, and so is a sign-up through an invite link whose owner used that same browser. A sign-up from the same IP as the sponsor is allowed but flagged (households are fine). Flagged accounts never count on the leaderboard and cannot adopt from the holding tank; a suspended account cannot sign in. If a member says they were refused at sign-up: one account per person, sign in to the existing one, or contact support if it is a shared computer at work or a library.
|
||||||
- ONE ACCOUNT PER PERSON (Terms section 3, 2026-09-16): a person may hold exactly one account; second or duplicate accounts under any email, name or wallet, and self-referral through another account, are prohibited. The only sanctioned way to hold more than one position is Qualified Start (extra wallets linked inside the one account). Duplicates may be merged, suspended or closed and credits, prizes and contest rankings earned through them are forfeited; the contract's on-chain payments cannot be reversed. Family members join under the member's link with their own email.
|
- ONE ACCOUNT PER PERSON (Terms section 3, 2026-09-16): a person may hold exactly one account; second or duplicate accounts under any email, name or wallet, and self-referral through another account, are prohibited. The only sanctioned way to hold more than one position is Qualified Start (extra wallets linked inside the one account). Duplicates may be merged, suspended or closed and credits, prizes and contest rankings earned through them are forfeited; the contract's on-chain payments cannot be reversed. Family members join under the member's link with their own email.
|
||||||
- BLOG (public, instantadpay.com/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today.
|
- BLOG (public, instantadpay.com/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today.
|
||||||
- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; members cannot trigger posts themselves (the 'Post to Telegram' button is admin-only); 'Share' opens a picker (X, Facebook, Telegram, WhatsApp, LinkedIn, Massifly (copies the post and opens the feed composer), the phone's own share menu, copy link, save image) that shares the member's public badge page instantadpay.com/b/<username>/<badge>, which shows the badge and their join link.
|
- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; members cannot trigger posts themselves (the 'Post to Telegram' button is admin-only); 'Share' opens a picker (X, Facebook, Telegram, WhatsApp, LinkedIn, Massifly (copies the post and opens the feed composer), the phone's own share menu, copy link, save image) that shares the member's public badge page instantadpay.com/b/<username>/<badge>, which shows the badge and their join link.
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// 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 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) + '). One account per person: sign in to that one instead. If this is a shared computer, 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. One account per person. 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. One account per person, and you cannot refer yourself.'; }
|
||||||
|
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 };
|
||||||
@@ -62,6 +62,7 @@ async function computeRaw(period) {
|
|||||||
const sponsor = map[e.recipientId], buyer = map[e.buyerId];
|
const sponsor = map[e.recipientId], buyer = map[e.buyerId];
|
||||||
if (!sponsor) continue;
|
if (!sponsor) continue;
|
||||||
if (buyer && buyer === sponsor) continue; // own positions never count
|
if (buyer && buyer === sponsor) continue; // own positions never count
|
||||||
|
if (R.fraud && ((buyer && R.fraud.excluded(buyer)) || R.fraud.excluded(sponsor))) continue; // flagged / suspended accounts never count (2026-09-16)
|
||||||
const r = rows[sponsor] = rows[sponsor] || { email: sponsor, name: names[sponsor], sales: 0, cents: 0, pol: 0n, buyers: new Set() };
|
const r = rows[sponsor] = rows[sponsor] || { email: sponsor, name: names[sponsor], sales: 0, cents: 0, pol: 0n, buyers: new Set() };
|
||||||
r.sales += 1; r.cents += price[e.tx + ':' + e.buyerId] || 0; r.pol += BigInt(e.amountWei || 0); if (buyer) r.buyers.add(buyer);
|
r.sales += 1; r.cents += price[e.tx + ':' + e.buyerId] || 0; r.pol += BigInt(e.amountWei || 0); if (buyer) r.buyers.add(buyer);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -263,7 +263,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div>
|
<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>
|
<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 class="tablewrap"><table class="adm-table" id="memTable"></table></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,6 +488,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/common.js?v=20260916a"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+22
-3
@@ -276,7 +276,7 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
async function loadMembers() {
|
async function loadMembers() {
|
||||||
loadTank();
|
loadTank(); loadFraud();
|
||||||
const r = await api('/api/admin/members');
|
const r = await api('/api/admin/members');
|
||||||
allMembers = r.members || [];
|
allMembers = r.members || [];
|
||||||
drawMembers();
|
drawMembers();
|
||||||
@@ -290,10 +290,29 @@
|
|||||||
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
|
+ '<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 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>' + (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="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-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
|
+ '<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);
|
$('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 => {
|
document.addEventListener('click', async e => {
|
||||||
const b = e.target.closest('[data-spon]'); if (!b) return;
|
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' });
|
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
@@ -19,7 +19,8 @@
|
|||||||
<h3>How we use it</h3>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<h3>Your choices</h3>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const drip = require('./drip');
|
|||||||
const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set)
|
const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set)
|
||||||
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
|
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
|
||||||
const chatbot = require('./chatbot');
|
const chatbot = require('./chatbot');
|
||||||
|
const fraud = require('./fraud');
|
||||||
const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
|
const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
|
||||||
const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward
|
const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward
|
||||||
const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
|
const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
|
||||||
@@ -401,6 +402,11 @@ async function boot() {
|
|||||||
snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, send: async (c, t, th) => { await telegramSend(c, t, th); return true; } });
|
snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, send: async (c, t, th) => { await telegramSend(c, t, th); return true; } });
|
||||||
setInterval(() => snapshot.tick().catch(e => console.error('snapshot', e.message)), 10 * 60 * 1000);
|
setInterval(() => snapshot.tick().catch(e => console.error('snapshot', e.message)), 10 * 60 * 1000);
|
||||||
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
|
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
|
||||||
|
await fraud.init({ dataDir: DATA_DIR });
|
||||||
|
{ // a suspended account is signed out everywhere: every member route sees no session
|
||||||
|
const realFrom = auth.fromRequest.bind(auth);
|
||||||
|
auth.fromRequest = async (req) => { const sess = await realFrom(req); if (sess && sess.email && fraud.isSuspended(sess.email)) return null; return sess; };
|
||||||
|
}
|
||||||
legacy.init({ dataDir: DATA_DIR });
|
legacy.init({ dataDir: DATA_DIR });
|
||||||
traffic.init({ dataDir: DATA_DIR });
|
traffic.init({ dataDir: DATA_DIR });
|
||||||
promos.init({ dataDir: DATA_DIR });
|
promos.init({ dataDir: DATA_DIR });
|
||||||
@@ -414,7 +420,7 @@ async function boot() {
|
|||||||
audit.init({ dataDir: DATA_DIR, notify: text => { const sc = siteConfig(); if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {}); else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: counter audit', text).catch(() => {}); } });
|
audit.init({ dataDir: DATA_DIR, notify: text => { const sc = siteConfig(); if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {}); else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: counter audit', text).catch(() => {}); } });
|
||||||
setTimeout(() => audit.dailyTick(), 5 * 60 * 1000); setInterval(() => audit.dailyTick(), 24 * 60 * 60 * 1000);
|
setTimeout(() => audit.dailyTick(), 5 * 60 * 1000); setInterval(() => audit.dailyTick(), 24 * 60 * 60 * 1000);
|
||||||
videomaker.init({ dataDir: DATA_DIR, spaces, accounts });
|
videomaker.init({ dataDir: DATA_DIR, spaces, accounts });
|
||||||
leaderboard.init({ chain, accounts, ads, dataDir: DATA_DIR, siteConfig, pushFeed, adminEmail: ADMIN_EMAIL,
|
leaderboard.init({ chain, accounts, ads, fraud, dataDir: DATA_DIR, siteConfig, pushFeed, adminEmail: ADMIN_EMAIL,
|
||||||
notify: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); if (String(sc.leaderboardAnnounceGeneral || '1') !== '0') await telegramSend(sc.telegramEchoChatId, text, null); } });
|
notify: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); if (String(sc.leaderboardAnnounceGeneral || '1') !== '0') await telegramSend(sc.telegramEchoChatId, text, null); } });
|
||||||
setTimeout(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 90 * 1000);
|
setTimeout(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 90 * 1000);
|
||||||
setInterval(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 60 * 60 * 1000);
|
setInterval(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 60 * 60 * 1000);
|
||||||
@@ -448,6 +454,7 @@ function siteConfig() {
|
|||||||
rehearsal: true, // shows the testnet banner; flipped off at mainnet launch
|
rehearsal: true, // shows the testnet banner; flipped off at mainnet launch
|
||||||
// payment-proof Telegram feed (blank = off) and the P&L pane's fixed monthly cost
|
// payment-proof Telegram feed (blank = off) and the P&L pane's fixed monthly cost
|
||||||
telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/',
|
telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/',
|
||||||
|
fraudMaxSignupsPerIpDay: 2, fraudBlockSharedDevice: 'on', // anti-fraud: accounts per IP per day; block a second account from the same browser
|
||||||
telegramEchoChatId: '', telegramEchoTopicId: '', telegramEchoEvents: 'payouts', // shared cross-program payments topic
|
telegramEchoChatId: '', telegramEchoTopicId: '', telegramEchoEvents: 'payouts', // shared cross-program payments topic
|
||||||
telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL
|
telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL
|
||||||
launchAt: '', // public launch moment, ISO 8601 with offset (e.g. 2026-09-18T19:00:00-05:00): countdown on /launch + dashboard mark
|
launchAt: '', // public launch moment, ISO 8601 with offset (e.g. 2026-09-18T19:00:00-05:00): countdown on /launch + dashboard mark
|
||||||
@@ -690,6 +697,16 @@ async function sponsorGainNudge(sp, buyerName, lostNames) {
|
|||||||
await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html);
|
await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
// anti-fraud admin alert (Telegram admin chat, else email): who, which flags, and whether the sign-up was blocked
|
||||||
|
function fraudAlert(email, fc, spAcct, blocked) {
|
||||||
|
try {
|
||||||
|
const sc = siteConfig();
|
||||||
|
const text = (blocked ? '\u26D4 InstantAdPay sign-up BLOCKED: ' : '\u{1F6A9} InstantAdPay sign-up flagged: ') + String(email).replace(/^(.{2}).*(@.*)$/, '$1***$2')
|
||||||
|
+ ' [' + (fc.flags || []).join(', ') + ']' + (fc.ip ? ' ip ' + fc.ip : '') + (spAcct ? ' sponsor ' + (spAcct.username ? '@' + spAcct.username : spAcct.email) : '') + '. Admin > Members > Duplicate signals.';
|
||||||
|
if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {});
|
||||||
|
else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: sign-up ' + (blocked ? 'blocked' : 'flagged'), text).catch(() => {});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
const sponsorRoutedLast = new Map(); // buyer email -> ts (one alert per buyer per hour)
|
const sponsorRoutedLast = new Map(); // buyer email -> ts (one alert per buyer per hour)
|
||||||
function sponsorRoutedAlert(who, spd, routed, skipped) {
|
function sponsorRoutedAlert(who, spd, routed, skipped) {
|
||||||
const k = String(who || '?'); if (Date.now() - (sponsorRoutedLast.get(k) || 0) < 3600000) return; sponsorRoutedLast.set(k, Date.now());
|
const k = String(who || '?'); if (Date.now() - (sponsorRoutedLast.get(k) || 0) < 3600000) return; sponsorRoutedLast.set(k, Date.now());
|
||||||
@@ -1210,6 +1227,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (p === '/api/auth/email/start' && req.method === 'POST') {
|
if (p === '/api/auth/email/start' && req.method === 'POST') {
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
const e = String(b.email || '').trim().toLowerCase();
|
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 (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
|
||||||
const prev = emailCodes.get(e);
|
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.' }); }
|
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.' }); }
|
||||||
@@ -1222,9 +1240,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
console.error('sendCode failed', err.message);
|
console.error('sendCode failed', err.message);
|
||||||
return json(res, 502, { error: 'Could not send the email. Try again in a minute.' });
|
return json(res, 502, { error: 'Could not send the email. Try again in a minute.' });
|
||||||
}
|
}
|
||||||
return json(res, 200, { ok: true, sent: true });
|
return json(res, 200, { ok: true, sent: true }, devHdr);
|
||||||
}
|
}
|
||||||
if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code });
|
if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code }, devHdr);
|
||||||
return json(res, 503, { error: 'Email sign-in is not configured yet.' });
|
return json(res, 503, { error: 'Email sign-in is not configured yet.' });
|
||||||
}
|
}
|
||||||
if (p === '/api/auth/email/verify' && req.method === 'POST') {
|
if (p === '/api/auth/email/verify' && req.method === 'POST') {
|
||||||
@@ -1239,8 +1257,19 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const ref = parseCookies(req)['iap.sponsor'] || '';
|
const ref = parseCookies(req)['iap.sponsor'] || '';
|
||||||
const via = parseCookies(req)['iap.angle'] || '';
|
const via = parseCookies(req)['iap.angle'] || '';
|
||||||
const joinedRef = decodeURIComponent(parseCookies(req)['iap.ref'] || '') || null;
|
const joinedRef = decodeURIComponent(parseCookies(req)['iap.ref'] || '') || null;
|
||||||
|
if (fraud.isSuspended(e)) return json(res, 403, { error: 'This account is suspended. Contact support.' });
|
||||||
|
let fraudFlags = [];
|
||||||
|
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());
|
||||||
|
fraudFlags = fc.flags;
|
||||||
|
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
|
const r = await accounts.ensure(e, ref, via, joinedRef); // first touch wins; existing accounts unchanged
|
||||||
if (r.error) return json(res, 400, r);
|
if (r.error) return json(res, 400, r);
|
||||||
|
if (r.created) { fraud.recordSignup(e, req, fraudFlags).catch(() => {}); if (fraudFlags.length) fraudAlert(e, { flags: fraudFlags, ip: fraud.ipOf(req) }, null, false); }
|
||||||
|
else fraud.recordSeen(e, req).catch(() => {});
|
||||||
// the lead is in the door: queue the getting-started sequence (opt-in box is pre-checked on both forms)
|
// the lead is in the door: queue the getting-started sequence (opt-in box is pre-checked on both forms)
|
||||||
if (r.created && (b.followups || b.newsletter)) drip.enqueue(e, ref, via).catch(() => {});
|
if (r.created && (b.followups || b.newsletter)) drip.enqueue(e, ref, via).catch(() => {});
|
||||||
// a wallet-only session (signed with a wallet, no account) finishing setup:
|
// a wallet-only session (signed with a wallet, no account) finishing setup:
|
||||||
@@ -1818,6 +1847,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const s = await auth.fromRequest(req);
|
const s = await auth.fromRequest(req);
|
||||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
|
if (fraud.excluded(s.email)) return json(res, 403, { error: 'Adoptions are not available on this account. Contact support.' });
|
||||||
const r = await tank.adopt(s.email, b.who, b.note);
|
const r = await tank.adopt(s.email, b.who, b.note);
|
||||||
if (r.ok) { // tell the payments topic who picked whom up (Marty, 2026-09-12)
|
if (r.ok) { // tell the payments topic who picked whom up (Marty, 2026-09-12)
|
||||||
pushFeed({ type: 'Adopted', sponsor: r.adopterName, member: r.adopteeName, ts: Date.now() });
|
pushFeed({ type: 'Adopted', sponsor: r.adopterName, member: r.adopteeName, ts: Date.now() });
|
||||||
@@ -2752,15 +2782,26 @@ const server = http.createServer(async (req, res) => {
|
|||||||
m.sponsorVia = sp ? (t === String(sp.username || '').toLowerCase() ? 'username' : t === String(sp.code || '').toLowerCase() ? 'code' : 'member #') : (t ? 'unresolved' : '');
|
m.sponsorVia = sp ? (t === String(sp.username || '').toLowerCase() ? 'username' : t === String(sp.code || '').toLowerCase() ? 'code' : 'member #') : (t ? 'unresolved' : '');
|
||||||
}
|
}
|
||||||
for (const m of members) { try { const ps = await accounts.positions(m.email); m.positions = ps.length; m.positionIds = ps.map(p => p.memberId).filter(Boolean); } catch (e) { m.positions = 0; } }
|
for (const m of members) { try { const ps = await accounts.positions(m.email); m.positions = ps.length; m.positionIds = ps.map(p => p.memberId).filter(Boolean); } catch (e) { m.positions = 0; } }
|
||||||
|
for (const m of members) { try { const sg = await fraud.get(m.email); m.flags = sg ? sg.flags : []; m.suspended = !!(sg && sg.suspended); m.lastIp = sg ? sg.lastIp : ''; } catch (e) { m.flags = []; m.suspended = false; } }
|
||||||
return json(res, 200, { members });
|
return json(res, 200, { members });
|
||||||
}
|
}
|
||||||
if (p === '/api/admin/members' && req.method === 'PATCH') {
|
if (p === '/api/admin/members' && req.method === 'PATCH') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
if (!b.email) return json(res, 400, { error: 'Which member?' });
|
if (!b.email) return json(res, 400, { error: 'Which member?' });
|
||||||
|
if (b.suspend !== undefined) { // anti-fraud switch: suspended accounts cannot sign in; flags keep them off the leaderboard and out of adoptions
|
||||||
|
if (b.suspend) await fraud.suspend(b.email, b.reason || 'duplicate account'); else await fraud.unsuspend(b.email);
|
||||||
|
if (b.flags) await fraud.addFlags(b.email, [].concat(b.flags));
|
||||||
|
return json(res, 200, { ok: true, signals: await fraud.get(b.email) });
|
||||||
|
}
|
||||||
|
if (b.flags !== undefined) { const f = b.flags === null ? (await fraud.clearFlags(b.email), []) : await fraud.addFlags(b.email, [].concat(b.flags)); return json(res, 200, { ok: true, flags: f }); }
|
||||||
const r = await accounts.setSponsorRef(b.email, b.sponsorRef);
|
const r = await accounts.setSponsorRef(b.email, b.sponsorRef);
|
||||||
return json(res, r.error ? 400 : 200, r);
|
return json(res, r.error ? 400 : 200, r);
|
||||||
}
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
if (p === '/api/admin/campaigns' && req.method === 'GET') {
|
if (p === '/api/admin/campaigns' && req.method === 'GET') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
return json(res, 200, { campaigns: await ads.adminList(), rates: ads.rates(), bannerSizes: ads.bannerSizes(), houseOwner: ads.HOUSE_OWNER });
|
return json(res, 200, { campaigns: await ads.adminList(), rates: ads.rates(), bannerSizes: ads.bannerSizes(), houseOwner: ads.HOUSE_OWNER });
|
||||||
|
|||||||
Reference in New Issue
Block a user