🎁 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..c4ce260d 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,108 @@ 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 {
+ // 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.
+ 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: 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");
+ 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..924c1a1d
--- /dev/null
+++ b/api/tests/verify-resend.test.mjs
@@ -0,0 +1,286 @@
+/**
+ * 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("