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 = '
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.
New here? The same button creates your free membership. Your earnings always go - straight to this wallet. We never hold them.
- +Takes ten seconds. No wallet needed to join.
+ + + +Welcome back.
+ + + +Crypto-native? You can also sign in with just your wallet. + One free signature, no email needed.
…
—
-1 credit = 1¢ of delivery across the network. Recorded on-chain; only your campaigns can spend them.
0
+1 credit = 1 cent of ad delivery across the network. Recorded on-chain. Only your campaigns can spend them.
…
Your earnings pay straight to your own wallet, so we need to know which one is yours. + One free signature links it. It cannot move funds or approve anything. Buying any package links it automatically too.
+ +One free transaction registers this wallet on-chain so commissions can reach it. - Buying any package does this automatically, so you can also just start with a package below.
- +One free transaction registers your linked wallet on-chain so commissions can reach it, + and it unlocks your invite link. Buying any package does this automatically.
+Share it anywhere. Everyone who joins through it becomes part of your line. You earn 50 percent of every ad package they ever buy, plus levels 2 and 3 of their teams as you qualify.
-Sign in to get your link.
+…
The full ladder with live pricing is on the home page. - Purchases from this wallet automatically credit this account.
+ Purchases from your linked wallet automatically credit this account.Every payout the contract has sent to your wallet. Each line links to the transaction.
-Every payout the contract has sent to your wallet. Each line links to the transaction.
+Activations and qualifying buys in your line.
+Activations and qualifying buys in your line.
-