Skip to content

feat(activation): verify-email resend endpoint + recovery links (anti-enumeration) - #97

Merged
Deesmo merged 3 commits into
mainfrom
fix/verify-resend
Jul 28, 2026
Merged

feat(activation): verify-email resend endpoint + recovery links (anti-enumeration)#97
Deesmo merged 3 commits into
mainfrom
fix/verify-resend

Conversation

@Deesmo

@Deesmo Deesmo commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Problem

Comments in verification.ts/agent.ts/agents.ts have referenced /v1/verify-email/resend since the verification gate shipped — but the endpoint was never built. A user whose 30-minute verify token expires (or whose email never arrives) is permanently stranded: 75 pending credits they can never activate, and no recovery path. That user will never convert.

What this adds

POST /v1/agent/verify-email/resend — accepts {email} (JSON or urlencoded form).

  • Anti-enumeration by construction: ALWAYS the same neutral 200 body ("If an unverified account exists for that email…") whether the account exists, is already verified, or is inside the resend cooldown. JSON for API clients, a script-free HTML page for browser form posts (Accept-negotiated). Internal DB errors also return the neutral 200 — no availability oracle.
  • Rate limit: 3/hour per normalized email + IP (same in-process Map pattern as ipSignupCounts; gmail dot/plus aliases share one budget), counted before any DB read so the 429 can't leak existence either. The existing /v1/agent authLimiter (20/15min/IP) still applies on top.
  • Cooldown: if the current token was minted <2 min ago the resend is a silent no-op — the endpoint can't be used as a mail cannon inside the hourly window.
  • Internals: new reissueEmailVerification() rotates ONLY verifyToken/verifyTokenExpiry and re-sends the email with the account's CURRENT pendingCredits.

⚠️ Why not just call issueEmailVerification() again (as the stale comments implied): that function is the signup grant-splitter. On a non-fresh agent the SignupIdentity claim is already consumed → gated=0 → it would SET pendingCredits to 0, wiping the user's 75 gated credits. The resend path deliberately never touches any credit field, and the tests assert that.

Recovery links

  • Expired/invalid-link error page (verifyEmailHtml.ts): "Didn't get the email — or did the link expire?" plain-HTML form (zero JavaScript, per the emailed-link page rules) posting to the new route; browser gets a neutral script-free confirmation page.
  • Signup success card (signupHtml.ts): verify note + "Didn't get the email? Resend it" link — fires only on explicit click, mirrors the server's neutral message via textContent.
  • Stale comments in verification.ts / agent.ts / agents.ts now point at the real route.

Tests

New tests/verify-resend.test.mjs (13 tests, wired into npm test):

  • anti-enumeration: byte-identical 200 bodies for unknown email / verified account / cooldown no-op
  • happy path: expired token → single write rotating only token fields; asserts credits/pendingCredits/emailVerified are never written
  • no-token unverified account (failed signup setup) is recoverable
  • rate limit: 4th request → 429 pre-DB; per-email key (no blanket IP ban); gmail alias normalization shares the budget
  • browser form post → neutral, script-free, submitted email never reflected
  • garbage email → 400; internal DB error → neutral 200

Verification

  • npm run build exit 0
  • node tests/verify-resend.test.mjs — 13/13 pass
  • Adjacent suites re-run green: verify-activation, signup-firstcall, oauth-signup-cta, pages, critical-regressions (all exit 0)

Council constraints respected: conversion/stop-bleed only, no payment-rail changes, nothing auto-fires, all copy factual, no user input reflected into HTML.

🤖 Generated with Claude Code


Note

Medium Risk
Touches signup/verification and outbound email with enumeration and abuse controls; credit fields are intentionally untouched but behavior is security-sensitive.

Overview
Adds POST /v1/agent/verify-email/resend so users with expired or missing verification emails can get a new link without calling signup-time issueEmailVerification() again (that path would wipe pendingCredits on existing accounts).

The handler always returns the same neutral 200 (JSON or script-free HTML via Accept), with 429 only from an hourly per normalized-email+IP limiter that runs before any DB read. reissueEmailVerification() rotates only verifyToken / verifyTokenExpiry, enforces a short mint cooldown, and re-sends mail using the current pending balance.

Recovery surfaces: a no-JS form on the invalid-link page, a click-only resend on the signup success card, and a neutral “check your inbox” page for form posts. tests/verify-resend.test.mjs is wired into npm test.

Reviewed by Cursor Bugbot for commit e4f6e12. Configure here.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Gmail alias resend lookup fails
    • reissueEmailVerification now resolves the agent via the same normalized-identity SQL as enforceSignupLimits (Gmail dot/plus/googlemail aliases match the stored row) and sends to the stored Agent.email, verified end-to-end against a local DB and covered by a new regression test.

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit e4f6e12. Configure here.

Comment thread api/src/lib/verification.ts Outdated
const agent = await prisma.agent.findUnique({
where: { email },
select: { id: true, emailVerified: true, pendingCredits: true, verifyTokenExpiry: true },
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gmail alias resend lookup fails

High Severity

The reissueEmailVerification function uses findUnique on the submitted email, but the email stored during signup might be a non-canonical form (e.g., Gmail dot/plus aliases). This mismatch prevents finding the agent, causing the resend to silently fail (returning a neutral 200) and leaving pending credits locked for affected users.

Fix in Cursor Fix in Web

Triggered by learned rule: SignupIdentity queries must use normalizeEmailIdentity, not plain toLowerCase

Reviewed by Cursor Bugbot for commit e4f6e12. Configure here.

@cursor
cursor Bot force-pushed the fix/verify-resend branch from e4ad77d to bbe515d Compare July 28, 2026 21:09
brad valdes and others added 2 commits July 28, 2026 17:11
…-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 <noreply@anthropic.com>
…tity

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.
@Deesmo
Deesmo force-pushed the fix/verify-resend branch from bbe515d to 8fb41af Compare July 28, 2026 21:12
@Deesmo
Deesmo merged commit 218f06e into main Jul 28, 2026
6 checks passed
@Deesmo
Deesmo deleted the fix/verify-resend branch July 28, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants