| 1 | import { generateToken, hashToken } from "../auth/crypto"; |
| 2 | |
| 3 | export type EmailTokenPurpose = "verify" | "reset"; |
| 4 | |
| 5 | export class EmailTokenRepo { |
| 6 | constructor( |
| 7 | private readonly db: D1Database, |
| 8 | private readonly pepper: string, |
| 9 | ) {} |
| 10 | |
| 11 | // Issues a single-use token and returns the raw value for the email link. |
| 12 | async issue(userId: number, purpose: EmailTokenPurpose, ttlMs: number): Promise<string> { |
| 13 | const token = generateToken(); |
| 14 | const tokenHash = await hashToken(this.pepper, token); |
| 15 | const now = new Date(); |
| 16 | const expires = new Date(now.getTime() + ttlMs); |
| 17 | await this.db |
| 18 | .prepare("INSERT INTO email_tokens (token_hash, user_id, purpose, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)") |
| 19 | .bind(tokenHash, userId, purpose, now.toISOString(), expires.toISOString()) |
| 20 | .run(); |
| 21 | return token; |
| 22 | } |
| 23 | |
| 24 | // Atomically redeems a token: a single UPDATE ... RETURNING marks it used and |
| 25 | // hands back the user id, so a token can never be consumed twice. |
| 26 | async consume(token: string, purpose: EmailTokenPurpose): Promise<number | null> { |
| 27 | const tokenHash = await hashToken(this.pepper, token); |
| 28 | const now = new Date().toISOString(); |
| 29 | const row = await this.db |
| 30 | .prepare( |
| 31 | `UPDATE email_tokens SET used_at = ?1 |
| 32 | WHERE token_hash = ?2 AND purpose = ?3 AND used_at IS NULL AND expires_at > ?1 |
| 33 | RETURNING user_id`, |
| 34 | ) |
| 35 | .bind(now, tokenHash, purpose) |
| 36 | .first<{ user_id: number }>(); |
| 37 | return row?.user_id ?? null; |
| 38 | } |
| 39 | |
| 40 | async invalidateForUser(userId: number, purpose: EmailTokenPurpose): Promise<void> { |
| 41 | await this.db |
| 42 | .prepare("UPDATE email_tokens SET used_at = ?1 WHERE user_id = ?2 AND purpose = ?3 AND used_at IS NULL") |
| 43 | .bind(new Date().toISOString(), userId, purpose) |
| 44 | .run(); |
| 45 | } |
| 46 | } |
| 47 |