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
+13 -1
View File
@@ -68,6 +68,18 @@ function login(email, password) {
acct.lastSeen = Date.now(); save();
return { ok: true, account: publicView(acct) };
}
// Passwordless path: a verified email code proves ownership, so the account
// may exist with no password at all.
function ensure(email, sponsorId) {
const e = normEmail(email);
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
if (!db.byEmail[e]) {
db.byEmail[e] = { email: e, pass: null, sponsorId: Number(sponsorId) || 0, address: null, created: Date.now() };
db.joins += 1;
save();
}
return { ok: true, account: publicView(db.byEmail[e]) };
}
function byEmail(email) { const a = db.byEmail[normEmail(email)]; return a ? publicView(a) : null; }
function byAddress(address) {
const e = db.byAddress[normAddr(address)];
@@ -96,4 +108,4 @@ function publicView(a) {
}
function count() { return Object.keys(db.byEmail).length; }
module.exports = { init, signup, login, byEmail, byAddress, linkWallet, count };
module.exports = { init, signup, login, ensure, byEmail, byAddress, linkWallet, count };
+47
View File
@@ -0,0 +1,47 @@
// Outbound mail via SendGrid v3 (domain-authenticated instantadpay.com).
// Key sources: SENDGRID_KEY env, else DATA_DIR/sendgrid.key in the volume.
// No key = email sign-in stays feature-flagged off in production.
const fs = require('fs');
const path = require('path');
const https = require('https');
let DATA_DIR = null;
const FROM = { email: 'no-reply@instantadpay.com', name: 'InstantAdPay' };
function init(opts) { DATA_DIR = opts.dataDir; }
function key() {
if (process.env.SENDGRID_KEY) return process.env.SENDGRID_KEY.trim();
try { return fs.readFileSync(path.join(DATA_DIR, 'sendgrid.key'), 'utf8').trim(); } catch (e) { return ''; }
}
function hasKey() { return !!key(); }
function send(to, subject, text) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
personalizations: [{ to: [{ email: to }] }],
from: FROM,
subject,
content: [{ type: 'text/plain', value: text }]
});
const req = https.request({ hostname: 'api.sendgrid.com', path: '/v3/mail/send', method: 'POST',
headers: { Authorization: 'Bearer ' + key(), 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body) }, timeout: 15000 },
res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => res.statusCode < 300 ? resolve(true) : reject(new Error('sendgrid ' + res.statusCode + ': ' + d.slice(0, 200))));
});
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('sendgrid timeout')));
req.end(body);
});
}
function sendCode(to, code) {
return send(to, code + ' is your InstantAdPay sign-in code',
'Your sign-in code is: ' + code + '\n\n'
+ 'It works for 15 minutes. If you did not request it, ignore this email.\n\n'
+ 'InstantAdPay\nAdvertise and earn instantly. Locked in code, not promises.\nhttps://instantadpay.com');
}
module.exports = { init, hasKey, send, sendCode };
+29
View File
@@ -120,6 +120,35 @@
finally { btn.disabled = false; }
};
// passwordless (feature-flagged on config.emailAuth): code replaces passwords
(async () => {
const cfg = await IAP.getConfig();
if (!cfg.emailAuth) return;
$('passCards').hidden = true;
$('magicCard').hidden = false;
const start = busy($('mcSendBtn'), async () => {
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
$('mcCodeRow').hidden = false;
$('mcVerifyBtn').hidden = false;
$('mcSendBtn').hidden = true;
$('mcResend').hidden = false;
if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); }
else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok');
$('mcCode').focus();
});
$('mcSendBtn').addEventListener('click', start);
$('mcResend').addEventListener('click', busy($('mcResend'), async () => {
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
if (r.devCode) $('mcCode').value = r.devCode;
IAP.status('Fresh code sent.', 'ok');
}));
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value });
IAP.status('You are in.', 'ok');
await render();
}));
})();
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value });
IAP.status('Welcome aboard. You are in.', 'ok');
+11 -1
View File
@@ -15,7 +15,17 @@
</section>
<div id="authArea">
<div class="grid c2">
<div class="card" id="magicCard" hidden>
<h3>Sign in or join free</h3>
<p class="muted small">Type your email and we send a 6-digit code. No password to invent,
no password to forget. New emails get a free account automatically.</p>
<p><input id="mcEmail" type="email" placeholder="Email" autocomplete="email" style="width:100%;max-width:420px"></p>
<p id="mcCodeRow" hidden><input id="mcCode" inputmode="numeric" placeholder="6-digit code" style="width:100%;max-width:420px"></p>
<button class="btn" id="mcSendBtn">Email me a code</button>
<button class="btn" id="mcVerifyBtn" hidden>Sign in</button>
<button class="btn sec small" id="mcResend" hidden>Send a fresh code</button>
</div>
<div class="grid c2" id="passCards">
<div class="card">
<h3>Create your free account</h3>
<p class="muted small">Takes ten seconds. No wallet needed to join.</p>
+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') {