CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
email-verification.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| c63b860 | 1 | /** |
| 2 | * Block P2 — email verification routes. | |
| 3 | * | |
| 4 | * GET /verify-email?token=… Consume a token. On success: 302 to | |
| 5 | * /dashboard?verified=1 and fire-and-forget | |
| 6 | * the welcome email. On failure: render a | |
| 7 | * "link expired" page. | |
| 8 | * POST /verify-email/resend requireAuth. Issues a fresh verification | |
| 9 | * token. Rate-limited per user (3/hour). | |
| 10 | */ | |
| 11 | ||
| 12 | import { Hono } from "hono"; | |
| 13 | import { eq } from "drizzle-orm"; | |
| 14 | import { db } from "../db"; | |
| 15 | import { users } from "../db/schema"; | |
| 16 | import { Layout } from "../views/layout"; | |
| 17 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 18 | import type { AuthEnv } from "../middleware/auth"; | |
| 19 | import { | |
| 20 | consumeVerificationToken, | |
| 21 | startEmailVerification, | |
| 22 | sendWelcomeEmail, | |
| 23 | } from "../lib/email-verification"; | |
| 24 | ||
| 25 | const verify = new Hono<AuthEnv>(); | |
| 26 | ||
| 27 | const RESEND_LIMIT = 3; | |
| 28 | const RESEND_WINDOW_MS = 60 * 60 * 1000; | |
| 29 | const _resendLog: Map<string, number[]> = new Map(); | |
| 30 | ||
| 31 | function checkResendRate(userId: string): { allowed: boolean; remaining: number } { | |
| 32 | const now = Date.now(); | |
| 33 | const cutoff = now - RESEND_WINDOW_MS; | |
| 34 | const recent = (_resendLog.get(userId) || []).filter((t) => t > cutoff); | |
| 35 | if (recent.length >= RESEND_LIMIT) { | |
| 36 | _resendLog.set(userId, recent); | |
| 37 | return { allowed: false, remaining: 0 }; | |
| 38 | } | |
| 39 | recent.push(now); | |
| 40 | _resendLog.set(userId, recent); | |
| 41 | return { allowed: true, remaining: RESEND_LIMIT - recent.length }; | |
| 42 | } | |
| 43 | ||
| 44 | /** Test-only: wipe the in-memory rate-limit counters. */ | |
| 45 | export function __resetResendRateLimitForTests(): void { | |
| 46 | _resendLog.clear(); | |
| 47 | } | |
| 48 | ||
| 49 | verify.get("/verify-email", softAuth, async (c) => { | |
| 50 | const token = c.req.query("token") || ""; | |
| 51 | const user = c.get("user") || null; | |
| 52 | const result = await consumeVerificationToken(token); | |
| 53 | ||
| 54 | if (result.ok && result.userId) { | |
| 55 | void sendWelcomeEmail(result.userId); | |
| 56 | return c.redirect("/dashboard?verified=1"); | |
| 57 | } | |
| 58 | ||
| 59 | return c.html( | |
| 60 | <Layout title="Verification link expired" user={user}> | |
| 61 | <div class="auth-container"> | |
| 62 | <h2>Link expired</h2> | |
| 63 | <p style="color:var(--text-muted);font-size:14px;line-height:1.55"> | |
| 64 | That verification link is no longer valid. Links expire after 24 | |
| 65 | hours and can only be used once. Sign in and request a fresh link | |
| 66 | from your dashboard. | |
| 67 | </p> | |
| 68 | <p class="auth-switch" style="margin-top:24px"> | |
| 69 | <a href="/login">Sign in</a> | |
| 70 | </p> | |
| 71 | </div> | |
| 72 | </Layout> | |
| 73 | ); | |
| 74 | }); | |
| 75 | ||
| 76 | verify.post("/verify-email/resend", requireAuth, async (c) => { | |
| 77 | const user = c.get("user"); | |
| 78 | if (!user) return c.redirect("/login"); | |
| 79 | ||
| 80 | let email = user.email; | |
| 81 | let verifiedAt: Date | null = (user as any).emailVerifiedAt | |
| 82 | ? new Date((user as any).emailVerifiedAt as string | Date) | |
| 83 | : null; | |
| 84 | try { | |
| 85 | const [fresh] = await db | |
| 86 | .select({ | |
| 87 | email: users.email, | |
| 88 | emailVerifiedAt: users.emailVerifiedAt, | |
| 89 | }) | |
| 90 | .from(users) | |
| 91 | .where(eq(users.id, user.id)) | |
| 92 | .limit(1); | |
| 93 | if (fresh) { | |
| 94 | email = fresh.email; | |
| 95 | verifiedAt = fresh.emailVerifiedAt | |
| 96 | ? new Date(fresh.emailVerifiedAt as unknown as string | Date) | |
| 97 | : null; | |
| 98 | } | |
| 99 | } catch { | |
| 100 | // best effort | |
| 101 | } | |
| 102 | ||
| 103 | if (verifiedAt) { | |
| 104 | return c.redirect("/dashboard?verified=1"); | |
| 105 | } | |
| 106 | ||
| 107 | const rate = checkResendRate(user.id); | |
| 108 | if (!rate.allowed) { | |
| 109 | return c.redirect("/dashboard?verify=rate_limited"); | |
| 110 | } | |
| 111 | ||
| 112 | void startEmailVerification(user.id, email); | |
| 113 | return c.redirect("/dashboard?verify=sent"); | |
| 114 | }); | |
| 115 | ||
| 116 | export default verify; |