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:
+78
-28
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user