import { Router } from "express"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import { createPendingVerification, verifyCode } from "../services/verification.js"; import { sendVerificationEmail } from "../services/email.js"; import { queryWithRetry } from "../services/db.js"; import logger from "../services/logger.js"; const router = Router(); const emailChangeLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 3, message: { error: "Too many email change attempts. Please try again in 1 hour." }, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.body?.apiKey || ipKeyGenerator(req.ip || "unknown"), }); const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; async function validateApiKey(apiKey) { const result = await queryWithRetry(`SELECT key, email, tier FROM api_keys WHERE key = $1`, [apiKey]); return result.rows[0] || null; } /** * @openapi * /v1/email-change: * post: * tags: [Account] * summary: Request email change * description: | * Sends a 6-digit verification code to the new email address. * Rate limited to 3 requests per hour per API key. * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [apiKey, newEmail] * properties: * apiKey: * type: string * newEmail: * type: string * format: email * responses: * 200: * description: Verification code sent * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: verification_sent * message: * type: string * 400: * description: Missing or invalid fields * 403: * description: Invalid API key * 409: * description: Email already taken * 429: * description: Too many attempts */ router.post("/", emailChangeLimiter, async (req, res) => { try { const { apiKey, newEmail } = req.body || {}; if (!apiKey || typeof apiKey !== "string") { res.status(400).json({ error: "apiKey is required." }); return; } if (!newEmail || typeof newEmail !== "string") { res.status(400).json({ error: "newEmail is required." }); return; } const cleanEmail = newEmail.trim().toLowerCase(); if (!EMAIL_RE.test(cleanEmail)) { res.status(400).json({ error: "Invalid email format." }); return; } const keyRow = await validateApiKey(apiKey); if (!keyRow) { res.status(403).json({ error: "Invalid API key." }); return; } // Check if email is already taken by another key const existing = await queryWithRetry(`SELECT key FROM api_keys WHERE email = $1 AND key != $2`, [cleanEmail, apiKey]); if (existing.rows.length > 0) { res.status(409).json({ error: "This email is already associated with another account." }); return; } const pending = await createPendingVerification(cleanEmail); sendVerificationEmail(cleanEmail, pending.code).catch(err => { logger.error({ err, email: cleanEmail }, "Failed to send email change verification"); }); res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." }); } catch (err) { const reqId = req.requestId || "unknown"; logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change"); res.status(500).json({ error: "Internal server error" }); } }); /** * @openapi * /v1/email-change/verify: * post: * tags: [Account] * summary: Verify email change code * description: Verifies the 6-digit code and updates the account email. * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [apiKey, newEmail, code] * properties: * apiKey: * type: string * newEmail: * type: string * format: email * code: * type: string * pattern: '^\d{6}$' * responses: * 200: * description: Email updated * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: ok * newEmail: * type: string * 400: * description: Missing fields or invalid code * 403: * description: Invalid API key * 410: * description: Code expired * 429: * description: Too many failed attempts */ router.post("/verify", async (req, res) => { try { const { apiKey, newEmail, code } = req.body || {}; if (!apiKey || !newEmail || !code) { res.status(400).json({ error: "apiKey, newEmail, and code are required." }); return; } const cleanEmail = newEmail.trim().toLowerCase(); const cleanCode = String(code).trim(); const keyRow = await validateApiKey(apiKey); if (!keyRow) { res.status(403).json({ error: "Invalid API key." }); return; } const result = await verifyCode(cleanEmail, cleanCode); switch (result.status) { case "ok": { await queryWithRetry(`UPDATE api_keys SET email = $1 WHERE key = $2`, [cleanEmail, apiKey]); logger.info({ apiKey: apiKey.slice(0, 10) + "...", newEmail: cleanEmail }, "Email changed"); res.json({ status: "ok", newEmail: cleanEmail }); break; } case "expired": res.status(410).json({ error: "Verification code has expired. Please request a new one." }); break; case "max_attempts": res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); break; case "invalid": res.status(400).json({ error: "Invalid verification code." }); break; } } catch (err) { const reqId = req.requestId || "unknown"; logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change/verify"); res.status(500).json({ error: "Internal server error" }); } }); export { router as emailChangeRouter };