fix: OpenAPI spec accuracy — hide internal endpoints, mark signup/verify deprecated
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m9s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m9s
- Remove @openapi annotations from /v1/billing/webhook (Stripe-internal) - Remove @openapi annotations from /v1/billing/success (browser redirect) - Mark /v1/signup/verify as deprecated (returns 410) - Add 3 TDD tests in openapi-spec.test.ts - Update 2 existing tests in app-routes.test.ts - 530 tests passing (was 527)
This commit is contained in:
parent
1d5d9adf08
commit
6b1b3d584e
15 changed files with 399 additions and 290 deletions
156
dist/routes/email-change.js
vendored
156
dist/routes/email-change.js
vendored
|
|
@ -2,70 +2,162 @@ 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, updateKeyEmail } from "../services/keys.js";
|
||||
import { queryWithRetry } from "../services/db.js";
|
||||
import logger from "../services/logger.js";
|
||||
const router = Router();
|
||||
const changeLimiter = rateLimit({
|
||||
const emailChangeLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
message: { error: "Too many attempts. Please try again in 1 hour." },
|
||||
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",
|
||||
});
|
||||
router.post("/", changeLimiter, async (req, res) => {
|
||||
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
|
||||
const newEmail = req.body?.newEmail;
|
||||
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) => {
|
||||
const { apiKey, newEmail } = req.body || {};
|
||||
if (!apiKey || typeof apiKey !== "string") {
|
||||
res.status(400).json({ error: "API key is required (Authorization header or body)." });
|
||||
res.status(400).json({ error: "apiKey is required." });
|
||||
return;
|
||||
}
|
||||
if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||
res.status(400).json({ error: "A valid new email address is required." });
|
||||
if (!newEmail || typeof newEmail !== "string") {
|
||||
res.status(400).json({ error: "newEmail is required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
const keys = getAllKeys();
|
||||
const userKey = keys.find((k) => k.key === apiKey);
|
||||
if (!userKey) {
|
||||
res.status(401).json({ error: "Invalid API key." });
|
||||
if (!EMAIL_RE.test(cleanEmail)) {
|
||||
res.status(400).json({ error: "Invalid email format." });
|
||||
return;
|
||||
}
|
||||
const existing = keys.find((k) => k.email === cleanEmail);
|
||||
if (existing) {
|
||||
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) => {
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
|
||||
});
|
||||
res.json({ status: "verification_sent", message: "Verification code sent to your new email address." });
|
||||
res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." });
|
||||
});
|
||||
router.post("/verify", changeLimiter, async (req, res) => {
|
||||
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
|
||||
const { newEmail, code } = req.body || {};
|
||||
/**
|
||||
* @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) => {
|
||||
const { apiKey, newEmail, code } = req.body || {};
|
||||
if (!apiKey || !newEmail || !code) {
|
||||
res.status(400).json({ error: "API key, new email, and code are required." });
|
||||
res.status(400).json({ error: "apiKey, newEmail, and code are required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
const cleanCode = String(code).trim();
|
||||
const keys = getAllKeys();
|
||||
const userKey = keys.find((k) => k.key === apiKey);
|
||||
if (!userKey) {
|
||||
res.status(401).json({ error: "Invalid API key." });
|
||||
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": {
|
||||
const updated = await updateKeyEmail(apiKey, cleanEmail);
|
||||
if (updated) {
|
||||
res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail });
|
||||
}
|
||||
else {
|
||||
res.status(500).json({ error: "Failed to update email." });
|
||||
}
|
||||
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":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue