import { Router } from "express"; import rateLimit from "express-rate-limit"; import { createPendingVerification, verifyCode } from "../services/verification.js"; import { sendVerificationEmail } from "../services/email.js"; import { getAllKeys } from "../services/keys.js"; import logger from "../services/logger.js"; const router = Router(); const recoverLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 3, message: { error: "Too many recovery attempts. Please try again in 1 hour." }, standardHeaders: true, legacyHeaders: false, }); /** * @openapi * /v1/recover: * post: * tags: [Account] * summary: Request API key recovery * description: | * Sends a 6-digit verification code to the email address if an account exists. * Response is always the same regardless of whether the email exists (to prevent enumeration). * Rate limited to 3 requests per hour. * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [email] * properties: * email: * type: string * format: email * description: Email address associated with the API key * responses: * 200: * description: Recovery code sent (or no-op if email not found) * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: recovery_sent * message: * type: string * 400: * description: Invalid email format * 429: * description: Too many recovery attempts */ router.post("/", recoverLimiter, async (req, res) => { const { email } = req.body || {}; if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { res.status(400).json({ error: "A valid email address is required." }); return; } const cleanEmail = email.trim().toLowerCase(); const keys = getAllKeys(); const userKey = keys.find(k => k.email === cleanEmail); if (!userKey) { res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); return; } const pending = await createPendingVerification(cleanEmail); sendVerificationEmail(cleanEmail, pending.code).catch(err => { logger.error({ err, email: cleanEmail }, "Failed to send recovery email"); }); res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); }); /** * @openapi * /v1/recover/verify: * post: * tags: [Account] * summary: Verify recovery code and retrieve API key * description: Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes. * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [email, code] * properties: * email: * type: string * format: email * code: * type: string * pattern: '^\d{6}$' * description: 6-digit verification code * responses: * 200: * description: API key recovered * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: recovered * apiKey: * type: string * description: The recovered API key * tier: * type: string * enum: [free, pro] * 400: * description: Invalid verification code or missing fields * 410: * description: Verification code expired * 429: * description: Too many failed attempts */ router.post("/verify", recoverLimiter, async (req, res) => { const { email, code } = req.body || {}; if (!email || !code) { res.status(400).json({ error: "Email and code are required." }); return; } const cleanEmail = email.trim().toLowerCase(); const cleanCode = String(code).trim(); const result = await verifyCode(cleanEmail, cleanCode); switch (result.status) { case "ok": { const keys = getAllKeys(); const userKey = keys.find(k => k.email === cleanEmail); if (userKey) { res.json({ status: "recovered", apiKey: userKey.key, tier: userKey.tier, message: "Your API key has been recovered. Save it securely — it is shown only once.", }); } else { res.json({ status: "recovered", message: "No API key found for this email.", }); } 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; } }); export { router as recoverRouter };