import { randomBytes, randomInt, timingSafeEqual } from "crypto"; import logger from "./logger.js"; import pool from "./db.js"; import { queryWithRetry, connectWithRetry } from "./db.js"; export interface Verification { email: string; token: string; apiKey: string; createdAt: string; verifiedAt: string | null; } export interface PendingVerification { email: string; code: string; createdAt: string; expiresAt: string; attempts: number; } const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; const CODE_EXPIRY_MS = 15 * 60 * 1000; const MAX_ATTEMPTS = 3; export async function createVerification(email: string, apiKey: string): Promise { // Check for existing unexpired, unverified const existing = await queryWithRetry( "SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1", [email] ); if (existing.rows.length > 0) { const r = existing.rows[0]; return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null }; } // Remove old unverified await queryWithRetry("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]); const token = randomBytes(32).toString("hex"); const now = new Date().toISOString(); await queryWithRetry( "INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)", [email, token, apiKey, now] ); return { email, token, apiKey, createdAt: now, verifiedAt: null }; } export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } { // Synchronous wrapper — we'll make it async-compatible // Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor. // For simplicity, we'll cache verifications in memory too. return verifyTokenSync(token); } // In-memory cache for verifications (loaded on startup, updated on changes) let verificationsCache: Verification[] = []; export async function loadVerifications(): Promise { const result = await queryWithRetry("SELECT * FROM verifications"); verificationsCache = result.rows.map((r) => ({ email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null, })); // Cleanup expired entries every 15 minutes setInterval(() => { const cutoff = Date.now() - 24 * 60 * 60 * 1000; const before = verificationsCache.length; verificationsCache = verificationsCache.filter( (v) => v.verifiedAt || new Date(v.createdAt).getTime() > cutoff ); const removed = before - verificationsCache.length; if (removed > 0) logger.info({ removed }, "Cleaned expired verification cache entries"); }, 15 * 60 * 1000); } function verifyTokenSync(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } { const v = verificationsCache.find((v) => v.token === token); if (!v) return { status: "invalid" }; if (v.verifiedAt) return { status: "already_verified", verification: v }; const age = Date.now() - new Date(v.createdAt).getTime(); if (age > TOKEN_EXPIRY_MS) return { status: "expired" }; v.verifiedAt = new Date().toISOString(); // Update DB async queryWithRetry("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch((err) => logger.error({ err }, "Failed to update verification")); return { status: "ok", verification: v }; } export async function createPendingVerification(email: string): Promise { await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [email]); const now = new Date(); const pending: PendingVerification = { email, code: String(randomInt(100000, 999999)), createdAt: now.toISOString(), expiresAt: new Date(now.getTime() + CODE_EXPIRY_MS).toISOString(), attempts: 0, }; await queryWithRetry( "INSERT INTO pending_verifications (email, code, created_at, expires_at, attempts) VALUES ($1, $2, $3, $4, $5)", [pending.email, pending.code, pending.createdAt, pending.expiresAt, pending.attempts] ); return pending; } export async function verifyCode(email: string, code: string): Promise<{ status: "ok" | "invalid" | "expired" | "max_attempts" }> { const cleanEmail = email.trim().toLowerCase(); const result = await queryWithRetry("SELECT * FROM pending_verifications WHERE email = $1", [cleanEmail]); const pending = result.rows[0]; if (!pending) return { status: "invalid" }; if (new Date() > new Date(pending.expires_at)) { await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); return { status: "expired" }; } if (pending.attempts >= MAX_ATTEMPTS) { await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); return { status: "max_attempts" }; } await queryWithRetry("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]); const a = Buffer.from(pending.code, "utf8"); const b = Buffer.from(code, "utf8"); const codeMatch = a.length === b.length && timingSafeEqual(a, b); if (!codeMatch) { return { status: "invalid" }; } await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); return { status: "ok" }; } export async function isEmailVerified(email: string): Promise { const result = await queryWithRetry( "SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email] ); return result.rows.length > 0; } export async function getVerifiedApiKey(email: string): Promise { const result = await queryWithRetry( "SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email] ); return result.rows[0]?.api_key ?? null; }