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
+39 -9
View File
@@ -134,7 +134,28 @@ const server = http.createServer(async (req, res) => {
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') {
const b = await readBody(req);
const r = auth.makeChallenge(b.address);
@@ -144,12 +165,19 @@ const server = http.createServer(async (req, res) => {
const b = await readBody(req);
const r = await auth.verifyChallenge(b.address, b.signature);
if (r.error) return json(res, 400, r);
// bind the visitor's sponsor cookie to this wallet, first touch wins
const sid = Number(parseCookies(req)['iap.sponsor']) || 0;
const attributed = accounts.attributeSponsor(r.address, sid);
accounts.upsert(r.address, { lastSeen: Date.now() });
return json(res, 200, { ok: true, address: r.address, memberId: r.memberId, sponsorId: attributed },
{ 'Set-Cookie': auth.sessionCookie(r.token) });
let memberId = 0;
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
const s = auth.fromRequest(req);
if (s && s.email) {
const lr = accounts.linkWallet(s.email, r.address);
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') {
auth.logout(req);
@@ -159,8 +187,10 @@ const server = http.createServer(async (req, res) => {
const s = auth.fromRequest(req);
if (!s) return json(res, 200, { signedIn: false });
const memberId = await auth.refreshMemberId(s);
const acct = accounts.get(s.address) || {};
const out = { signedIn: true, address: s.address, memberId, sponsorId: acct.sponsorId || 0 };
const acct = (s.email && accounts.byEmail(s.email)) || (s.address && accounts.byAddress(s.address)) || null;
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) {
try {
const mm = await chain.member(memberId);