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
56
dist/services/browser.js
vendored
56
dist/services/browser.js
vendored
|
|
@ -196,6 +196,32 @@ export async function closeBrowser() {
|
|||
}
|
||||
instances.length = 0;
|
||||
}
|
||||
/** Build a Puppeteer-compatible PDFOptions object from user-supplied render options. */
|
||||
export function buildPdfOptions(options) {
|
||||
const result = {
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
|
||||
};
|
||||
if (options.headerTemplate !== undefined)
|
||||
result.headerTemplate = options.headerTemplate;
|
||||
if (options.footerTemplate !== undefined)
|
||||
result.footerTemplate = options.footerTemplate;
|
||||
if (options.displayHeaderFooter !== undefined)
|
||||
result.displayHeaderFooter = options.displayHeaderFooter;
|
||||
if (options.scale !== undefined)
|
||||
result.scale = options.scale;
|
||||
if (options.pageRanges)
|
||||
result.pageRanges = options.pageRanges;
|
||||
if (options.preferCSSPageSize !== undefined)
|
||||
result.preferCSSPageSize = options.preferCSSPageSize;
|
||||
if (options.width)
|
||||
result.width = options.width;
|
||||
if (options.height)
|
||||
result.height = options.height;
|
||||
return result;
|
||||
}
|
||||
export async function renderPdf(html, options = {}) {
|
||||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
|
|
@ -206,20 +232,7 @@ export async function renderPdf(html, options = {}) {
|
|||
(async () => {
|
||||
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
||||
await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" });
|
||||
const pdf = await page.pdf({
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
|
||||
headerTemplate: options.headerTemplate,
|
||||
footerTemplate: options.footerTemplate,
|
||||
displayHeaderFooter: options.displayHeaderFooter || false,
|
||||
...(options.scale !== undefined && { scale: options.scale }),
|
||||
...(options.pageRanges && { pageRanges: options.pageRanges }),
|
||||
...(options.preferCSSPageSize !== undefined && { preferCSSPageSize: options.preferCSSPageSize }),
|
||||
...(options.width && { width: options.width }),
|
||||
...(options.height && { height: options.height }),
|
||||
});
|
||||
const pdf = await page.pdf(buildPdfOptions(options));
|
||||
return Buffer.from(pdf);
|
||||
})(),
|
||||
new Promise((_, reject) => {
|
||||
|
|
@ -281,20 +294,7 @@ export async function renderUrlPdf(url, options = {}) {
|
|||
waitUntil: options.waitUntil || "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await page.pdf({
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
|
||||
...(options.headerTemplate && { headerTemplate: options.headerTemplate }),
|
||||
...(options.footerTemplate && { footerTemplate: options.footerTemplate }),
|
||||
...(options.displayHeaderFooter !== undefined && { displayHeaderFooter: options.displayHeaderFooter }),
|
||||
...(options.scale !== undefined && { scale: options.scale }),
|
||||
...(options.pageRanges && { pageRanges: options.pageRanges }),
|
||||
...(options.preferCSSPageSize !== undefined && { preferCSSPageSize: options.preferCSSPageSize }),
|
||||
...(options.width && { width: options.width }),
|
||||
...(options.height && { height: options.height }),
|
||||
});
|
||||
const pdf = await page.pdf(buildPdfOptions(options));
|
||||
return Buffer.from(pdf);
|
||||
})(),
|
||||
new Promise((_, reject) => {
|
||||
|
|
|
|||
24
dist/services/db.js
vendored
24
dist/services/db.js
vendored
|
|
@ -1,6 +1,6 @@
|
|||
import pg from "pg";
|
||||
import logger from "./logger.js";
|
||||
import { isTransientError } from "../utils/errors.js";
|
||||
import { isTransientError, errorMessage, errorCode } from "../utils/errors.js";
|
||||
const { Pool } = pg;
|
||||
const pool = new Pool({
|
||||
host: process.env.DATABASE_HOST || "172.17.0.1",
|
||||
|
|
@ -51,7 +51,7 @@ export async function queryWithRetry(queryText, params, maxRetries = 3) {
|
|||
throw err;
|
||||
}
|
||||
const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000); // 1s, 2s, 4s (capped at 5s)
|
||||
logger.warn({ err: err.message, code: err.code, attempt: attempt + 1, maxRetries, delayMs }, "Transient DB error, destroying bad connection and retrying...");
|
||||
logger.warn({ err: errorMessage(err), code: errorCode(err), attempt: attempt + 1, maxRetries, delayMs }, "Transient DB error, destroying bad connection and retrying...");
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ export async function connectWithRetry(maxRetries = 3) {
|
|||
throw validationErr;
|
||||
}
|
||||
const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000);
|
||||
logger.warn({ err: validationErr.message, code: validationErr.code, attempt: attempt + 1 }, "Connection validation failed, destroying and retrying...");
|
||||
logger.warn({ err: errorMessage(validationErr), code: errorCode(validationErr), attempt: attempt + 1 }, "Connection validation failed, destroying and retrying...");
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
continue;
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@ export async function connectWithRetry(maxRetries = 3) {
|
|||
throw err;
|
||||
}
|
||||
const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000);
|
||||
logger.warn({ err: err.message, code: err.code, attempt: attempt + 1, maxRetries, delayMs }, "Transient DB connect error, retrying...");
|
||||
logger.warn({ err: errorMessage(err), code: errorCode(err), attempt: attempt + 1, maxRetries, delayMs }, "Transient DB connect error, retrying...");
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
|
@ -153,28 +153,18 @@ export async function initDatabase() {
|
|||
* - Orphaned usage rows (key no longer exists)
|
||||
*/
|
||||
export async function cleanupStaleData() {
|
||||
const results = { expiredVerifications: 0, staleKeys: 0, orphanedUsage: 0 };
|
||||
const results = { expiredVerifications: 0, orphanedUsage: 0 };
|
||||
// 1. Delete expired pending verifications
|
||||
const pv = await queryWithRetry("DELETE FROM pending_verifications WHERE expires_at < NOW() RETURNING email");
|
||||
results.expiredVerifications = pv.rowCount || 0;
|
||||
// 2. Delete unverified free-tier keys (email not in verified verifications)
|
||||
const sk = await queryWithRetry(`
|
||||
DELETE FROM api_keys
|
||||
WHERE tier = 'free'
|
||||
AND email NOT IN (
|
||||
SELECT DISTINCT email FROM verifications WHERE verified_at IS NOT NULL
|
||||
)
|
||||
RETURNING key
|
||||
`);
|
||||
results.staleKeys = sk.rowCount || 0;
|
||||
// 3. Delete orphaned usage rows
|
||||
// 2. Delete orphaned usage rows (key no longer exists in api_keys)
|
||||
const ou = await queryWithRetry(`
|
||||
DELETE FROM usage
|
||||
WHERE key NOT IN (SELECT key FROM api_keys)
|
||||
RETURNING key
|
||||
`);
|
||||
results.orphanedUsage = ou.rowCount || 0;
|
||||
logger.info({ ...results }, `Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.staleKeys} stale keys, ${results.orphanedUsage} orphaned usage rows removed`);
|
||||
logger.info({ ...results }, `Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.orphanedUsage} orphaned usage rows removed`);
|
||||
return results;
|
||||
}
|
||||
export { pool };
|
||||
|
|
|
|||
4
dist/services/email.js
vendored
4
dist/services/email.js
vendored
|
|
@ -14,10 +14,8 @@ const transportConfig = {
|
|||
greetingTimeout: 5000,
|
||||
socketTimeout: 10000,
|
||||
tls: { rejectUnauthorized: false },
|
||||
...(smtpUser && smtpPass ? { auth: { user: smtpUser, pass: smtpPass } } : {}),
|
||||
};
|
||||
if (smtpUser && smtpPass) {
|
||||
transportConfig.auth = { user: smtpUser, pass: smtpPass };
|
||||
}
|
||||
const transporter = nodemailer.createTransport(transportConfig);
|
||||
export async function sendVerificationEmail(email, code) {
|
||||
try {
|
||||
|
|
|
|||
77
dist/services/keys.js
vendored
77
dist/services/keys.js
vendored
|
|
@ -3,6 +3,20 @@ import logger from "./logger.js";
|
|||
import { queryWithRetry } from "./db.js";
|
||||
// In-memory cache for fast lookups, synced with PostgreSQL
|
||||
let keysCache = [];
|
||||
/** Look up a key row in the DB by a given column. Returns null if not found. */
|
||||
export async function findKeyInCacheOrDb(column, value) {
|
||||
const result = await queryWithRetry(`SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE ${column} = $1 LIMIT 1`, [value]);
|
||||
if (result.rows.length === 0)
|
||||
return null;
|
||||
const r = result.rows[0];
|
||||
return {
|
||||
key: r.key,
|
||||
tier: r.tier,
|
||||
email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
};
|
||||
}
|
||||
export async function loadKeys() {
|
||||
try {
|
||||
const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys");
|
||||
|
|
@ -102,55 +116,60 @@ export async function downgradeByCustomer(stripeCustomerId) {
|
|||
}
|
||||
// DB fallback: key may exist on another pod's cache or after a restart
|
||||
logger.info({ stripeCustomerId }, "downgradeByCustomer: cache miss, falling back to DB");
|
||||
const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1", [stripeCustomerId]);
|
||||
if (result.rows.length === 0) {
|
||||
const dbKey = await findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||
if (!dbKey) {
|
||||
logger.warn({ stripeCustomerId }, "downgradeByCustomer: customer not found in cache or DB");
|
||||
return false;
|
||||
}
|
||||
const row = result.rows[0];
|
||||
await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
|
||||
// Add to local cache so subsequent lookups on this pod work
|
||||
const cached = {
|
||||
key: row.key,
|
||||
tier: "free",
|
||||
email: row.email,
|
||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
||||
stripeCustomerId: row.stripe_customer_id || undefined,
|
||||
};
|
||||
keysCache.push(cached);
|
||||
logger.info({ stripeCustomerId, key: row.key }, "downgradeByCustomer: downgraded via DB fallback");
|
||||
dbKey.tier = "free";
|
||||
keysCache.push(dbKey);
|
||||
logger.info({ stripeCustomerId, key: dbKey.key }, "downgradeByCustomer: downgraded via DB fallback");
|
||||
return true;
|
||||
}
|
||||
export async function findKeyByCustomerId(stripeCustomerId) {
|
||||
// Check DB directly — survives pod restarts unlike in-memory cache
|
||||
const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1", [stripeCustomerId]);
|
||||
if (result.rows.length === 0)
|
||||
return null;
|
||||
const r = result.rows[0];
|
||||
return {
|
||||
key: r.key,
|
||||
tier: r.tier,
|
||||
email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
};
|
||||
return findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||
}
|
||||
export function getAllKeys() {
|
||||
return [...keysCache];
|
||||
}
|
||||
export async function updateKeyEmail(apiKey, newEmail) {
|
||||
const entry = keysCache.find((k) => k.key === apiKey);
|
||||
if (!entry)
|
||||
if (entry) {
|
||||
entry.email = newEmail;
|
||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
|
||||
return true;
|
||||
}
|
||||
// DB fallback: key may exist on another pod's cache or after a restart
|
||||
logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: cache miss, falling back to DB");
|
||||
const dbKey = await findKeyInCacheOrDb("key", apiKey);
|
||||
if (!dbKey) {
|
||||
logger.warn({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: key not found in cache or DB");
|
||||
return false;
|
||||
entry.email = newEmail;
|
||||
}
|
||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
|
||||
dbKey.email = newEmail;
|
||||
keysCache.push(dbKey);
|
||||
logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: updated via DB fallback");
|
||||
return true;
|
||||
}
|
||||
export async function updateEmailByCustomer(stripeCustomerId, newEmail) {
|
||||
const entry = keysCache.find(k => k.stripeCustomerId === stripeCustomerId);
|
||||
if (!entry)
|
||||
if (entry) {
|
||||
entry.email = newEmail;
|
||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]);
|
||||
return true;
|
||||
}
|
||||
// DB fallback: key may exist on another pod's cache or after a restart
|
||||
logger.info({ stripeCustomerId }, "updateEmailByCustomer: cache miss, falling back to DB");
|
||||
const dbKey = await findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||
if (!dbKey) {
|
||||
logger.warn({ stripeCustomerId }, "updateEmailByCustomer: customer not found in cache or DB");
|
||||
return false;
|
||||
entry.email = newEmail;
|
||||
}
|
||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]);
|
||||
dbKey.email = newEmail;
|
||||
keysCache.push(dbKey);
|
||||
logger.info({ stripeCustomerId, key: dbKey.key }, "updateEmailByCustomer: updated via DB fallback");
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
67
dist/services/verification.js
vendored
67
dist/services/verification.js
vendored
|
|
@ -1,64 +1,7 @@
|
|||
import { randomBytes, randomInt, timingSafeEqual } from "crypto";
|
||||
import logger from "./logger.js";
|
||||
import { randomInt, timingSafeEqual } from "crypto";
|
||||
import { queryWithRetry } from "./db.js";
|
||||
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
const CODE_EXPIRY_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
export async function createVerification(email, apiKey) {
|
||||
// Check for existing unexpired, unverified
|
||||
const existing = await queryWithRetry("SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1", [email]);
|
||||
if (existing.rows.length > 0) {
|
||||
const r = existing.rows[0];
|
||||
return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null };
|
||||
}
|
||||
// Remove old unverified
|
||||
await queryWithRetry("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]);
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const now = new Date().toISOString();
|
||||
await queryWithRetry("INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)", [email, token, apiKey, now]);
|
||||
return { email, token, apiKey, createdAt: now, verifiedAt: null };
|
||||
}
|
||||
export function verifyToken(token) {
|
||||
// Synchronous wrapper — we'll make it async-compatible
|
||||
// Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor.
|
||||
// For simplicity, we'll cache verifications in memory too.
|
||||
return verifyTokenSync(token);
|
||||
}
|
||||
// In-memory cache for verifications (loaded on startup, updated on changes)
|
||||
let verificationsCache = [];
|
||||
export async function loadVerifications() {
|
||||
const result = await queryWithRetry("SELECT * FROM verifications");
|
||||
verificationsCache = result.rows.map((r) => ({
|
||||
email: r.email,
|
||||
token: r.token,
|
||||
apiKey: r.api_key,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null,
|
||||
}));
|
||||
// Cleanup expired entries every 15 minutes
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const before = verificationsCache.length;
|
||||
verificationsCache = verificationsCache.filter((v) => v.verifiedAt || new Date(v.createdAt).getTime() > cutoff);
|
||||
const removed = before - verificationsCache.length;
|
||||
if (removed > 0)
|
||||
logger.info({ removed }, "Cleaned expired verification cache entries");
|
||||
}, 15 * 60 * 1000);
|
||||
}
|
||||
function verifyTokenSync(token) {
|
||||
const v = verificationsCache.find((v) => v.token === token);
|
||||
if (!v)
|
||||
return { status: "invalid" };
|
||||
if (v.verifiedAt)
|
||||
return { status: "already_verified", verification: v };
|
||||
const age = Date.now() - new Date(v.createdAt).getTime();
|
||||
if (age > TOKEN_EXPIRY_MS)
|
||||
return { status: "expired" };
|
||||
v.verifiedAt = new Date().toISOString();
|
||||
// Update DB async
|
||||
queryWithRetry("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch((err) => logger.error({ err }, "Failed to update verification"));
|
||||
return { status: "ok", verification: v };
|
||||
}
|
||||
export async function createPendingVerification(email) {
|
||||
await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [email]);
|
||||
const now = new Date();
|
||||
|
|
@ -96,11 +39,3 @@ export async function verifyCode(email, code) {
|
|||
await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
|
||||
return { status: "ok" };
|
||||
}
|
||||
export async function isEmailVerified(email) {
|
||||
const result = await queryWithRetry("SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
export async function getVerifiedApiKey(email) {
|
||||
const result = await queryWithRetry("SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]);
|
||||
return result.rows[0]?.api_key ?? null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue