/** * @mandujs/core/auth/verification — email verification flow (Phase 5.3). * * Flow: * 1. Caller collects an email at signup and invokes `send(userId, email)`. * 2. We mint a single-use token, render a link, and hand the rendered * message to the caller's {@link EmailSender}. * 3. The user clicks the link. The landing route invokes * `consume(tokenFromUrl)`. * 4. On success, `onVerified({ userId, email })` fires — the caller * persists the "verified" flag on their user record. * * ## What this module does NOT do * * - **No user-record mutation.** We don't know what your `users` table * looks like. The `onVerified` callback is your hook. * - **No auto-login.** Verification is an identity claim, not a session. * If you want "verify and log in", do both in your landing route. * - **No rate limiting.** Caller must gate `send()` (suggested: 1/min per * userId) to prevent outbound-email spam from a malicious attacker who * knows someone's account id. Phase 6 rate-limit middleware can wrap * this. * - **No storage side-effects on failed consume.** A bogus / expired token * quietly returns `null` — do NOT log which check failed (would leak * signal to brute-forcers). * * ## Idempotency caveat * * `consume()` marks the token used before invoking `onVerified`. If your * callback throws, the token is already consumed — the user would need a * fresh verification email. This is the safer trade-off: we'd rather * force a resend than leave a window where a single token can be * re-consumed and trigger `onVerified` twice. Make `onVerified` idempotent * so double-invocation (from a race elsewhere in your stack) is harmless. * * @example * ```ts * import { createEmailVerification } from "@mandujs/core/auth/verification"; * import { createAuthTokenStore } from "@mandujs/core/auth/tokens"; // internal * * const store = createAuthTokenStore({ secret: process.env.TOKEN_SECRET! }); * const verify = createEmailVerification({ * store, * sender: mail, * fromAddress: "noreply@example.com", * verifyUrlTemplate: "https://app.example.com/verify?token={token}", * renderEmail: ({ url }) => ({ * subject: "Verify your email", * html: `

Click here.

`, * }), * onVerified: async ({ userId, email }) => { * await db.users.update(userId, { emailVerifiedAt: new Date(), email }); * }, * }); * * await verify.send("u-1", "alice@example.com"); * // later, in the verify route: * const ok = await verify.consume(tokenFromQuery); * if (!ok) return ctx.badRequest("invalid or expired token"); * ``` * * @module auth/verification */ import type { EmailSender } from "../email/index.js"; import type { AuthTokenStore, TokenRecord } from "./tokens.js"; // ─── Public types ─────────────────────────────────────────────────────────── /** Construction options for {@link createEmailVerification}. */ export interface VerificationFlowOptions { /** Token store from {@link createAuthTokenStore}. */ store: AuthTokenStore; /** Email transport. See `@mandujs/core/email` for provider options. */ sender: EmailSender; /** * `From:` address stamped on every outbound verification message. Accepts * bare (`noreply@example.com`) or display-name (`"App "`) form — * passed through to the provider, which does final validation. * * Required (no default) because a wrong `From:` on a transactional email * gets the entire domain flagged by the provider. The caller picks. */ fromAddress: string; /** * URL template for the verification link. Must contain the literal * `{token}` placeholder — we substitute it with `encodeURIComponent(token)` * at send time. * * @example `"https://app.example.com/verify?token={token}"` */ verifyUrlTemplate: string; /** * Render the email body. Returned object is forwarded to * {@link EmailSender.send} — `subject` is required; you must provide at * least one of `html` / `text`. */ renderEmail: (args: { url: string; userId: string; email: string; }) => { subject: string; html?: string; text?: string }; /** * Called after `consume()` marks a token used. Receives the verified * `{ userId, email }`. If this throws, the error propagates to the * `consume` caller — but the token is already consumed. Make this * idempotent. */ onVerified: (args: { userId: string; email: string }) => Promise; } /** Public surface returned by {@link createEmailVerification}. */ export interface VerificationFlow { /** * Mint a verification token, render the email with the link embedded, * and hand it off to the sender. Callers should rate-limit this per * userId — see Phase 6 rate-limit middleware. */ send(userId: string, email: string): Promise; /** * Consume a token. Returns `{ userId, email }` on success, `null` on any * failure mode (unknown / expired / already-used / tampered / wrong * purpose). Never throws on bad input. * * NOTE: if `onVerified` throws, the token is already consumed and the * error propagates. See the module-level "Idempotency caveat". */ consume(token: string): Promise<{ userId: string; email: string } | null>; } // ─── Constants ────────────────────────────────────────────────────────────── const URL_PLACEHOLDER = "{token}"; const PURPOSE = "verify-email" as const; // ─── Factory ──────────────────────────────────────────────────────────────── /** * Wire up an email-verification flow. * * @throws {TypeError} Synchronously when `verifyUrlTemplate` is missing the * `{token}` placeholder, or when `fromAddress` is empty. */ export function createEmailVerification( options: VerificationFlowOptions, ): VerificationFlow { const { store, sender, fromAddress, verifyUrlTemplate, renderEmail, onVerified } = options; if (typeof fromAddress !== "string" || fromAddress.length === 0) { throw new TypeError( "[@mandujs/core/auth/verification] createEmailVerification: 'fromAddress' is required and must be a non-empty string.", ); } if (typeof verifyUrlTemplate !== "string" || !verifyUrlTemplate.includes(URL_PLACEHOLDER)) { throw new TypeError( `[@mandujs/core/auth/verification] createEmailVerification: 'verifyUrlTemplate' must include the literal '${URL_PLACEHOLDER}' placeholder.`, ); } async function send(userId: string, email: string): Promise { if (typeof userId !== "string" || userId.length === 0) { throw new TypeError( "[@mandujs/core/auth/verification] send: userId must be a non-empty string.", ); } if (typeof email !== "string" || email.length === 0) { throw new TypeError( "[@mandujs/core/auth/verification] send: email must be a non-empty string.", ); } // Persist the email-under-verification in `meta` so `consume()` can // surface it back to `onVerified`. The token itself binds to the userId // at the store level; `meta.email` is what the user is CLAIMING to // control at send time. They prove control by receiving the link. const { token } = await store.mint(PURPOSE, userId, { email }); // Base64url-safe nonces mean `encodeURIComponent` is effectively a // no-op — we still wrap so a future nonce-alphabet change can't // silently produce broken URLs. const url = verifyUrlTemplate.replace(URL_PLACEHOLDER, encodeURIComponent(token)); const rendered = renderEmail({ url, userId, email }); if (!rendered || typeof rendered !== "object") { throw new TypeError( "[@mandujs/core/auth/verification] renderEmail: must return { subject, html?, text? }.", ); } await sender.send({ from: fromAddress, to: email, subject: rendered.subject, html: rendered.html, text: rendered.text, }); } async function consume( token: string, ): Promise<{ userId: string; email: string } | null> { // `parseToken` inside the store handles `null`/malformed input by // returning null — but our wire layer may have URL-encoded the token, // so reverse the encoding we applied in `send()` first. A malformed // %-sequence short-circuits to null (never throw on user input). const decoded = safeDecodeURIComponent(token); if (decoded === null) return null; const record = await store.consume(PURPOSE, decoded); if (!record) return null; const email = extractEmail(record); if (!email) { // Token was valid but the email is missing from meta — indicates a // store inconsistency (hand-edited row?). Treat as bogus rather than // invoking `onVerified` without an email. return null; } await onVerified({ userId: record.userId, email }); return { userId: record.userId, email }; } return { send, consume }; } // ─── Helpers ──────────────────────────────────────────────────────────────── /** * `decodeURIComponent` throws on malformed `%XX`. User-supplied query * strings can carry bad encodings, so we wrap and normalise to null. */ function safeDecodeURIComponent(value: string): string | null { if (typeof value !== "string" || value.length === 0) return null; try { return decodeURIComponent(value); } catch { return null; } } /** * Pull `email` from `record.meta`. Returns `null` when meta is missing or * the key is not a non-empty string. Defensive against a hand-edited DB * (or a future migration) where meta ends up with unexpected shape. */ function extractEmail(record: TokenRecord): string | null { const meta = record.meta; if (!meta || typeof meta !== "object") return null; const raw = meta.email; return typeof raw === "string" && raw.length > 0 ? raw : null; }