// Site-side member accounts for InstantAdPay. // The chain is the source of truth for money, credits, and qualification; // this module holds what the chain doesn't: free members (email + password, // the way normal people join), sponsor attribution before first purchase // (spec §4), and the wallet link once one is connected at purchase time. // Wiping this file = the clean reset between rehearsal and mainnet. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); let DATA_DIR = null; const FILE = () => path.join(DATA_DIR, 'accounts.json'); let db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 }; function load() { try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {} if (!db || !db.v) db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 }; if (db.v === 1) { db.v = 2; db.byEmail = db.byEmail || {}; } // early rehearsal file } function save() { try { const tmp = FILE() + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(db), { mode: 0o600 }); fs.renameSync(tmp, FILE()); } catch (e) { console.error('accounts save failed', e.message); } } function init(opts) { DATA_DIR = opts.dataDir; load(); } // ---- password hashing (scrypt, no deps) ---- function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(String(password), salt, 32); return salt.toString('hex') + ':' + hash.toString('hex'); } function checkPassword(password, stored) { try { const [saltHex, hashHex] = String(stored).split(':'); const hash = crypto.scryptSync(String(password), Buffer.from(saltHex, 'hex'), 32); return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')); } catch (e) { return false; } } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const normEmail = e => String(e || '').trim().toLowerCase(); const normAddr = a => String(a || '').trim().toLowerCase(); // ---- email accounts (the normal join path) ---- function signup(email, password, sponsorId) { const e = normEmail(email); if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; if (String(password || '').length < 8) return { error: 'Password needs at least 8 characters.' }; if (db.byEmail[e]) return { error: 'That email already has an account. Log in instead.' }; db.byEmail[e] = { email: e, pass: hashPassword(password), sponsorId: Number(sponsorId) || 0, // first touch, written on-chain at first purchase address: null, created: Date.now() }; db.joins += 1; save(); return { ok: true, account: publicView(db.byEmail[e]) }; } function login(email, password) { const e = normEmail(email); const acct = db.byEmail[e]; if (!acct || !checkPassword(password, acct.pass)) return { error: 'Wrong email or password.' }; acct.lastSeen = Date.now(); save(); return { ok: true, account: publicView(acct) }; } function byEmail(email) { const a = db.byEmail[normEmail(email)]; return a ? publicView(a) : null; } function byAddress(address) { const e = db.byAddress[normAddr(address)]; return e ? publicView(db.byEmail[e]) : null; } // ---- wallet link (happens at purchase / payout activation time) ---- // First link wins and is permanent for the account; one wallet, one account. function linkWallet(email, address) { const e = normEmail(email); const a = normAddr(address); const acct = db.byEmail[e]; if (!acct) return { error: 'No such account.' }; if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' }; if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet ' + acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Earnings pay to that wallet. Connect it instead.' }; if (db.byAddress[a] && db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' }; acct.address = a; db.byAddress[a] = e; save(); return { ok: true, account: publicView(acct) }; } function publicView(a) { return { email: a.email, sponsorId: a.sponsorId || 0, address: a.address || null, created: a.created }; } function count() { return Object.keys(db.byEmail).length; } module.exports = { init, signup, login, byEmail, byAddress, linkWallet, count };