// Self-service email verification (engagement Phase 1b). A user asks to set or // change their address; a tokened link goes to the address they typed, and only // opening that link installs it. The opaque token lives only in the emailed link — // the DB stores its sha256 — so a DB read never yields a usable link. Same shape // as password_resets and user_invites, deliberately: the design of record calls // this link "signed", but every comparable flow here uses a hashed random token, // and matching them beats adding a second token mechanism for one caller. // // The address is stored ON THE ROW rather than read from the user at confirm // time, because a token proves control of the address it was mailed to and // nothing else. const crypto = require('crypto') const db = require('./emailVerifications.db') // A day, not an hour. Unlike a password reset this is not a live credential-reset // capability — the worst a leaked token does is attach an address its holder // already controls — and a verification mail is routinely opened on another // device, hours later. const DEFAULT_TTL_MINUTES = 24 * 60 // Per-user ceiling on verification sends, independent of the per-IP limiter: the // mail goes to an address the RECIPIENT did not choose to hear from, so an // attacker with one account must not be able to use it to pester a mailbox. const MAX_SENDS_PER_WINDOW = 5 const SEND_WINDOW_MINUTES = 60 function hashToken(raw) { return crypto.createHash('sha256').update(String(raw)).digest('hex') } // Create a verification for one user + address. Returns { id, token } — the // plaintext token is returned ONCE, for the link, and is never recoverable after. async function create({ userId, email, requestedIp, ttlMinutes = DEFAULT_TTL_MINUTES }) { const token = crypto.randomBytes(32).toString('base64url') const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000) const id = await db.insert({ tokenHash: hashToken(token), userId, email, requestedIp, expiresAt }) return { id, token } } // Resolve a pending, unexpired verification from its plaintext token, else null. // Returns the RAW row (incl. user_id and the address it proves). async function findValidByToken(token) { if (!token) return null const row = await db.findByTokenHash(hashToken(token)) if (!row || row.status !== 'pending') return null if (new Date(row.expires_at).getTime() < Date.now()) return null return row } // Atomically consume a pending verification (double-use-safe). True if this call // won the race. async function consume(id) { return (await db.markUsed(id)) === 1 } const invalidatePendingForUser = (userId) => db.invalidatePendingForUser(userId) // True when this user has already asked for as many verification mails as the // window allows. async function sendQuotaExhausted(userId) { const since = new Date(Date.now() - SEND_WINDOW_MINUTES * 60 * 1000) return (await db.countRecentForUser(userId, since)) >= MAX_SENDS_PER_WINDOW } module.exports = { create, findValidByToken, consume, invalidatePendingForUser, sendQuotaExhausted, hashToken, DEFAULT_TTL_MINUTES, MAX_SENDS_PER_WINDOW, SEND_WINDOW_MINUTES, }