diff --git a/chatbot.js b/chatbot.js index ff9a28f..ca15348 100644 --- a/chatbot.js +++ b/chatbot.js @@ -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. - 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. +- 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. - 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//, which shows the badge and their join link. diff --git a/fraud.js b/fraud.js new file mode 100644 index 0000000..7d1f007 --- /dev/null +++ b/fraud.js @@ -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 }; diff --git a/leaderboard.js b/leaderboard.js index 9c436e8..e3748de 100644 --- a/leaderboard.js +++ b/leaderboard.js @@ -62,6 +62,7 @@ async function computeRaw(period) { const sponsor = map[e.recipientId], buyer = map[e.buyerId]; if (!sponsor) continue; 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() }; r.sales += 1; r.cents += price[e.tx + ':' + e.buyerId] || 0; r.pol += BigInt(e.amountWei || 0); if (buyer) r.buyers.add(buyer); } diff --git a/public/admin.html b/public/admin.html index 4baf8ab..2f20c0a 100644 --- a/public/admin.html +++ b/public/admin.html @@ -263,7 +263,8 @@

Members

newest first
-

+

Loading duplicate signals…

+

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.

@@ -487,6 +488,6 @@ - + diff --git a/public/assets/admin.js b/public/assets/admin.js index 60c6216..738e6e5 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -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 @@ + '' + (a.memberId ? '#' + a.memberId : 'free') + '' + '' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : 'none') + '' + '' + (a.sponsorName ? esc(a.sponsorName) + (a.sponsorVia === 'code' ? ' via code ' + esc(a.sponsorRef) + '' : a.sponsorVia === 'member #' ? ' via #' + esc(a.sponsorRef) + '' : '') : a.sponsorRef ? 'dead link: ' + esc(a.sponsorRef) + '' : 'none') + '' + (a.positions ? a.positions : '0') + '' + esc(a.joinedVia || '') + '' + esc(a.code || '') + '' - + '' + when(a.created) + '' - + ' ').join(''); + + '' + when(a.created) + (a.suspended ? ' suspended' : '') + ((a.flags || []).length ? ' ' + esc((a.flags || []).join(' ')) + '' : '') + '' + + ' ').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 ? '

' + title + '

' + list.map(g => '
' + esc(g.key) + ': ' + g.emails.map(x => esc(x)).join(', ') + '
').join('') : ''; + box.innerHTML = '

Duplicate signals

' + 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.

' + + grp('Shared browser', r.sharedDevice) + grp('Shared IP', r.sharedIp) + + (r.flagged.length ? '

Flagged

' + r.flagged.map(f => '
' + esc(f.email) + ' [' + esc(f.flags.join(', ')) + ']' + (f.suspended ? ' suspended' : '') + '
').join('') : '') + + (r.suspended.length ? '

Suspended

' + r.suspended.map(x => '
' + esc(x.email) + ' (' + esc(x.reason || '') + ', ' + when(x.at) + ')
').join('') : '') + + (!r.sharedDevice.length && !r.sharedIp.length && !r.flagged.length && !r.suspended.length ? '

Nothing shared or flagged yet.

' : ''); + } catch (e) { box.innerHTML = '

' + esc(e.message) + '

'; } + } 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' }); diff --git a/public/privacy.html b/public/privacy.html index 20384a8..9cfae54 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -19,7 +19,8 @@

How we use it

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.

Cookies

-

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.

+

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.

+

Abuse prevention. 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.

Sharing

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.

Your choices

diff --git a/server.js b/server.js index 0f0839d..a9cc49b 100644 --- a/server.js +++ b/server.js @@ -23,6 +23,7 @@ const drip = require('./drip'); const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set) let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ } const chatbot = require('./chatbot'); +const fraud = require('./fraud'); 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 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; } }); 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' }); + 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 }); traffic.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(() => {}); } }); setTimeout(() => audit.dailyTick(), 5 * 60 * 1000); setInterval(() => audit.dailyTick(), 24 * 60 * 60 * 1000); 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); } }); 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); @@ -448,6 +454,7 @@ function siteConfig() { 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 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 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 @@ -690,6 +697,16 @@ async function sponsorGainNudge(sp, buyerName, lostNames) { await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html); } 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) function sponsorRoutedAlert(who, spd, routed, skipped) { 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') { const b = await readBody(req); 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.' }); 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.' }); } @@ -1222,9 +1240,9 @@ const server = http.createServer(async (req, res) => { console.error('sendCode failed', err.message); 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.' }); } 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 via = parseCookies(req)['iap.angle'] || ''; 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 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) 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: @@ -1818,6 +1847,7 @@ const server = http.createServer(async (req, res) => { const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); 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); 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() }); @@ -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' : ''); } 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 }); } if (p === '/api/admin/members' && req.method === 'PATCH') { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); const b = await readBody(req); 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); 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 (!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 });