Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
- Add reusable header components (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After) - Reference headers in 200 responses on all conversion and demo endpoints - Add Retry-After header to 429 responses - Update Rate Limits section in API description to mention response headers - Add comprehensive tests for header documentation (21 new tests) - All 809 tests passing
188 lines
6.8 KiB
JavaScript
188 lines
6.8 KiB
JavaScript
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 };
|