Passwordless sign-in: 6-digit email codes, feature-flagged on SendGrid key

/api/auth/email/start issues a 15-min code (60s resend guard, 6 tries);
verify creates the account passwordless (sponsor cookie first-touch) and
mints the session. UI swaps the password cards for the code flow when
config.emailAuth is on; dev mode returns the code inline. Password flow
remains until the key lands in the volume (data/sendgrid.key) or
SENDGRID_KEY env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-04 13:38:11 -05:00
parent 7bb1a78ca3
commit 08f7a408e3
5 changed files with 143 additions and 3 deletions
+43 -1
View File
@@ -12,6 +12,7 @@ const chain = require('./chain');
const auth = require('./auth');
const accounts = require('./accounts');
const ads = require('./ads');
const mailer = require('./mailer');
const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname;
@@ -26,6 +27,9 @@ chain.init({ onEvent: ev => pushFeed(ev) });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain });
mailer.init({ dataDir: DATA_DIR });
// magic-code sign-in: emailLower -> {code, exp, tries}
const emailCodes = new Map();
setTimeout(() => ads.dailySweep(), 60 * 1000);
setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges
@@ -112,7 +116,8 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/config' && req.method === 'GET') {
const c = chain.getConfig();
return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId,
chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0] }, siteConfig()));
chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0],
emailAuth: mailer.hasKey() || !IS_PROD }, siteConfig()));
}
if (p === '/api/catalog' && req.method === 'GET') {
return json(res, 200, { products: await chain.catalog() });
@@ -158,6 +163,43 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
// -- passwordless: email code sign-in (signup and login are the same act)
if (p === '/api/auth/email/start' && req.method === 'POST') {
const b = await readBody(req);
const e = String(b.email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
const prev = emailCodes.get(e);
if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' });
const code = String(Math.floor(100000 + Math.random() * 900000));
emailCodes.set(e, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 });
if (mailer.hasKey()) {
try { await mailer.sendCode(e, code); } catch (err) {
console.error('sendCode failed', err.message);
return json(res, 502, { error: 'Could not send the email. Try again in a minute.' });
}
return json(res, 200, { ok: true, sent: true });
}
if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code });
return json(res, 503, { error: 'Email sign-in is not configured yet.' });
}
if (p === '/api/auth/email/verify' && req.method === 'POST') {
const b = await readBody(req);
const e = String(b.email || '').trim().toLowerCase();
const rec = emailCodes.get(e);
if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' });
rec.tries += 1;
if (rec.tries > 6) { emailCodes.delete(e); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); }
if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
emailCodes.delete(e);
const sid = Number(parseCookies(req)['iap.sponsor']) || 0;
const r = accounts.ensure(e, sid); // first touch wins; existing accounts unchanged
if (r.error) return json(res, 400, r);
let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} }
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') {