feat(activation): verify-email resend endpoint + recovery links (anti-enumeration) - #97
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
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.
| const agent = await prisma.agent.findUnique({ | ||
| where: { email }, | ||
| select: { id: true, emailVerified: true, pendingCredits: true, verifyTokenExpiry: true }, | ||
| }); |
There was a problem hiding this comment.
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.
Triggered by learned rule: SignupIdentity queries must use normalizeEmailIdentity, not plain toLowerCase
Reviewed by Cursor Bugbot for commit e4f6e12. Configure here.
e4ad77d to
bbe515d
Compare
…-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.
bbe515d to
8fb41af
Compare


Problem
Comments in
verification.ts/agent.ts/agents.tshave referenced/v1/verify-email/resendsince 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).Accept-negotiated). Internal DB errors also return the neutral 200 — no availability oracle.ipSignupCounts; gmail dot/plus aliases share one budget), counted before any DB read so the 429 can't leak existence either. The existing/v1/agentauthLimiter (20/15min/IP) still applies on top.reissueEmailVerification()rotates ONLYverifyToken/verifyTokenExpiryand re-sends the email with the account's CURRENTpendingCredits.Recovery links
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.signupHtml.ts): verify note + "Didn't get the email? Resend it" link — fires only on explicit click, mirrors the server's neutral message viatextContent.verification.ts/agent.ts/agents.tsnow point at the real route.Tests
New
tests/verify-resend.test.mjs(13 tests, wired intonpm test):credits/pendingCredits/emailVerifiedare never writtenVerification
npm run buildexit 0node tests/verify-resend.test.mjs— 13/13 passCouncil 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/resendso users with expired or missing verification emails can get a new link without calling signup-timeissueEmailVerification()again (that path would wipependingCreditson 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 onlyverifyToken/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.mjsis wired intonpm test.Reviewed by Cursor Bugbot for commit e4f6e12. Configure here.