Email-first membership: signup/login, wallet linked at purchase time

Normal people join with email + password (sponsor attribution via cookie at
signup); the wallet only appears when buying or activating payouts, and gets
linked to the account then. Wallet-only sign-in remains for crypto-native
users. Sessions carry {email, address, memberId}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-04 13:00:56 -05:00
parent a87a63d75d
commit a90d5eee78
6 changed files with 271 additions and 114 deletions
+78 -28
View File
@@ -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; // 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 // this module holds what the chain doesn't: free members (email + password,
// touched the chain yet, sponsor attribution before first purchase (spec §4), // the way normal people join), sponsor attribution before first purchase
// display handles, and join stats. Wiping this file = the clean reset between // (spec §4), and the wallet link once one is connected at purchase time.
// the Amoy dress rehearsal and mainnet launch. // Wiping this file = the clean reset between rehearsal and mainnet.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
let DATA_DIR = null; let DATA_DIR = null;
const FILE = () => path.join(DATA_DIR, 'accounts.json'); 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() { function load() {
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {} 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() { function save() {
try { try {
const tmp = FILE() + '.tmp'; const tmp = FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(db)); fs.writeFileSync(tmp, JSON.stringify(db), { mode: 0o600 });
fs.renameSync(tmp, FILE()); fs.renameSync(tmp, FILE());
} catch (e) { console.error('accounts save failed', e.message); } } catch (e) { console.error('accounts save failed', e.message); }
} }
function init(opts) { DATA_DIR = opts.dataDir; load(); } function init(opts) { DATA_DIR = opts.dataDir; load(); }
function get(address) { return db.byAddress[(address || '').toLowerCase()] || null; } // ---- password hashing (scrypt, no deps) ----
function upsert(address, fields) { function hashPassword(password) {
const a = (address || '').toLowerCase(); const salt = crypto.randomBytes(16);
if (!/^0x[0-9a-f]{40}$/.test(a)) return null; const hash = crypto.scryptSync(String(password), salt, 32);
const cur = db.byAddress[a] || { created: Date.now() }; return salt.toString('hex') + ':' + hash.toString('hex');
db.byAddress[a] = Object.assign(cur, fields || {});
save();
return db.byAddress[a];
} }
// Sponsor attribution: first touch wins, written on-chain at the member's function checkPassword(password, stored) {
// first purchase/activation and permanent from then on. try {
function attributeSponsor(address, sponsorId) { const [saltHex, hashHex] = String(stored).split(':');
const a = (address || '').toLowerCase(); const hash = crypto.scryptSync(String(password), Buffer.from(saltHex, 'hex'), 32);
const cur = get(a); return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
if (cur && cur.sponsorId) return cur.sponsorId; // first touch already set } catch (e) { return false; }
const id = Number(sponsorId) || 0;
upsert(a, { sponsorId: id });
db.joins += 1; save();
return id;
} }
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 };
+19 -9
View File
@@ -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 (' 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.' }; + rec.slice(0, 6) + '…' + rec.slice(-4) + '). Switch accounts and tap sign-in again.' };
challenges.delete(a); challenges.delete(a);
let memberId = 0; return { ok: true, address: a };
try { memberId = await chain.memberIdByAccount(a); } catch (e) { /* chain read down: session still valid */ } }
// 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'); 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(); saveSessions();
return { token, address: a, memberId };
} }
function sessionCookie(token) { function sessionCookie(token) {
return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`; 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) { async function refreshMemberId(sess) {
// called after an on-chain action so the session learns its new member id // called after an on-chain action so the session learns its new member id
if (!sess.address) return sess.memberId || 0;
try { try {
const id = await chain.memberIdByAccount(sess.address); const id = await chain.memberIdByAccount(sess.address);
if (id && id !== sess.memberId) { sess.memberId = id; sessions.set(sess.token, { if (id && id !== sess.memberId) updateSession(sess.token, { memberId: id });
address: sess.address, memberId: id, expires: sess.expires }); saveSessions(); } return id || sess.memberId || 0;
return id; } catch (e) { return sess.memberId || 0; }
} catch (e) { return sess.memberId; }
} }
function logout(req) { function logout(req) {
const s = fromRequest(req); const s = fromRequest(req);
if (s) { sessions.delete(s.token); saveSessions(); } 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 };
+6
View File
@@ -31,6 +31,12 @@
async function buyPack(btn) { async function buyPack(btn) {
try { try {
btn.disabled = true; 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…'); IAP.status('Confirm the purchase in your wallet…');
const r = await IAPWallet.buy(Number(btn.dataset.id), sp.sponsorId || 0, btn.dataset.cost); 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.'); if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
+69 -33
View File
@@ -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 () { (async function () {
await IAP.renderNav('my'); await IAP.renderNav('my');
const $ = IAP.$; 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() { async function render() {
const me = await IAP.refreshNavWallet(); const me = await IAP.refreshNavWallet();
if (!me || !me.signedIn) { $('signinCard').hidden = false; $('memberArea').hidden = true; return; } const signedIn = me && me.signedIn;
$('signinCard').hidden = true; $('authArea').hidden = !!signedIn;
$('memberArea').hidden = false; $('memberArea').hidden = !signedIn;
if (!signedIn) return;
const who = [];
if (me.email) who.push(me.email);
if (me.address) who.push('wallet <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span>');
else who.push('no wallet linked yet');
if (me.memberId) who.push('on-chain <b>member #' + me.memberId + '</b>'
+ (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : ''));
else if (me.sponsorId) who.push('invited by member #' + me.sponsorId);
$('posLine').innerHTML = who.join('<br>');
$('creditLine').textContent = (me.credits || 0).toLocaleString();
$('linkCard').hidden = !!me.address;
$('activateCard').hidden = !(me.address && !me.memberId);
$('activityArea').hidden = !me.memberId;
if (me.memberId) { if (me.memberId) {
$('posLine').innerHTML = 'On-chain <b>member #' + me.memberId + '</b><br>wallet <span class="mono">'
+ me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span>'
+ (me.onchainSponsorId ? '<br>sponsored by member #' + me.onchainSponsorId : '<br>no sponsor (house line)');
$('creditLine').textContent = (me.credits || 0).toLocaleString();
const bc = me.buyerCount || 0; const bc = me.buyerCount || 0;
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>' $('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>' + (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>'
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3' : bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
: (2 - bc) + ' more ≥$20 buyer(s) unlock level 2'); : (2 - bc) + ' more buyer(s) of $20+ unlock level 2');
$('activateCard').hidden = true;
$('inviteLine').textContent = location.origin + '/join/' + me.memberId; $('inviteLine').textContent = location.origin + '/join/' + me.memberId;
$('copyInvite').hidden = false; $('copyInvite').hidden = false;
loadActivity(); loadActivity();
} else { } else {
$('posLine').innerHTML = 'Signed in as <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6) $('qualLine').textContent = 'Level 1 pays the moment you are on-chain. Referrals who buy packages of $20 or more unlock levels 2 and 3.';
+ '</span><br>free member, not on-chain yet' $('inviteLine').textContent = me.address
+ (me.sponsorId ? '<br>invited by member #' + me.sponsorId : ''); ? 'Activate payouts above and your invite link appears here.'
$('creditLine').textContent = '0'; : 'Link a wallet and switch on payouts to get your invite link.';
$('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.';
$('copyInvite').hidden = true; $('copyInvite').hidden = true;
} }
} }
@@ -42,43 +57,64 @@
const fill = (id, evs, empty) => { const fill = (id, evs, empty) => {
const el = $(id); const el = $(id);
el.innerHTML = ''; el.innerHTML = '';
if (!evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; } if (!evs || !evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; }
for (const ev of evs) el.appendChild(IAP.feedRow(ev, c)); 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('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('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) {} } 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 { try {
$('signinBtn').disabled = true;
IAP.status('Check your wallet for the free sign-in signature…'); IAP.status('Check your wallet for the free sign-in signature…');
await IAPWallet.signIn(); await IAPWallet.signIn();
IAP.status('Signed in.', 'ok'); IAP.status('Signed in with your wallet.', 'ok');
await render(); await render();
} catch (e) { IAP.status((e && e.message) || String(e), 'bad'); } } catch (err) { IAP.status((err && err.message) || String(err), 'bad'); }
finally { $('signinBtn').disabled = false; }
}); });
$('linkBtn').addEventListener('click', busy($('linkBtn'), async () => {
$('activateBtn').addEventListener('click', async () => { IAP.status('Check your wallet for the free link signature…');
try { await IAPWallet.signIn(); // server binds the wallet to the signed-in email account
$('activateBtn').disabled = true; 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(); const me = await (await fetch('/api/me')).json();
IAP.status('Confirm the free activation in your wallet…'); IAP.status('Confirm the free activation in your wallet…');
const r = await IAPWallet.activate(me.sponsorId || 0); const r = await IAPWallet.activate(me.sponsorId || 0);
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.'); if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
IAP.status('Payout wallet activated. Your invite link is live.', 'ok'); IAP.status('Payouts are on. Your invite link is live.', 'ok');
await render(); await render();
} catch (e) { IAP.status('Activation failed: ' + ((e && e.message) || e), 'bad'); } }));
finally { $('activateBtn').disabled = false; }
});
$('copyInvite').addEventListener('click', async () => { $('copyInvite').addEventListener('click', async () => {
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); } 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'); } 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(); render();
})(); })();
+42 -17
View File
@@ -9,49 +9,71 @@
<div class="wrap"> <div class="wrap">
<section class="hero" style="padding-bottom:16px"> <section class="hero" style="padding-bottom:16px">
<h1>My <em>account</em></h1> <h1>My <em>account</em></h1>
<p class="lead" id="introLead">Sign in with one free wallet signature. No email. No password. <p class="lead" id="introLead">Join free with your email. Your wallet only comes out when you buy
No account to create. The signature is free and cannot move funds.</p> a package or switch on payouts, and it stays yours the whole time.</p>
</section> </section>
<div class="card" id="signinCard"> <div id="authArea">
<h3>Sign in with your wallet</h3> <div class="grid c2">
<p class="muted small">New here? The same button creates your free membership. Your earnings always go <div class="card">
straight to this wallet. We never hold them.</p> <h3>Create your free account</h3>
<button class="btn" id="signinBtn">Connect &amp; sign in</button> <p class="muted small">Takes ten seconds. No wallet needed to join.</p>
<p><input id="suEmail" type="email" placeholder="Email" autocomplete="email" style="width:100%"></p>
<p><input id="suPass" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" style="width:100%"></p>
<button class="btn" id="signupBtn">Join free</button>
</div>
<div class="card">
<h3>Log in</h3>
<p class="muted small">Welcome back.</p>
<p><input id="liEmail" type="email" placeholder="Email" autocomplete="email" style="width:100%"></p>
<p><input id="liPass" type="password" placeholder="Password" autocomplete="current-password" style="width:100%"></p>
<button class="btn sec" id="loginBtn">Log in</button>
</div>
</div>
<p class="small muted">Crypto-native? You can also <a href="#" id="walletSigninLink">sign in with just your wallet</a>.
One free signature, no email needed.</p>
</div> </div>
<div id="memberArea" hidden> <div id="memberArea" hidden>
<div class="grid c3"> <div class="grid c3">
<div class="card"><h3>Your position</h3> <div class="card"><h3>Your account</h3>
<p id="posLine" class="muted small">…</p></div> <p id="posLine" class="muted small">…</p></div>
<div class="card"><h3>Ad credits</h3> <div class="card"><h3>Ad credits</h3>
<p class="mono" style="font-size:26px;margin:0" id="creditLine">—</p> <p class="mono" style="font-size:26px;margin:0" id="creditLine">0</p>
<p class="muted small">1 credit = 1¢ of delivery across the network. Recorded on-chain; only your campaigns can spend them.</p></div> <p class="muted small">1 credit = 1 cent of ad delivery across the network. Recorded on-chain. Only your campaigns can spend them.</p></div>
<div class="card"><h3>Qualification</h3> <div class="card"><h3>Earning levels</h3>
<p id="qualLine" class="muted small">…</p></div> <p id="qualLine" class="muted small">…</p></div>
</div> </div>
<div class="card" id="linkCard" hidden>
<h3>Link your wallet</h3>
<p class="muted small">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.</p>
<button class="btn" id="linkBtn">Connect &amp; link wallet</button>
</div>
<div class="card" id="activateCard" hidden> <div class="card" id="activateCard" hidden>
<h3>Activate your payout wallet, free</h3> <h3>Switch on payouts, free</h3>
<p class="muted small">One free transaction registers this wallet on-chain so commissions can reach it. <p class="muted small">One free transaction registers your linked wallet on-chain so commissions can reach it,
Buying any package does this automatically, so you can also just start with a package below.</p> and it unlocks your invite link. Buying any package does this automatically.</p>
<button class="btn sec" id="activateBtn">Activate payout wallet</button> <button class="btn sec" id="activateBtn">Activate payouts</button>
</div> </div>
<div class="card"> <div class="card">
<h3>Your invite link</h3> <h3>Your invite link</h3>
<p class="muted small">Share it anywhere. Everyone who joins through it becomes part of your line. <p class="muted small">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.</p> You earn 50 percent of every ad package they ever buy, plus levels 2 and 3 of their teams as you qualify.</p>
<p class="mono" id="inviteLine">Sign in to get your link.</p> <p class="mono" id="inviteLine">…</p>
<button class="btn small sec" id="copyInvite" hidden>Copy link</button> <button class="btn small sec" id="copyInvite" hidden>Copy link</button>
</div> </div>
<div class="card"> <div class="card">
<h3>Buy ad packages</h3> <h3>Buy ad packages</h3>
<p class="muted small">The full ladder with live pricing is on the <a href="/">home page</a>. <p class="muted small">The full ladder with live pricing is on the <a href="/">home page</a>.
Purchases from this wallet automatically credit this account.</p> Purchases from your linked wallet automatically credit this account.</p>
</div> </div>
<div id="activityArea" hidden>
<h2>Your activity, straight from the chain</h2> <h2>Your activity, straight from the chain</h2>
<div class="grid c2"> <div class="grid c2">
<div class="card"> <div class="card">
@@ -71,6 +93,9 @@
</div> </div>
</div> </div>
<p class="small"><a href="#" id="logoutLink">Log out</a></p>
</div>
<footer><div>InstantAdPay · <a href="/ledger">live ledger</a></div></footer> <footer><div>InstantAdPay · <a href="/ledger">live ledger</a></div></footer>
</div> </div>
<script src="/assets/common.js"></script> <script src="/assets/common.js"></script>
+39 -9
View File
@@ -134,7 +134,28 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { onchainMembers: members, siteAccounts: accounts.count() }); return json(res, 200, { onchainMembers: members, siteAccounts: accounts.count() });
} }
// -- auth // -- accounts: email + password is the normal join path (wallet comes
// out only at purchase / payout-activation time and gets linked then)
if (p === '/api/signup' && req.method === 'POST') {
const b = await readBody(req);
const sid = Number(parseCookies(req)['iap.sponsor']) || 0; // first-touch attribution
const r = accounts.signup(b.email, b.password, sid);
if (r.error) return json(res, 400, r);
const token = auth.mintSession({ email: r.account.email });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
if (p === '/api/login' && req.method === 'POST') {
const b = await readBody(req);
const r = accounts.login(b.email, b.password);
if (r.error) return json(res, 400, r);
let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} }
const token = auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
// -- wallet auth: link-to-account when an email session exists, or
// wallet-first sign-in for crypto-native users
if (p === '/api/auth/challenge' && req.method === 'POST') { if (p === '/api/auth/challenge' && req.method === 'POST') {
const b = await readBody(req); const b = await readBody(req);
const r = auth.makeChallenge(b.address); const r = auth.makeChallenge(b.address);
@@ -144,12 +165,19 @@ const server = http.createServer(async (req, res) => {
const b = await readBody(req); const b = await readBody(req);
const r = await auth.verifyChallenge(b.address, b.signature); const r = await auth.verifyChallenge(b.address, b.signature);
if (r.error) return json(res, 400, r); if (r.error) return json(res, 400, r);
// bind the visitor's sponsor cookie to this wallet, first touch wins let memberId = 0;
const sid = Number(parseCookies(req)['iap.sponsor']) || 0; try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
const attributed = accounts.attributeSponsor(r.address, sid); const s = auth.fromRequest(req);
accounts.upsert(r.address, { lastSeen: Date.now() }); if (s && s.email) {
return json(res, 200, { ok: true, address: r.address, memberId: r.memberId, sponsorId: attributed }, const lr = accounts.linkWallet(s.email, r.address);
{ 'Set-Cookie': auth.sessionCookie(r.token) }); if (lr.error) return json(res, 400, lr);
auth.updateSession(s.token, { address: r.address, memberId });
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
}
const acct = accounts.byAddress(r.address);
const token = auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
return json(res, 200, { ok: true, address: r.address, memberId },
{ 'Set-Cookie': auth.sessionCookie(token) });
} }
if (p === '/api/auth/logout' && req.method === 'POST') { if (p === '/api/auth/logout' && req.method === 'POST') {
auth.logout(req); auth.logout(req);
@@ -159,8 +187,10 @@ const server = http.createServer(async (req, res) => {
const s = auth.fromRequest(req); const s = auth.fromRequest(req);
if (!s) return json(res, 200, { signedIn: false }); if (!s) return json(res, 200, { signedIn: false });
const memberId = await auth.refreshMemberId(s); const memberId = await auth.refreshMemberId(s);
const acct = accounts.get(s.address) || {}; const acct = (s.email && accounts.byEmail(s.email)) || (s.address && accounts.byAddress(s.address)) || null;
const out = { signedIn: true, address: s.address, memberId, sponsorId: acct.sponsorId || 0 }; const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null, memberId,
sponsorId: (acct && acct.sponsorId) || Number(parseCookies(req)['iap.sponsor']) || 0 };
if (memberId) { if (memberId) {
try { try {
const mm = await chain.member(memberId); const mm = await chain.member(memberId);