Document rate limit headers in OpenAPI spec
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
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
This commit is contained in:
parent
a3bba8f0d5
commit
70eb6908e3
18 changed files with 801 additions and 821 deletions
130
dist/routes/email-change.js
vendored
130
dist/routes/email-change.js
vendored
|
|
@ -1,5 +1,5 @@
|
|||
import { Router } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
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";
|
||||
|
|
@ -11,7 +11,7 @@ const emailChangeLimiter = rateLimit({
|
|||
message: { error: "Too many email change attempts. Please try again in 1 hour." },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.body?.apiKey || req.ip || "unknown",
|
||||
keyGenerator: (req) => req.body?.apiKey || ipKeyGenerator(req.ip || "unknown"),
|
||||
});
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
async function validateApiKey(apiKey) {
|
||||
|
|
@ -63,36 +63,43 @@ async function validateApiKey(apiKey) {
|
|||
* description: Too many attempts
|
||||
*/
|
||||
router.post("/", emailChangeLimiter, async (req, res) => {
|
||||
const { apiKey, newEmail } = req.body || {};
|
||||
if (!apiKey || typeof apiKey !== "string") {
|
||||
res.status(400).json({ error: "apiKey is required." });
|
||||
return;
|
||||
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." });
|
||||
}
|
||||
if (!newEmail || typeof newEmail !== "string") {
|
||||
res.status(400).json({ error: "newEmail is required." });
|
||||
return;
|
||||
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" });
|
||||
}
|
||||
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." });
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -140,35 +147,42 @@ router.post("/", emailChangeLimiter, async (req, res) => {
|
|||
* description: Too many failed attempts
|
||||
*/
|
||||
router.post("/verify", async (req, res) => {
|
||||
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;
|
||||
try {
|
||||
const { apiKey, newEmail, code } = req.body || {};
|
||||
if (!apiKey || !newEmail || !code) {
|
||||
res.status(400).json({ error: "apiKey, newEmail, and code are required." });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue