From 6b8fdcd41040206d8a635f66cdab5b379b71fe36 Mon Sep 17 00:00:00 2001 From: brad valdes Date: Tue, 28 Jul 2026 16:56:13 -0400 Subject: [PATCH 1/2] feat(activation): verify-email resend endpoint + recovery links (anti-enumeration) POST /v1/agent/verify-email/resend was referenced in comments since the verification gate shipped but never built - a user whose 30-min token expired was permanently stranded with 75 pending credits and no way to activate them. - lib/verification.ts: reissueEmailVerification() rotates ONLY verifyToken/verifyTokenExpiry and re-sends the email. Deliberately NOT issueEmailVerification(): on a non-fresh agent the SignupIdentity claim is already consumed, so that path would compute pending=0 and WIPE the user's gated pendingCredits. 2-min cooldown vs mail-cannoning; 3/hour per normalized-email+IP in-process limiter (same pattern as ipSignupCounts), counted before any DB read. - routes/agent.ts: the endpoint. Always the same neutral 200 (JSON for API clients, script-free HTML page for browser form posts) whether the account exists / is verified / is cooling down - anti-enumeration. 400 only for unusable email, 429 on the rate limit (pre-DB). Sits under the existing /v1/agent authLimiter too. - assets/verifyEmailHtml.ts: 'Didn't get the email?' plain-HTML resend form on the expired/invalid-link error page (zero JS, per the emailed-link page rules) + neutral renderVerifyResendSentPage(). - assets/signupHtml.ts: verify note + click-only resend link on the signup success card (response mirrored via textContent). - routes/agents.ts + stale comments now point at the real route. - tests/verify-resend.test.mjs (13 tests, wired into npm test): anti-enumeration byte-identical bodies, credit fields never touched, cooldown, rate limit incl. gmail-alias normalization, form-post HTML, neutral 200 on internal DB error. Co-Authored-By: Claude Fable 5 --- api/package.json | 3 +- api/src/assets/signupHtml.ts | 37 +++++ api/src/assets/verifyEmailHtml.ts | 31 +++- api/src/lib/verification.ts | 83 +++++++++- api/src/routes/agent.ts | 50 +++++- api/src/routes/agents.ts | 4 +- api/tests/verify-resend.test.mjs | 258 ++++++++++++++++++++++++++++++ 7 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 api/tests/verify-resend.test.mjs diff --git a/api/package.json b/api/package.json index d3f3c62b..ca14605b 100644 --- a/api/package.json +++ b/api/package.json @@ -16,9 +16,10 @@ "db:cleanup": "node dist/cron/dbCleanup.js", "funnel-report": "node dist/scripts/funnelReport.js", "npm:audit": "npm audit --json > /tmp/audit.json && echo \"Audit complete\"", - "test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs", + "test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs", "test:integration": "node tests/integration.test.js", "test:verify-activation": "node tests/verify-activation.test.mjs", + "test:verify-resend": "node tests/verify-resend.test.mjs", "test:oauth-refresh-race": "node tests/oauth-refresh-race.test.js", "test:pages": "node tests/pages.test.js", "test:rate-limit": "node tests/global-rate-limit.test.js", diff --git a/api/src/assets/signupHtml.ts b/api/src/assets/signupHtml.ts index 2de900d9..f34b04ee 100644 --- a/api/src/assets/signupHtml.ts +++ b/api/src/assets/signupHtml.ts @@ -169,6 +169,11 @@ export const SIGNUP_HTML = ` // is safe to embed in a double-quoted href. var oauthNext = ''; + // Email of the account created in THIS page session (set on successful + // register) — powers the verify-note resend link. Empty on the OAuth + // ?key= arrival path, where the email isn't known, so no link renders. + var signupEmail = ''; + (function() { const params = new URLSearchParams(window.location.search); const preKey = params.get('key'); @@ -224,6 +229,12 @@ export const SIGNUP_HTML = ` refNote = '
🎁 Referral code ' + savedRef + ' saved. Verify your email, then apply it from your dashboard — you and your referrer each get bonus credits.
'; } } catch(_) {} + // Verify note + resend recovery (only when we know the email that was + // just registered — not on the OAuth ?key= arrival path). + var verifyNote = ''; + if (signupEmail) { + verifyNote = '
Next: verify your email from the link we just sent to unlock your remaining pending credits. Didn't get the email? Resend it.
'; + } // OAuth resume: opt-in link back to the preserved /oauth/authorize URL // so consent can finish. oauthNext was validated at load (same-origin // authorize path only, no quote/angle/whitespace chars). @@ -238,6 +249,7 @@ export const SIGNUP_HTML = ` '
' + apiKey + '
' + '' + '
You have ' + credits + ' credits to get started. Refreshed monthly on the free plan. No subscription required.
' + + verifyNote + refNote + resumeCta + '
' + @@ -263,6 +275,29 @@ export const SIGNUP_HTML = ` }); }); } + // Resend wiring: fires ONLY from an explicit click (never auto), posts + // the email as JSON, and mirrors the server's neutral message via + // textContent so nothing from the response is interpreted as markup. + var resendLink = document.getElementById('resend-verify-link'); + if (resendLink) { + resendLink.addEventListener('click', function(e) { + e.preventDefault(); + resendLink.textContent = 'Sending...'; + fetch('/v1/agent/verify-email/resend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: signupEmail }) + }).then(function(r) { + return r.json().catch(function() { return {}; }); + }).then(function(d) { + var note = document.getElementById('verify-note'); + if (note) note.textContent = (d && d.message) ? String(d.message) : 'Requested — check your inbox and spam folder.'; + }).catch(function() { + var note = document.getElementById('verify-note'); + if (note) note.textContent = 'Could not reach the server — please try again in a minute.'; + }); + }); + } // Curl example with the fresh key prefilled. Set via textContent (never // string-built HTML) so the key can never be interpreted as markup. var curlEl = document.getElementById('first-call-curl'); @@ -361,6 +396,8 @@ export const SIGNUP_HTML = ` let data; try { data = await res.json(); } catch(_) { data = {}; } if (res.ok && data.api_key) { + // Remember the email for the verify-note resend link. + signupEmail = email; // If password provided, set it and log in via session cookie const pw = (document.getElementById('password').value || '').trim(); if (pw && pw.length >= 8) { diff --git a/api/src/assets/verifyEmailHtml.ts b/api/src/assets/verifyEmailHtml.ts index b6318f05..6945df60 100644 --- a/api/src/assets/verifyEmailHtml.ts +++ b/api/src/assets/verifyEmailHtml.ts @@ -167,7 +167,11 @@ export function renderVerifyActivationPage(creditsActivated: number): string { `); } -/** Invalid / expired / already-used token. */ +/** + * Invalid / expired / already-used token — with a script-free recovery form + * that POSTs to /v1/agent/verify-email/resend (plain HTML form: these pages + * are reachable from an emailed link, so zero JavaScript by design). + */ export function renderVerifyErrorPage(): string { return pageShell("Link invalid or expired", `

Link invalid or expired

@@ -175,5 +179,30 @@ export function renderVerifyErrorPage(): string { If you clicked it before, your email may already be verified — sign in to your dashboard to check your status and credit balance.

Open dashboard → +
+

Didn't get the email — or did the link expire?

+

Enter your account email and we'll send a fresh verification link (valid for 30 minutes).

+
+ + +
+
+ `); +} + +/** + * Neutral resend confirmation (anti-enumeration) — rendered for browser form + * posts to /v1/agent/verify-email/resend. IDENTICAL for every input: it never + * confirms whether an account exists, is verified, or was inside the resend + * cooldown. Script-free like every page reachable from an emailed link. + */ +export function renderVerifyResendSentPage(): string { + return pageShell("Check your inbox", ` +

Check your inbox

+

If an unverified account exists for that email, a new verification + link is on its way (valid for 30 minutes). Don't see it after a couple of minutes? + Check your spam folder.

+ Open dashboard → `); } diff --git a/api/src/lib/verification.ts b/api/src/lib/verification.ts index ce841bf2..cbb3f99f 100644 --- a/api/src/lib/verification.ts +++ b/api/src/lib/verification.ts @@ -203,7 +203,8 @@ export function recordSignupIp(ip: string | undefined): void { * Set up the verification gate for a freshly-created agent: activates a small * starter allowance immediately (SIGNUP_STARTER_CREDITS), moves the remainder * of the grant into pendingCredits, issues a token, sends the email. - * Non-fatal on email failure (token can be re-issued via /v1/verify-email/resend). + * Non-fatal on email failure (token can be re-issued via + * POST /v1/agent/verify-email/resend → reissueEmailVerification below). * * The whole grant (starter + pending) is gated by an ATOMIC claim on the * normalized email identity (SignupIdentity unique insert). If the identity @@ -247,6 +248,86 @@ export async function issueEmailVerification( return { starter, pending }; } +// ─── Verification resend (recovery for expired/lost tokens) ────────────── + +/** + * Resend rate limit: N requests per calendar hour per (normalized email + IP) + * key. Same in-process Map pattern as the per-IP signup counter above — + * conservative, resets on deploy, counts EVERY attempt (before any DB read) + * so the limiter itself can never become an account-enumeration oracle. + */ +const VERIFY_RESEND_MAX_PER_HOUR = parseInt(process.env.VERIFY_RESEND_MAX_PER_HOUR ?? "3", 10); +const resendCounts = new Map(); + +export function allowVerificationResend(email: string, ip: string | undefined): boolean { + const hour = new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH + const key = `${normalizeEmailIdentity(email)}|${ip ?? "unknown"}`; + const entry = resendCounts.get(key); + const count = entry && entry.hour === hour ? entry.count : 0; + if (count >= VERIFY_RESEND_MAX_PER_HOUR) return false; + resendCounts.set(key, { hour, count: count + 1 }); + // Opportunistic cleanup to bound memory (mirrors ipSignupCounts) + if (resendCounts.size > 10000) { + for (const [k, v] of resendCounts) { + if (v.hour !== hour) resendCounts.delete(k); + } + } + return true; +} + +/** + * Cooldown between token issues: if the CURRENT token was minted within this + * window, a resend is silently skipped — the email is already on its way, and + * re-minting on every click would let the endpoint be used as a mail cannon + * inside the hourly rate window. + */ +export const VERIFY_RESEND_COOLDOWN_MS = parseInt( + process.env.VERIFY_RESEND_COOLDOWN_MS ?? String(2 * 60 * 1000), + 10 +); + +/** + * Re-issue a verification token for an existing UNVERIFIED account (recovery + * path for expired/lost tokens: POST /v1/agent/verify-email/resend). + * + * ⚠️ Deliberately NOT issueEmailVerification(): that function is the SIGNUP + * grant-splitter — on a non-fresh agent the identity claim has already been + * consumed, so it would compute pending=0 and SET pendingCredits to 0, wiping + * the user's gated grant. This function rotates ONLY verifyToken + + * verifyTokenExpiry and never touches any credit field. + * + * Silent no-op (returns false) when the account doesn't exist, is already + * verified, or a live token is inside the resend cooldown — callers must NOT + * branch their response on the return value (anti-enumeration). + */ +export async function reissueEmailVerification(email: string): Promise { + const agent = await prisma.agent.findUnique({ + where: { email }, + select: { id: true, emailVerified: true, pendingCredits: true, verifyTokenExpiry: true }, + }); + if (!agent || agent.emailVerified) return false; + if (agent.verifyTokenExpiry) { + // Tokens live VERIFY_TOKEN_TTL_MS, so issue time = expiry − TTL. An + // EXPIRED token was minted ≥30 min ago and always passes this gate. + const issuedAt = agent.verifyTokenExpiry.getTime() - VERIFY_TOKEN_TTL_MS; + if (Date.now() - issuedAt < VERIFY_RESEND_COOLDOWN_MS) return false; + } + const token = crypto.randomBytes(32).toString("hex"); + await prisma.agent.update({ + where: { id: agent.id }, + data: { + verifyToken: token, + verifyTokenExpiry: new Date(Date.now() + VERIFY_TOKEN_TTL_MS), + }, + }); + const verifyUrl = `https://archtools.dev/v1/agent/verify-email?token=${token}`; + sendVerificationEmail({ to: email, verifyUrl, pendingCredits: agent.pendingCredits }).catch((e) => { + logger.warn({ agentId: agent.id, error: String(e) }, "Verification resend email failed"); + }); + logger.info({ agentId: agent.id }, "Verification token re-issued"); + return true; +} + /** * Non-consuming validity check for a verify token (used by the GET confirm * page). Returns the pending credits the token would activate, or null if the diff --git a/api/src/routes/agent.ts b/api/src/routes/agent.ts index ebe5047c..08f15e53 100644 --- a/api/src/routes/agent.ts +++ b/api/src/routes/agent.ts @@ -9,8 +9,8 @@ import { stripe } from "../lib/stripe.js"; import Stripe from "stripe"; import crypto from "crypto"; import bcrypt from "bcryptjs"; -import { SIGNUP_FREE_CREDITS, isDisposableEmail, issueEmailVerification, verifyEmailToken, peekEmailVerifyToken, enforceSignupLimits, recordSignupIp, normalizeEmailIdentity } from "../lib/verification.js"; -import { VERIFY_TOKEN_RE, renderVerifyConfirmPage, renderVerifyActivationPage, renderVerifyErrorPage } from "../assets/verifyEmailHtml.js"; +import { SIGNUP_FREE_CREDITS, isDisposableEmail, issueEmailVerification, verifyEmailToken, peekEmailVerifyToken, enforceSignupLimits, recordSignupIp, normalizeEmailIdentity, allowVerificationResend, reissueEmailVerification } from "../lib/verification.js"; +import { VERIFY_TOKEN_RE, renderVerifyConfirmPage, renderVerifyActivationPage, renderVerifyErrorPage, renderVerifyResendSentPage } from "../assets/verifyEmailHtml.js"; import { REFERRAL_REWARD } from "../lib/referralReward.js"; const router = Router(); @@ -186,8 +186,9 @@ router.post("/register", async (req: Request, res: Response): Promise => { gatedCredits = grant.pending; } catch (e) { // Fail closed: do NOT grant credits if the verification gate could not be - // set up. The user can re-trigger via the resend endpoint. - console.error("Verification setup failed (credits remain 0, user can resend):", e); + // set up. Recovery: POST /v1/agent/verify-email/resend (below) re-issues + // the token for any unverified account. + console.error("Verification setup failed (credits remain 0, user can resend via /v1/agent/verify-email/resend):", e); } res.status(201).json({ @@ -285,6 +286,47 @@ router.post("/verify-email", async (req: Request, res: Response): Promise } }); +// POST /v1/agent/verify-email/resend — recovery for expired/lost verification +// links: re-issues the token for an existing UNVERIFIED account and re-sends +// the email. ANTI-ENUMERATION: the response is ALWAYS the same neutral 200 +// (JSON for API clients, a script-free HTML page for browser form posts) +// whether the account exists, is already verified, or is inside the resend +// cooldown — nothing about account state is ever revealed. The only non-200s +// are 400 (no usable email supplied) and 429 (rate limit, counted on the +// SUBMITTED email+IP BEFORE any DB read, so it can't leak existence either). +router.post("/verify-email/resend", async (req: Request, res: Response): Promise => { + const email = String((req.body?.email ?? "")).toLowerCase().trim(); + // Browser form posts (Accept: text/html) get a page; fetch/API clients + // (Accept: */* or application/json) get JSON — json listed first wins */*. + const wantsHtml = req.accepts(["json", "html"]) === "html"; + if (!email || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.status(400).json({ ok: false, error: "invalid_request", message: "A valid email is required.", request_id: reqId() }); + return; + } + if (!allowVerificationResend(email, req.ip)) { + res.status(429).json({ ok: false, error: "rate_limited", message: "Too many resend requests for this email. Try again in an hour.", request_id: reqId() }); + return; + } + try { + // Return value intentionally ignored — the response below is identical + // for every internal outcome (see anti-enumeration note above). + await reissueEmailVerification(email); + } catch (e) { + // Still neutral: an internal failure must not become an enumeration or + // availability oracle. + console.error("verify-email resend error:", e); + } + if (wantsHtml) { + res.send(renderVerifyResendSentPage()); + return; + } + res.json({ + ok: true, + message: "If an unverified account exists for that email, a new verification link has been sent. Check your inbox and spam folder.", + request_id: reqId(), + }); +}); + // GET /v1/agent/usage router.get("/usage", requireAuth, async (req: AuthedRequest, res: Response): Promise => { const agent = req.agent; diff --git a/api/src/routes/agents.ts b/api/src/routes/agents.ts index 3423e6b6..532ecaf9 100644 --- a/api/src/routes/agents.ts +++ b/api/src/routes/agents.ts @@ -336,7 +336,9 @@ router.post("/register", async (req: Request, res: Response): Promise => { starterCredits = grant.starter; gatedCredits = grant.pending; } catch (e) { - console.error("Verification setup failed (credits remain 0, user can resend):", e); + // Recovery: POST /v1/agent/verify-email/resend re-issues the token for + // any unverified account. + console.error("Verification setup failed (credits remain 0, user can resend via /v1/agent/verify-email/resend):", e); } res.status(201).json({ diff --git a/api/tests/verify-resend.test.mjs b/api/tests/verify-resend.test.mjs new file mode 100644 index 00000000..c9c9f711 --- /dev/null +++ b/api/tests/verify-resend.test.mjs @@ -0,0 +1,258 @@ +/** + * Verify-email RESEND endpoint — regression tests (2026-07-28). + * + * POST /v1/agent/verify-email/resend is the recovery path for expired/lost + * verification links. Covers: + * - ANTI-ENUMERATION: byte-identical neutral 200 body for unknown email, + * already-verified account, and cooldown no-op — account state never leaks. + * - Happy path: existing unverified account with an expired token gets a + * fresh token (single write rotating ONLY verifyToken/verifyTokenExpiry — + * credit fields must NEVER be touched; the old issueEmailVerification + * path would have zeroed pendingCredits). + * - Cooldown: a token minted moments ago is NOT re-minted. + * - Rate limit: 4th request for the same email+IP within the hour → 429, + * counted BEFORE any DB read (unknown emails burn the same budget). + * - Browser form posts get a neutral script-free HTML page. + * - Recovery links render on the verify error page + signup success card. + * + * Uses the built dist with a stubbed prisma layer (same pattern as + * verify-activation.test.mjs) plus a real express app on an ephemeral port. + * + * Run: cd api && npm run build && node tests/verify-resend.test.mjs + */ +import assert from "assert"; + +process.env.DATABASE_URL ??= "postgresql://stub:stub@127.0.0.1:5432/stub"; + +const { prisma } = await import("../dist/lib/prisma.js"); +const { renderVerifyErrorPage, renderVerifyResendSentPage } = await import("../dist/assets/verifyEmailHtml.js"); +const { SIGNUP_HTML } = await import("../dist/assets/signupHtml.js"); + +let failures = 0; +function test(name, fn) { + try { fn(); console.log(` ✓ ${name}`); } + catch (e) { failures++; console.error(` ✗ ${name}: ${e.message}`); } +} +async function atest(name, fn) { + try { await fn(); console.log(` ✓ ${name}`); } + catch (e) { failures++; console.error(` ✗ ${name}: ${e.message}`); } +} + +// Pages reachable from an emailed link must be credential-free and inert. +function assertCredentialFree(html, label) { + assert.ok(!/arch_[a-f0-9]/i.test(html), `${label}: must not contain an API key`); + assert.ok(!html.toLowerCase().includes("localstorage"), `${label}: must not touch localStorage`); + assert.ok(!html.toLowerCase().includes("sessionstorage"), `${label}: must not touch sessionStorage`); + assert.ok(!html.toLowerCase().includes(" { + const html = renderVerifyErrorPage(); + assert.ok(html.includes('action="/v1/agent/verify-email/resend"'), "form targets the resend route"); + assert.ok(html.includes('method="POST"'), "form must POST"); + assert.ok(html.includes('type="email"'), "email input present"); + assert.ok(html.toLowerCase().includes("didn't get the email"), "recovery copy present"); + assertCredentialFree(html, "error page"); +}); + +test("resend-sent page: neutral (no existence claim), script-free", () => { + const html = renderVerifyResendSentPage(); + assert.ok(html.includes("If an unverified account exists"), "conditional, non-confirming copy"); + assert.ok(!html.toLowerCase().includes("account found"), "never confirms existence"); + assertCredentialFree(html, "resend-sent page"); +}); + +test("signup success card: resend link wired to the resend route, click-only", () => { + assert.ok(SIGNUP_HTML.includes("fetch('/v1/agent/verify-email/resend'"), "posts to the resend route"); + assert.ok(SIGNUP_HTML.includes('id="resend-verify-link"'), "resend link present"); + assert.ok(SIGNUP_HTML.includes("resendLink.addEventListener('click'"), "fires only on click"); + // server message mirrored via textContent, never innerHTML + assert.ok(SIGNUP_HTML.includes("note.textContent = (d && d.message)"), "response rendered inert"); + assert.ok(!/innerHTML\s*=\s*\(d && d\.message\)/.test(SIGNUP_HTML), "no markup-capable sink for the response"); +}); + +// ─── 2. Route behavior — real express app, stubbed prisma ─────────────────── +console.log("\nRoute behavior:"); + +const { default: agentRouter } = await import("../dist/routes/agent.js"); +const express = (await import("express")).default; +const app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use("/v1/agent", agentRouter); +const server = app.listen(0); +const BASE = `http://127.0.0.1:${server.address().port}`; + +// prisma stubs +let agentRow = null; // what findUnique returns +let updates = []; // captured update() calls +prisma.agent.findUnique = async () => agentRow; +prisma.agent.update = async (args) => { updates.push(args); return {}; }; + +async function postResend(email, headers = {}) { + const res = await fetch(`${BASE}/v1/agent/verify-email/resend`, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ email }), + }); + const text = await res.text(); + return { res, text }; +} + +// The exact neutral body every internal outcome must produce (modulo request_id). +function neutralShape(text) { + const body = JSON.parse(text); + assert.strictEqual(body.ok, true); + assert.ok(body.message.includes("If an unverified account exists"), "neutral conditional message"); + delete body.request_id; + return JSON.stringify(body); +} + +await atest("happy path: unverified account + EXPIRED token → 200, single token-only rotation", async () => { + updates = []; + agentRow = { + id: "agent-resend-1", + emailVerified: false, + pendingCredits: 75, + verifyTokenExpiry: new Date(Date.now() - 60_000), // expired 1 min ago (issued 31 min ago) + }; + const { res, text } = await postResend("stranded@example.com"); + assert.strictEqual(res.status, 200); + neutralShape(text); + assert.strictEqual(updates.length, 1, "exactly one write"); + const data = updates[0].data; + assert.ok(/^[a-f0-9]{64}$/.test(data.verifyToken), "fresh 64-hex token minted"); + assert.ok(data.verifyTokenExpiry > new Date(), "new expiry in the future"); + // CRITICAL: the resend must never touch credit fields (the signup-time + // issueEmailVerification would have SET pendingCredits to 0 here). + for (const forbidden of ["credits", "pendingCredits", "emailVerified"]) { + assert.ok(!(forbidden in data), `resend must not write ${forbidden}`); + } +}); + +let happyBody; +await atest("anti-enumeration: unknown email → SAME 200 body, zero writes", async () => { + updates = []; + agentRow = { id: "x", emailVerified: false, pendingCredits: 75, verifyTokenExpiry: new Date(Date.now() - 60_000) }; + const known = await postResend("known-unverified@example.com"); + const knownShape = neutralShape(known.text); + happyBody = knownShape; + + updates = []; + agentRow = null; // account does not exist + const unknown = await postResend("nobody@example.com"); + assert.strictEqual(unknown.res.status, 200, "unknown email still 200"); + assert.strictEqual(neutralShape(unknown.text), knownShape, "byte-identical body shape"); + assert.strictEqual(updates.length, 0, "no writes for unknown email"); +}); + +await atest("anti-enumeration: already-verified account → SAME 200 body, zero writes", async () => { + updates = []; + agentRow = { id: "agent-v", emailVerified: true, pendingCredits: 0, verifyTokenExpiry: null }; + const { res, text } = await postResend("verified@example.com"); + assert.strictEqual(res.status, 200); + assert.strictEqual(neutralShape(text), happyBody, "identical to the happy-path body"); + assert.strictEqual(updates.length, 0, "no token minted for a verified account"); +}); + +await atest("cooldown: token minted moments ago → 200 but NOT re-minted", async () => { + updates = []; + agentRow = { + id: "agent-cool", + emailVerified: false, + pendingCredits: 75, + verifyTokenExpiry: new Date(Date.now() + TTL_MS - 1000), // issued ~1s ago + }; + const { res, text } = await postResend("justsent@example.com"); + assert.strictEqual(res.status, 200); + assert.strictEqual(neutralShape(text), happyBody, "cooldown is invisible in the response"); + assert.strictEqual(updates.length, 0, "no re-mint inside the cooldown"); +}); + +await atest("no-token unverified account (failed signup setup) → recoverable", async () => { + updates = []; + agentRow = { id: "agent-notoken", emailVerified: false, pendingCredits: 0, verifyTokenExpiry: null }; + const { res } = await postResend("failedsetup@example.com"); + assert.strictEqual(res.status, 200); + assert.strictEqual(updates.length, 1, "token issued even when none existed"); +}); + +await atest("rate limit: 4th request for the same email+IP within the hour → 429 (pre-DB)", async () => { + agentRow = null; + let dbReads = 0; + prisma.agent.findUnique = async () => { dbReads++; return null; }; + const email = "ratelimit-me@example.com"; + for (let i = 0; i < 3; i++) { + const { res } = await postResend(email); + assert.strictEqual(res.status, 200, `request ${i + 1} allowed`); + } + const readsBefore = dbReads; + const { res, text } = await postResend(email); + assert.strictEqual(res.status, 429, "4th request blocked"); + const body = JSON.parse(text); + assert.strictEqual(body.error, "rate_limited"); + assert.strictEqual(dbReads, readsBefore, "429 issued before any DB read"); + // a DIFFERENT email from the same IP is not collateral damage + const other = await postResend("someone-else@example.com"); + assert.strictEqual(other.res.status, 200, "per-email key, not a blanket IP ban"); + prisma.agent.findUnique = async () => agentRow; +}); + +await atest("gmail dot/plus aliases share one rate-limit budget (no farming around it)", async () => { + agentRow = null; + for (const alias of ["rl.alias@gmail.com", "rlalias+1@gmail.com", "r.l.alias@googlemail.com"]) { + const { res } = await postResend(alias); + assert.strictEqual(res.status, 200, `${alias} allowed`); + } + const { res } = await postResend("rlalias@gmail.com"); + assert.strictEqual(res.status, 429, "normalized-identity budget exhausted"); +}); + +await atest("browser form post (urlencoded + Accept: text/html) → neutral script-free HTML page", async () => { + agentRow = null; + const res = await fetch(`${BASE}/v1/agent/verify-email/resend`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + body: "email=formuser%40example.com", + }); + const html = await res.text(); + assert.strictEqual(res.status, 200); + assert.ok(html.includes("If an unverified account exists"), "neutral copy"); + assert.ok(!html.includes("formuser"), "submitted email never reflected"); + assertCredentialFree(html, "resend HTML response"); +}); + +await atest("missing/garbage email → 400 invalid_request (never a 500)", async () => { + for (const bad of [undefined, "", "notanemail", "a@b", "x".repeat(255) + "@example.com"]) { + const res = await fetch(`${BASE}/v1/agent/verify-email/resend`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bad === undefined ? {} : { email: bad }), + }); + assert.strictEqual(res.status, 400, `rejected: ${String(bad).slice(0, 20)}`); + const body = await res.json(); + assert.strictEqual(body.error, "invalid_request"); + } +}); + +await atest("internal DB error → still the neutral 200 (no availability oracle)", async () => { + prisma.agent.findUnique = async () => { throw new Error("db down"); }; + const { res, text } = await postResend("dbdown@example.com"); + assert.strictEqual(res.status, 200); + assert.strictEqual(neutralShape(text), happyBody); + prisma.agent.findUnique = async () => agentRow; +}); + +server.close(); + +if (failures) { console.error(`\n${failures} failed`); process.exit(1); } +console.log("\nall verify-resend tests passed"); +process.exit(0); From 8fb41afc9d8e54f63dd1c14ec63fd06942990f82 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 21:03:10 +0000 Subject: [PATCH 2/2] fix(activation): resolve verify-email resend by normalized email identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reissueEmailVerification looked up the agent with an exact findUnique on the submitted email, but signup stores the raw lowercased input — so a Gmail dot/plus/googlemail alias of the stored address returned null and the resend silently no-op'd behind the anti-enumeration 200, leaving pending credits locked. Resolve via the same normalized-identity SQL as enforceSignupLimits (exact match preferred, oldest account fallback) and send the mail to the stored Agent.email. Tests: stub point moved from prisma.agent.findUnique to the normalized $queryRaw lookup; new regression asserts an alias submission still finds the stored dotted account and rotates its token. --- api/src/lib/verification.ts | 34 +++++++++++++++++++++----- api/tests/verify-resend.test.mjs | 42 ++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/api/src/lib/verification.ts b/api/src/lib/verification.ts index cbb3f99f..c4ce260d 100644 --- a/api/src/lib/verification.ts +++ b/api/src/lib/verification.ts @@ -301,11 +301,33 @@ export const VERIFY_RESEND_COOLDOWN_MS = parseInt( * branch their response on the return value (anti-enumeration). */ export async function reissueEmailVerification(email: string): Promise { - const agent = await prisma.agent.findUnique({ - where: { email }, - select: { id: true, emailVerified: true, pendingCredits: true, verifyTokenExpiry: true }, - }); - if (!agent || agent.emailVerified) return false; + // Resolve by NORMALIZED identity (same SQL as enforceSignupLimits), not an + // exact match: signup stores the raw lowercased input, so the stored address + // may be a Gmail dot/plus/googlemail alias of what the user types here. + // Prefer an exact match, then the oldest account, for determinism; the mail + // goes to the STORED Agent.email, never the submitted string. + const lowered = email.toLowerCase().trim(); + const normalized = normalizeEmailIdentity(email); + const rows = await prisma.$queryRaw< + { id: string; email: string; email_verified: boolean; pending_credits: number; verify_token_expiry: Date | null }[] + >` + SELECT "id", "email", "email_verified", "pending_credits", "verify_token_expiry" FROM "Agent" + WHERE ( + CASE WHEN lower(split_part(email, '@', 2)) IN ('gmail.com', 'googlemail.com') + THEN replace(split_part(split_part(lower(email), '@', 1), '+', 1), '.', '') || '@gmail.com' + ELSE lower(split_part(email, '@', 1)) || '@' || lower(split_part(email, '@', 2)) + END + ) = ${normalized} + ORDER BY (lower(email) = ${lowered}) DESC, "createdAt" ASC + LIMIT 1`; + const row = rows[0]; + if (!row || row.email_verified) return false; + const agent = { + id: row.id, + email: row.email, + pendingCredits: row.pending_credits, + verifyTokenExpiry: row.verify_token_expiry, + }; if (agent.verifyTokenExpiry) { // Tokens live VERIFY_TOKEN_TTL_MS, so issue time = expiry − TTL. An // EXPIRED token was minted ≥30 min ago and always passes this gate. @@ -321,7 +343,7 @@ export async function reissueEmailVerification(email: string): Promise }, }); const verifyUrl = `https://archtools.dev/v1/agent/verify-email?token=${token}`; - sendVerificationEmail({ to: email, verifyUrl, pendingCredits: agent.pendingCredits }).catch((e) => { + sendVerificationEmail({ to: agent.email, verifyUrl, pendingCredits: agent.pendingCredits }).catch((e) => { logger.warn({ agentId: agent.id, error: String(e) }, "Verification resend email failed"); }); logger.info({ agentId: agent.id }, "Verification token re-issued"); diff --git a/api/tests/verify-resend.test.mjs b/api/tests/verify-resend.test.mjs index c9c9f711..924c1a1d 100644 --- a/api/tests/verify-resend.test.mjs +++ b/api/tests/verify-resend.test.mjs @@ -88,10 +88,22 @@ app.use("/v1/agent", agentRouter); const server = app.listen(0); const BASE = `http://127.0.0.1:${server.address().port}`; -// prisma stubs -let agentRow = null; // what findUnique returns +// prisma stubs — reissueEmailVerification resolves the account via a +// normalized-identity $queryRaw (Gmail dot/plus/googlemail alias forms must +// match the stored row), so the raw query is the stub point. Rows come back +// with the raw snake_case column names. +let agentRow = null; // what the normalized-identity lookup returns let updates = []; // captured update() calls -prisma.agent.findUnique = async () => agentRow; +let lookupParams = []; // captured $queryRaw bind values +const rawRow = () => ({ + id: agentRow.id, + email: agentRow.email ?? "stored@example.com", + email_verified: agentRow.emailVerified, + pending_credits: agentRow.pendingCredits, + verify_token_expiry: agentRow.verifyTokenExpiry, +}); +const stubLookup = async (_strings, ...values) => { lookupParams = values; return agentRow ? [rawRow()] : []; }; +prisma.$queryRaw = stubLookup; prisma.agent.update = async (args) => { updates.push(args); return {}; }; async function postResend(email, headers = {}) { @@ -182,10 +194,26 @@ await atest("no-token unverified account (failed signup setup) → recoverable", assert.strictEqual(updates.length, 1, "token issued even when none existed"); }); +await atest("gmail alias submitted → stored dotted account still found, token rotated", async () => { + updates = []; + agentRow = { + id: "agent-alias", + email: "j.doe.stored@gmail.com", // signup stored the dotted form + emailVerified: false, + pendingCredits: 75, + verifyTokenExpiry: new Date(Date.now() - 60_000), // expired + }; + const { res, text } = await postResend("jdoestored+recover@googlemail.com"); + assert.strictEqual(res.status, 200); + assert.strictEqual(neutralShape(text), happyBody); + assert.ok(lookupParams.includes("jdoestored@gmail.com"), "lookup queries the NORMALIZED identity"); + assert.strictEqual(updates.length, 1, "stored account's token rotated despite the alias mismatch"); +}); + await atest("rate limit: 4th request for the same email+IP within the hour → 429 (pre-DB)", async () => { agentRow = null; let dbReads = 0; - prisma.agent.findUnique = async () => { dbReads++; return null; }; + prisma.$queryRaw = async () => { dbReads++; return []; }; const email = "ratelimit-me@example.com"; for (let i = 0; i < 3; i++) { const { res } = await postResend(email); @@ -200,7 +228,7 @@ await atest("rate limit: 4th request for the same email+IP within the hour → 4 // a DIFFERENT email from the same IP is not collateral damage const other = await postResend("someone-else@example.com"); assert.strictEqual(other.res.status, 200, "per-email key, not a blanket IP ban"); - prisma.agent.findUnique = async () => agentRow; + prisma.$queryRaw = stubLookup; }); await atest("gmail dot/plus aliases share one rate-limit budget (no farming around it)", async () => { @@ -244,11 +272,11 @@ await atest("missing/garbage email → 400 invalid_request (never a 500)", async }); await atest("internal DB error → still the neutral 200 (no availability oracle)", async () => { - prisma.agent.findUnique = async () => { throw new Error("db down"); }; + prisma.$queryRaw = async () => { throw new Error("db down"); }; const { res, text } = await postResend("dbdown@example.com"); assert.strictEqual(res.status, 200); assert.strictEqual(neutralShape(text), happyBody); - prisma.agent.findUnique = async () => agentRow; + prisma.$queryRaw = stubLookup; }); server.close();