diff --git a/accounts.js b/accounts.js index eff6a44..478cb97 100644 --- a/accounts.js +++ b/accounts.js @@ -1,49 +1,99 @@ -// Site-side member records for InstantAdPay. +// Site-side member accounts for InstantAdPay. // The chain is the source of truth for money, credits, and qualification; -// this module holds only what the chain doesn't: free members who haven't -// touched the chain yet, sponsor attribution before first purchase (spec §4), -// display handles, and join stats. Wiping this file = the clean reset between -// the Amoy dress rehearsal and mainnet launch. +// 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: 1, byAddress: {}, joins: 0 }; +let db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 }; function load() { try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {} - if (!db || db.v !== 1) db = { v: 1, byAddress: {}, joins: 0 }; + 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)); + 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(); } -function get(address) { return db.byAddress[(address || '').toLowerCase()] || null; } -function upsert(address, fields) { - const a = (address || '').toLowerCase(); - if (!/^0x[0-9a-f]{40}$/.test(a)) return null; - const cur = db.byAddress[a] || { created: Date.now() }; - db.byAddress[a] = Object.assign(cur, fields || {}); - save(); - return db.byAddress[a]; +// ---- 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'); } -// Sponsor attribution: first touch wins, written on-chain at the member's -// first purchase/activation and permanent from then on. -function attributeSponsor(address, sponsorId) { - const a = (address || '').toLowerCase(); - const cur = get(a); - if (cur && cur.sponsorId) return cur.sponsorId; // first touch already set - const id = Number(sponsorId) || 0; - upsert(a, { sponsorId: id }); - db.joins += 1; save(); - return id; +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; } } -function count() { return Object.keys(db.byAddress).length; } -module.exports = { init, get, upsert, attributeSponsor, count }; +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 }; diff --git a/auth.js b/auth.js index dba2d2f..3ebf88a 100644 --- a/auth.js +++ b/auth.js @@ -84,12 +84,22 @@ async function verifyChallenge(address, signature) { if (rec !== a) return { error: 'Your wallet signed with a different account than the page is using (' + rec.slice(0, 6) + '…' + rec.slice(-4) + '). Switch accounts and tap sign-in again.' }; challenges.delete(a); - let memberId = 0; - try { memberId = await chain.memberIdByAccount(a); } catch (e) { /* chain read down: session still valid */ } + return { ok: true, address: a }; +} +// Sessions carry {email, address, memberId} — email accounts are the normal +// join path; the wallet fields fill in when one is linked at purchase time. +function mintSession(fields) { const token = crypto.randomBytes(32).toString('hex'); - sessions.set(token, { address: a, memberId, expires: Date.now() + SESSION_TTL }); + sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields, + { expires: Date.now() + SESSION_TTL })); + saveSessions(); + return token; +} +function updateSession(token, fields) { + const s = sessions.get(token); + if (!s) return; + sessions.set(token, Object.assign({}, s, fields)); saveSessions(); - return { token, address: a, memberId }; } function sessionCookie(token) { return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`; @@ -105,16 +115,16 @@ function fromRequest(req) { } async function refreshMemberId(sess) { // called after an on-chain action so the session learns its new member id + if (!sess.address) return sess.memberId || 0; try { const id = await chain.memberIdByAccount(sess.address); - if (id && id !== sess.memberId) { sess.memberId = id; sessions.set(sess.token, { - address: sess.address, memberId: id, expires: sess.expires }); saveSessions(); } - return id; - } catch (e) { return sess.memberId; } + if (id && id !== sess.memberId) updateSession(sess.token, { memberId: id }); + return id || sess.memberId || 0; + } catch (e) { return sess.memberId || 0; } } function logout(req) { const s = fromRequest(req); if (s) { sessions.delete(s.token); saveSessions(); } } -module.exports = { init, makeChallenge, verifyChallenge, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout }; +module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout }; diff --git a/public/assets/home.js b/public/assets/home.js index 3da5d3e..e8e99d5 100644 --- a/public/assets/home.js +++ b/public/assets/home.js @@ -31,6 +31,12 @@ async function buyPack(btn) { try { btn.disabled = true; + // email members get their wallet linked to the account at buy time + const me = await (await fetch('/api/me')).json(); + if (me.signedIn && me.email && !me.address) { + IAP.status('First, a free signature links your wallet to your account…'); + await IAPWallet.signIn(); + } IAP.status('Confirm the purchase in your wallet…'); const r = await IAPWallet.buy(Number(btn.dataset.id), sp.sponsorId || 0, btn.dataset.cost); if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.'); diff --git a/public/assets/my.js b/public/assets/my.js index 3a59618..9d77d5e 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -1,36 +1,51 @@ -// My account: SIWE sign-in, member state, free activation, invite link. +// My account: email-first join/login, wallet link at purchase time, +// free payout activation, invite link, on-chain activity. (async function () { await IAP.renderNav('my'); const $ = IAP.$; + async function api(path, body) { + const r = await (await fetch(path, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json(); + if (r.error) throw new Error(r.error); + return r; + } + async function render() { const me = await IAP.refreshNavWallet(); - if (!me || !me.signedIn) { $('signinCard').hidden = false; $('memberArea').hidden = true; return; } - $('signinCard').hidden = true; - $('memberArea').hidden = false; + const signedIn = me && me.signedIn; + $('authArea').hidden = !!signedIn; + $('memberArea').hidden = !signedIn; + if (!signedIn) return; + + const who = []; + if (me.email) who.push(me.email); + if (me.address) who.push('wallet ' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + ''); + else who.push('no wallet linked yet'); + if (me.memberId) who.push('on-chain member #' + me.memberId + '' + + (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : '')); + else if (me.sponsorId) who.push('invited by member #' + me.sponsorId); + $('posLine').innerHTML = who.join('
'); + + $('creditLine').textContent = (me.credits || 0).toLocaleString(); + $('linkCard').hidden = !!me.address; + $('activateCard').hidden = !(me.address && !me.memberId); + $('activityArea').hidden = !me.memberId; if (me.memberId) { - $('posLine').innerHTML = 'On-chain member #' + me.memberId + '
wallet ' - + me.address.slice(0, 8) + '…' + me.address.slice(-6) + '' - + (me.onchainSponsorId ? '
sponsored by member #' + me.onchainSponsorId : '
no sponsor (house line)'); - $('creditLine').textContent = (me.credits || 0).toLocaleString(); const bc = me.buyerCount || 0; $('qualLine').innerHTML = '' + bc + ' qualifying buyer(s) referred
' + (bc >= 5 ? 'Level 3 unlocked: full three-level earnings' : bc >= 2 ? 'Level 2 unlocked · ' + (5 - bc) + ' more for level 3' - : (2 - bc) + ' more ≥$20 buyer(s) unlock level 2'); - $('activateCard').hidden = true; + : (2 - bc) + ' more buyer(s) of $20+ unlock level 2'); $('inviteLine').textContent = location.origin + '/join/' + me.memberId; $('copyInvite').hidden = false; loadActivity(); } else { - $('posLine').innerHTML = 'Signed in as ' + me.address.slice(0, 8) + '…' + me.address.slice(-6) - + '
free member, not on-chain yet' - + (me.sponsorId ? '
invited by member #' + me.sponsorId : ''); - $('creditLine').textContent = '0'; - $('qualLine').textContent = 'Activate your payout wallet (or buy any package) to start; referrals who buy ≥$20 packages qualify you.'; - $('activateCard').hidden = false; - $('inviteLine').textContent = 'Your link appears after your free on-chain activation.'; + $('qualLine').textContent = 'Level 1 pays the moment you are on-chain. Referrals who buy packages of $20 or more unlock levels 2 and 3.'; + $('inviteLine').textContent = me.address + ? 'Activate payouts above and your invite link appears here.' + : 'Link a wallet and switch on payouts to get your invite link.'; $('copyInvite').hidden = true; } } @@ -42,43 +57,64 @@ const fill = (id, evs, empty) => { const el = $(id); el.innerHTML = ''; - if (!evs.length) { el.innerHTML = '
' + empty + '
'; return; } + if (!evs || !evs.length) { el.innerHTML = '
' + empty + '
'; return; } for (const ev of evs) el.appendChild(IAP.feedRow(ev, c)); }; fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.'); fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.'); - fill('buyFeed', a.purchases, 'No purchases from this wallet yet.'); + fill('buyFeed', a.purchases, 'No purchases from your wallet yet.'); } catch (e) {} } - $('signinBtn').addEventListener('click', async () => { + const busy = (btn, fn) => async () => { + try { btn.disabled = true; await fn(); } + catch (e) { IAP.status((e && e.message) || String(e), 'bad'); } + finally { btn.disabled = false; } + }; + + $('signupBtn').addEventListener('click', busy($('signupBtn'), async () => { + await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value }); + IAP.status('Welcome aboard. You are in.', 'ok'); + await render(); + })); + $('loginBtn').addEventListener('click', busy($('loginBtn'), async () => { + await api('/api/login', { email: $('liEmail').value, password: $('liPass').value }); + IAP.status('Logged in.', 'ok'); + await render(); + })); + $('walletSigninLink').addEventListener('click', async e => { + e.preventDefault(); try { - $('signinBtn').disabled = true; IAP.status('Check your wallet for the free sign-in signature…'); await IAPWallet.signIn(); - IAP.status('Signed in.', 'ok'); + IAP.status('Signed in with your wallet.', 'ok'); await render(); - } catch (e) { IAP.status((e && e.message) || String(e), 'bad'); } - finally { $('signinBtn').disabled = false; } + } catch (err) { IAP.status((err && err.message) || String(err), 'bad'); } }); - - $('activateBtn').addEventListener('click', async () => { - try { - $('activateBtn').disabled = true; - const me = await (await fetch('/api/me')).json(); - IAP.status('Confirm the free activation in your wallet…'); - const r = await IAPWallet.activate(me.sponsorId || 0); - if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.'); - IAP.status('Payout wallet activated. Your invite link is live.', 'ok'); - await render(); - } catch (e) { IAP.status('Activation failed: ' + ((e && e.message) || e), 'bad'); } - finally { $('activateBtn').disabled = false; } - }); - + $('linkBtn').addEventListener('click', busy($('linkBtn'), async () => { + IAP.status('Check your wallet for the free link signature…'); + await IAPWallet.signIn(); // server binds the wallet to the signed-in email account + IAP.status('Wallet linked. Earnings pay there from now on.', 'ok'); + await render(); + })); + $('activateBtn').addEventListener('click', busy($('activateBtn'), async () => { + const me = await (await fetch('/api/me')).json(); + IAP.status('Confirm the free activation in your wallet…'); + const r = await IAPWallet.activate(me.sponsorId || 0); + if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.'); + IAP.status('Payouts are on. Your invite link is live.', 'ok'); + await render(); + })); $('copyInvite').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); } catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); } }); + $('logoutLink').addEventListener('click', async e => { + e.preventDefault(); + await fetch('/api/auth/logout', { method: 'POST' }); + IAP.status('Logged out.', 'ok'); + await render(); + }); render(); })(); diff --git a/public/my.html b/public/my.html index 078c86f..9260f93 100644 --- a/public/my.html +++ b/public/my.html @@ -9,66 +9,91 @@

My account

-

Sign in with one free wallet signature. No email. No password. - No account to create. The signature is free and cannot move funds.

+

Join free with your email. Your wallet only comes out when you buy + a package or switch on payouts, and it stays yours the whole time.

-
-

Sign in with your wallet

-

New here? The same button creates your free membership. Your earnings always go - straight to this wallet. We never hold them.

- +
+
+
+

Create your free account

+

Takes ten seconds. No wallet needed to join.

+

+

+ +
+
+

Log in

+

Welcome back.

+

+

+ +
+
+

Crypto-native? You can also sign in with just your wallet. + One free signature, no email needed.