From 70eb6908e38c1888cc06a1e739a5b6b3a03b7454 Mon Sep 17 00:00:00 2001 From: OpenClaw Subagent Date: Wed, 18 Mar 2026 11:06:22 +0100 Subject: [PATCH] Document rate limit headers in OpenAPI spec - 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 --- dist/index.js | 253 ++++------------ dist/middleware/usage.js | 2 +- dist/routes/billing.js | 46 +-- dist/routes/convert.js | 183 ++++-------- dist/routes/email-change.js | 130 +++++---- dist/routes/health.js | 2 +- dist/routes/recover.js | 155 +++++----- dist/routes/templates.js | 2 +- dist/services/browser.js | 56 ++-- dist/services/db.js | 24 +- dist/services/email.js | 4 +- dist/services/keys.js | 77 +++-- dist/services/verification.js | 67 +---- public/openapi.json | 449 +++++++++++++++++------------ src/__tests__/openapi-spec.test.ts | 90 ++++++ src/routes/convert.ts | 30 ++ src/routes/demo.ts | 20 ++ src/swagger.ts | 32 +- 18 files changed, 801 insertions(+), 821 deletions(-) diff --git a/dist/index.js b/dist/index.js index daba546..cf924ba 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,11 +1,9 @@ import express from "express"; import { randomUUID } from "crypto"; -import { createRequire } from "module"; +import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; -const _require = createRequire(import.meta.url); -const APP_VERSION = _require("../package.json").version; import path from "path"; import { fileURLToPath } from "url"; import rateLimit from "express-rate-limit"; @@ -18,13 +16,13 @@ import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js"; -import { getUsageStats, getUsageForKey } from "./middleware/usage.js"; -import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; +import { pdfRateLimitMiddleware } from "./middleware/pdfRateLimit.js"; +import { adminRouter } from "./routes/admin.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; -import { loadKeys, getAllKeys, isProKey } from "./services/keys.js"; -import { verifyToken, loadVerifications } from "./services/verification.js"; +import { loadKeys, getAllKeys } from "./services/keys.js"; +import { pagesRouter } from "./routes/pages.js"; import { initDatabase, pool, cleanupStaleData } from "./services/db.js"; -import { swaggerSpec } from "./swagger.js"; +import { startPeriodicCleanup, stopPeriodicCleanup } from "./utils/periodic-cleanup.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); @@ -49,7 +47,15 @@ app.use((_req, res, next) => { }); // Compression app.use(compressionMiddleware); +// Block search engine indexing on staging +app.use((req, res, next) => { + if (req.hostname.includes("staging")) { + res.setHeader("X-Robots-Tag", "noindex, nofollow"); + } + next(); +}); // Differentiated CORS middleware +const ALLOWED_ORIGINS = new Set(["https://docfast.dev", "https://staging.docfast.dev"]); app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || req.path.startsWith('/v1/recover') || @@ -57,7 +63,14 @@ app.use((req, res, next) => { req.path.startsWith('/v1/demo') || req.path.startsWith('/v1/email-change'); if (isAuthBillingRoute) { - res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); + const origin = req.headers.origin; + if (origin && ALLOWED_ORIGINS.has(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + } + else { + res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); + } } else { res.setHeader("Access-Control-Allow-Origin", "*"); @@ -128,155 +141,11 @@ app.use("/v1/billing", defaultJsonParser, billingRouter); const convertBodyLimit = express.json({ limit: "500kb" }); app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, templatesRouter); -/** - * @openapi - * /v1/usage/me: - * get: - * summary: Get your current month's usage - * description: Returns the authenticated user's PDF generation usage for the current billing month. - * security: - * - ApiKeyAuth: [] - * responses: - * 200: - * description: Current usage statistics - * content: - * application/json: - * schema: - * type: object - * properties: - * used: - * type: integer - * description: Number of PDFs generated this month - * limit: - * type: integer - * description: Monthly PDF limit for your plan - * plan: - * type: string - * enum: [pro, demo] - * description: Your current plan - * month: - * type: string - * description: Current billing month (YYYY-MM) - * 401: - * description: Missing or invalid API key - */ -app.get("/v1/usage/me", authMiddleware, (req, res) => { - const key = req.apiKeyInfo.key; - const { count, monthKey } = getUsageForKey(key); - const pro = isProKey(key); - res.json({ - used: count, - limit: pro ? 5000 : 100, - plan: pro ? "pro" : "demo", - month: monthKey, - }); -}); -// Admin: usage stats (admin key required) -const adminAuth = (req, res, next) => { - const adminKey = process.env.ADMIN_API_KEY; - if (!adminKey) { - res.status(503).json({ error: "Admin access not configured" }); - return; - } - if (req.apiKeyInfo?.key !== adminKey) { - res.status(403).json({ error: "Admin access required" }); - return; - } - next(); -}; -app.get("/v1/usage", authMiddleware, adminAuth, (req, res) => { - res.json(getUsageStats(req.apiKeyInfo?.key)); -}); -// Admin: concurrency stats (admin key required) -app.get("/v1/concurrency", authMiddleware, adminAuth, (_req, res) => { - res.json(getConcurrencyStats()); -}); -// Admin: database cleanup (admin key required) -app.post("/admin/cleanup", authMiddleware, adminAuth, async (_req, res) => { - try { - const results = await cleanupStaleData(); - res.json({ status: "ok", cleaned: results }); - } - catch (err) { - logger.error({ err }, "Admin cleanup failed"); - res.status(500).json({ error: "Cleanup failed", message: err.message }); - } -}); -// Email verification endpoint -app.get("/verify", (req, res) => { - const token = req.query.token; - if (!token) { - res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null)); - return; - } - const result = verifyToken(token); - switch (result.status) { - case "ok": - res.send(verifyPage("Email Verified! 🚀", "Your DocFast API key is ready:", result.verification.apiKey)); - break; - case "already_verified": - res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification.apiKey)); - break; - case "expired": - res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null)); - break; - case "invalid": - res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null)); - break; - } -}); -function verifyPage(title, message, apiKey) { - return ` - -${title} — DocFast - - - -
-

${title}

-

${message}

-${apiKey ? ` -
⚠️ Save your API key securely. You can recover it via email if needed.
-
${apiKey}
- -` : ``} -
- -`; -} -// Landing page +// Admin + usage routes (extracted to routes/admin.ts) +app.use(adminRouter); +// Pages, favicon, docs, openapi.json, /api (extracted to routes/pages.ts) const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Favicon route -app.get("/favicon.ico", (_req, res) => { - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'public, max-age=604800'); - res.sendFile(path.join(__dirname, "../public/favicon.svg")); -}); -// Dynamic OpenAPI spec — generated from @openapi JSDoc annotations at startup -app.get("/openapi.json", (_req, res) => { - res.json(swaggerSpec); -}); -// Docs page (clean URL) -app.get("/docs", (_req, res) => { - // Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation. - // Override helmet's default CSP to allow 'unsafe-eval' + blob: for Swagger UI. - res.setHeader("Content-Security-Policy", "default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' https: 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' https: data:;connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'"); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/docs.html")); -}); +app.use(pagesRouter); // Static asset cache headers middleware app.use((req, res, next) => { if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) { @@ -284,53 +153,10 @@ app.use((req, res, next) => { } next(); }); -// Landing page (explicit route to set Cache-Control header) -app.get("/", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=3600'); - res.sendFile(path.join(__dirname, "../public/index.html")); -}); app.use(express.static(path.join(__dirname, "../public"), { etag: true, cacheControl: false, })); -// Legal pages (clean URLs) -app.get("/impressum", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/impressum.html")); -}); -app.get("/privacy", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/privacy.html")); -}); -app.get("/terms", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/terms.html")); -}); -app.get("/examples", (_req, res) => { - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.sendFile(path.join(__dirname, "../public/examples.html")); -}); -app.get("/status", (_req, res) => { - res.setHeader("Cache-Control", "public, max-age=60"); - res.sendFile(path.join(__dirname, "../public/status.html")); -}); -// API root -app.get("/api", (_req, res) => { - res.json({ - name: "DocFast API", - version: APP_VERSION, - endpoints: [ - "POST /v1/demo/html — Try HTML→PDF (no auth, watermarked, 5/hour)", - "POST /v1/demo/markdown — Try Markdown→PDF (no auth, watermarked, 5/hour)", - "POST /v1/convert/html — HTML→PDF (requires API key)", - "POST /v1/convert/markdown — Markdown→PDF (requires API key)", - "POST /v1/convert/url — URL→PDF (requires API key)", - "POST /v1/templates/:id/render", - "GET /v1/templates", - "POST /v1/billing/checkout — Start Pro subscription", - ], - }); -}); // 404 handler - must be after all routes app.use((req, res) => { // Check if it's an API request @@ -373,12 +199,33 @@ app.use((req, res) => { `); } }); +// Global error handler — must be after all routes +app.use((err, req, res, _next) => { + const reqId = req.requestId || "unknown"; + // Check if this is a JSON parse error from express.json() + if (err instanceof SyntaxError && 'status' in err && err.status === 400 && 'body' in err) { + logger.warn({ err, requestId: reqId, method: req.method, path: req.path }, "Invalid JSON body"); + if (!res.headersSent) { + res.status(400).json({ error: "Invalid JSON in request body" }); + } + return; + } + logger.error({ err, requestId: reqId, method: req.method, path: req.path }, "Unhandled route error"); + if (!res.headersSent) { + const isApi = req.path.startsWith("/v1/") || req.path.startsWith("/health"); + if (isApi) { + res.status(500).json({ error: "Internal server error" }); + } + else { + res.status(500).send("Internal server error"); + } + } +}); async function start() { // Initialize PostgreSQL await initDatabase(); // Load data from PostgreSQL await loadKeys(); - await loadVerifications(); await loadUsageData(); await initBrowser(); logger.info(`Loaded ${getAllKeys().length} API keys`); @@ -393,12 +240,16 @@ async function start() { logger.error({ err }, "Startup cleanup failed (non-fatal)"); } }, 30_000); + // Run database cleanup every 6 hours (expired verifications, orphaned usage) + startPeriodicCleanup(); let shuttingDown = false; const shutdown = async (signal) => { if (shuttingDown) return; shuttingDown = true; logger.info(`Received ${signal}, starting graceful shutdown...`); + // 0. Stop periodic cleanup timer + stopPeriodicCleanup(); // 1. Stop accepting new connections, wait for in-flight requests (max 10s) await new Promise((resolve) => { const forceTimeout = setTimeout(() => { diff --git a/dist/middleware/usage.js b/dist/middleware/usage.js index 1074018..6dd72e3 100644 --- a/dist/middleware/usage.js +++ b/dist/middleware/usage.js @@ -83,7 +83,7 @@ export function usageMiddleware(req, res, next) { } const record = usage.get(key); if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) { - res.status(429).json({ error: "Free tier limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." }); + res.status(429).json({ error: "Account limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." }); return; } trackUsage(key, monthKey); diff --git a/dist/routes/billing.js b/dist/routes/billing.js index 9651bc4..2c4231d 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -1,15 +1,16 @@ import { Router } from "express"; -import rateLimit from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import Stripe from "stripe"; import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js"; import logger from "../services/logger.js"; -import { escapeHtml } from "../utils/html.js"; +import { renderSuccessPage, renderAlreadyProvisionedPage } from "../utils/billing-templates.js"; let _stripe = null; function getStripe() { if (!_stripe) { const key = process.env.STRIPE_SECRET_KEY; if (!key) throw new Error("STRIPE_SECRET_KEY not configured"); + // @ts-expect-error Stripe SDK types lag behind API versions _stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" }); } return _stripe; @@ -63,7 +64,7 @@ async function isDocFastSubscription(subscriptionId) { const checkoutLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 3, - keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown", + keyGenerator: (req) => ipKeyGenerator(req.ip || req.socket.remoteAddress || "unknown"), standardHeaders: true, legacyHeaders: false, message: { error: "Too many checkout requests. Please try again later." }, @@ -148,47 +149,12 @@ router.get("/success", async (req, res) => { const existingKey = await findKeyByCustomerId(customerId); if (existingKey) { provisionedSessions.set(session.id, Date.now()); - res.send(` -DocFast Pro — Key Already Provisioned - -
-

✅ Key Already Provisioned

-

A Pro API key has already been created for this purchase.

-

If you lost your key, use the key recovery feature.

-

View API docs →

-
`); + res.send(renderAlreadyProvisionedPage()); return; } const keyInfo = await createProKey(email, customerId); provisionedSessions.set(session.id, Date.now()); - // Return a nice HTML page instead of raw JSON - res.send(` -Welcome to DocFast Pro! - -
-

🎉 Welcome to Pro!

-

Your API key:

-
${escapeHtml(keyInfo.key)}
-

Save this key! It won't be shown again.

-

5,000 PDFs/month • All endpoints • Priority support

-

View API docs →

-
- -`); + res.send(renderSuccessPage(keyInfo.key)); } catch (err) { logger.error({ err }, "Success page error"); diff --git a/dist/routes/convert.js b/dist/routes/convert.js index 253d17d..3965751 100644 --- a/dist/routes/convert.js +++ b/dist/routes/convert.js @@ -2,10 +2,9 @@ import { Router } from "express"; import { renderPdf, renderUrlPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import dns from "node:dns/promises"; -import logger from "../services/logger.js"; import { isPrivateIP } from "../utils/network.js"; import { sanitizeFilename } from "../utils/sanitize.js"; -import { validatePdfOptions } from "../utils/pdf-options.js"; +import { handlePdfRoute } from "../utils/pdf-handler.js"; export const convertRouter = Router(); /** * @openapi @@ -38,6 +37,13 @@ export const convertRouter = Router(); * responses: * 200: * description: PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' * content: * application/pdf: * schema: @@ -53,60 +59,25 @@ export const convertRouter = Router(); * description: Unsupported Content-Type (must be application/json) * 429: * description: Rate limit or usage limit exceeded + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' * 500: * description: PDF generation failed */ convertRouter.post("/html", async (req, res) => { - let slotAcquired = false; - try { - // Reject non-JSON content types - const ct = req.headers["content-type"] || ""; - if (!ct.includes("application/json")) { - res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); - return; - } + await handlePdfRoute(req, res, async (sanitizedOptions) => { const body = typeof req.body === "string" ? { html: req.body } : req.body; if (!body.html) { res.status(400).json({ error: "Missing 'html' field" }); - return; + return null; } - // Validate PDF options - const validation = validatePdfOptions(body); - if (!validation.valid) { - res.status(400).json({ error: validation.error }); - return; - } - // Acquire concurrency slot - if (req.acquirePdfSlot) { - await req.acquirePdfSlot(); - slotAcquired = true; - } - // Wrap bare HTML fragments const fullHtml = body.html.includes(" { * responses: * 200: * description: PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' * content: * application/pdf: * schema: @@ -153,57 +131,23 @@ convertRouter.post("/html", async (req, res) => { * description: Unsupported Content-Type * 429: * description: Rate limit or usage limit exceeded + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' * 500: * description: PDF generation failed */ convertRouter.post("/markdown", async (req, res) => { - let slotAcquired = false; - try { - // Reject non-JSON content types - const ct = req.headers["content-type"] || ""; - if (!ct.includes("application/json")) { - res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); - return; - } + await handlePdfRoute(req, res, async (sanitizedOptions) => { const body = typeof req.body === "string" ? { markdown: req.body } : req.body; if (!body.markdown) { res.status(400).json({ error: "Missing 'markdown' field" }); - return; - } - // Validate PDF options - const validation = validatePdfOptions(body); - if (!validation.valid) { - res.status(400).json({ error: validation.error }); - return; - } - // Acquire concurrency slot - if (req.acquirePdfSlot) { - await req.acquirePdfSlot(); - slotAcquired = true; + return null; } const html = markdownToHtml(body.markdown, body.css); - const { pdf, durationMs } = await renderPdf(html, { - ...validation.sanitized, - }); - const filename = sanitizeFilename(body.filename || "document.pdf"); - res.setHeader("Content-Type", "application/pdf"); - res.setHeader("Content-Disposition", `inline; filename="${filename}"`); - res.setHeader("X-Render-Time", String(durationMs)); - res.send(pdf); - } - catch (err) { - logger.error({ err }, "Convert MD error"); - if (err.message === "QUEUE_FULL") { - res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); - return; - } - res.status(500).json({ error: `PDF generation failed: ${err.message}` }); - } - finally { - if (slotAcquired && req.releasePdfSlot) { - req.releasePdfSlot(); - } - } + const { pdf, durationMs } = await renderPdf(html, { ...sanitizedOptions }); + return { pdf, durationMs, filename: sanitizeFilename(body.filename || "document.pdf") }; + }); }); /** * @openapi @@ -240,6 +184,13 @@ convertRouter.post("/markdown", async (req, res) => { * responses: * 200: * description: PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' * content: * application/pdf: * schema: @@ -255,22 +206,18 @@ convertRouter.post("/markdown", async (req, res) => { * description: Unsupported Content-Type * 429: * description: Rate limit or usage limit exceeded + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' * 500: * description: PDF generation failed */ convertRouter.post("/url", async (req, res) => { - let slotAcquired = false; - try { - // Reject non-JSON content types - const ct = req.headers["content-type"] || ""; - if (!ct.includes("application/json")) { - res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); - return; - } + await handlePdfRoute(req, res, async (sanitizedOptions) => { const body = req.body; if (!body.url) { res.status(400).json({ error: "Missing 'url' field" }); - return; + return null; } // URL validation + SSRF protection let parsed; @@ -278,59 +225,31 @@ convertRouter.post("/url", async (req, res) => { parsed = new URL(body.url); if (!["http:", "https:"].includes(parsed.protocol)) { res.status(400).json({ error: "Only http/https URLs are supported" }); - return; + return null; } } catch { res.status(400).json({ error: "Invalid URL" }); - return; + return null; } - // DNS lookup to block private/reserved IPs + pin resolution to prevent DNS rebinding + // DNS lookup to block private/reserved IPs + pin resolution let resolvedAddress; try { const { address } = await dns.lookup(parsed.hostname); if (isPrivateIP(address)) { res.status(400).json({ error: "URL resolves to a private/internal IP address" }); - return; + return null; } resolvedAddress = address; } catch { res.status(400).json({ error: "DNS lookup failed for URL hostname" }); - return; - } - // Validate PDF options - const validation = validatePdfOptions(body); - if (!validation.valid) { - res.status(400).json({ error: validation.error }); - return; - } - // Acquire concurrency slot - if (req.acquirePdfSlot) { - await req.acquirePdfSlot(); - slotAcquired = true; + return null; } const { pdf, durationMs } = await renderUrlPdf(body.url, { - ...validation.sanitized, + ...sanitizedOptions, hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`, }); - const filename = sanitizeFilename(body.filename || "page.pdf"); - res.setHeader("Content-Type", "application/pdf"); - res.setHeader("Content-Disposition", `inline; filename="${filename}"`); - res.setHeader("X-Render-Time", String(durationMs)); - res.send(pdf); - } - catch (err) { - logger.error({ err }, "Convert URL error"); - if (err.message === "QUEUE_FULL") { - res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); - return; - } - res.status(500).json({ error: `PDF generation failed: ${err.message}` }); - } - finally { - if (slotAcquired && req.releasePdfSlot) { - req.releasePdfSlot(); - } - } + return { pdf, durationMs, filename: sanitizeFilename(body.filename || "page.pdf") }; + }); }); diff --git a/dist/routes/email-change.js b/dist/routes/email-change.js index feb68ea..ab5a9de 100644 --- a/dist/routes/email-change.js +++ b/dist/routes/email-change.js @@ -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 }; diff --git a/dist/routes/health.js b/dist/routes/health.js index 219fcd2..da35b67 100644 --- a/dist/routes/health.js +++ b/dist/routes/health.js @@ -90,7 +90,7 @@ healthRouter.get("/", async (_req, res) => { catch (error) { databaseStatus = { status: "error", - message: error.message || "Database connection failed" + message: error instanceof Error ? error.message : "Database connection failed" }; overallStatus = "degraded"; httpStatus = 503; diff --git a/dist/routes/recover.js b/dist/routes/recover.js index 6eee026..bac89b5 100644 --- a/dist/routes/recover.js +++ b/dist/routes/recover.js @@ -54,23 +54,39 @@ const recoverLimiter = rateLimit({ * 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) { + try { + 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) { + // DB fallback: cache may be stale in multi-replica setups + const dbResult = await queryWithRetry("SELECT key FROM api_keys WHERE email = $1 LIMIT 1", [cleanEmail]); + if (dbResult.rows.length > 0) { + const pending = await createPendingVerification(cleanEmail); + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send recovery email"); + }); + logger.info({ email: cleanEmail }, "recover: cache miss, sent recovery via DB fallback"); + } + 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." }); - 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." }); + catch (err) { + const reqId = req.requestId || "unknown"; + logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover"); + res.status(500).json({ error: "Internal server error" }); + } }); /** * @openapi @@ -119,58 +135,65 @@ router.post("/", recoverLimiter, async (req, res) => { * 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(); - let userKey = keys.find(k => k.email === cleanEmail); - // DB fallback: cache may be stale in multi-replica setups - if (!userKey) { - logger.info({ email: cleanEmail }, "recover verify: cache miss, falling back to DB"); - const dbResult = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1 LIMIT 1", [cleanEmail]); - if (dbResult.rows.length > 0) { - const row = dbResult.rows[0]; - userKey = { - key: row.key, - tier: row.tier, - email: row.email, - createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, - stripeCustomerId: row.stripe_customer_id || undefined, - }; - } - } - 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; + try { + const { email, code } = req.body || {}; + if (!email || !code) { + res.status(400).json({ error: "Email 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 = email.trim().toLowerCase(); + const cleanCode = String(code).trim(); + const result = await verifyCode(cleanEmail, cleanCode); + switch (result.status) { + case "ok": { + const keys = getAllKeys(); + let userKey = keys.find(k => k.email === cleanEmail); + // DB fallback: cache may be stale in multi-replica setups + if (!userKey) { + logger.info({ email: cleanEmail }, "recover verify: cache miss, falling back to DB"); + const dbResult = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1 LIMIT 1", [cleanEmail]); + if (dbResult.rows.length > 0) { + const row = dbResult.rows[0]; + userKey = { + key: row.key, + tier: row.tier, + email: row.email, + createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, + stripeCustomerId: row.stripe_customer_id || undefined, + }; + } + } + 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; + } + } + catch (err) { + const reqId = req.requestId || "unknown"; + logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover/verify"); + res.status(500).json({ error: "Internal server error" }); } }); export { router as recoverRouter }; diff --git a/dist/routes/templates.js b/dist/routes/templates.js index 5c6ccf0..6e481fd 100644 --- a/dist/routes/templates.js +++ b/dist/routes/templates.js @@ -168,6 +168,6 @@ templatesRouter.post("/:id/render", async (req, res) => { } catch (err) { logger.error({ err }, "Template render error"); - res.status(500).json({ error: "Template rendering failed", detail: err.message }); + res.status(500).json({ error: "Template rendering failed" }); } }); diff --git a/dist/services/browser.js b/dist/services/browser.js index 4ca5510..1776857 100644 --- a/dist/services/browser.js +++ b/dist/services/browser.js @@ -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) => { diff --git a/dist/services/db.js b/dist/services/db.js index fde5e35..6e0627f 100644 --- a/dist/services/db.js +++ b/dist/services/db.js @@ -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 }; diff --git a/dist/services/email.js b/dist/services/email.js index 3dc4d46..3fcc21d 100644 --- a/dist/services/email.js +++ b/dist/services/email.js @@ -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 { diff --git a/dist/services/keys.js b/dist/services/keys.js index 7222848..87736f1 100644 --- a/dist/services/keys.js +++ b/dist/services/keys.js @@ -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; } diff --git a/dist/services/verification.js b/dist/services/verification.js index c61e4b6..f7e0237 100644 --- a/dist/services/verification.js +++ b/dist/services/verification.js @@ -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; -} diff --git a/public/openapi.json b/public/openapi.json index aa1977b..13dd684 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -131,6 +131,59 @@ } }, "paths": { + "/v1/usage/me": { + "get": { + "summary": "Get your usage stats", + "tags": [ + "Account" + ], + "security": [ + { + "ApiKeyHeader": [] + }, + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Usage statistics for the authenticated user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "used": { + "type": "integer", + "description": "PDFs generated this month" + }, + "limit": { + "type": "integer", + "description": "Monthly PDF limit for your plan" + }, + "plan": { + "type": "string", + "enum": [ + "demo", + "pro" + ], + "description": "Current plan" + }, + "month": { + "type": "string", + "description": "Current billing month (YYYY-MM)" + } + } + } + } + } + }, + "401": { + "description": "Missing or invalid API key" + } + } + } + }, "/v1/billing/checkout": { "post": { "tags": [ @@ -168,102 +221,6 @@ } } }, - "/v1/billing/success": { - "get": { - "tags": [ - "Billing" - ], - "summary": "Checkout success page", - "description": "Provisions a Pro API key after successful Stripe checkout and displays it in an HTML page.\nCalled by Stripe redirect after payment completion.\n", - "parameters": [ - { - "in": "query", - "name": "session_id", - "required": true, - "schema": { - "type": "string" - }, - "description": "Stripe Checkout session ID" - } - ], - "responses": { - "200": { - "description": "HTML page displaying the new API key", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Missing session_id or no customer found" - }, - "409": { - "description": "Checkout session already used" - }, - "500": { - "description": "Failed to retrieve session" - } - } - } - }, - "/v1/billing/webhook": { - "post": { - "tags": [ - "Billing" - ], - "summary": "Stripe webhook endpoint", - "description": "Receives Stripe webhook events for subscription lifecycle management.\nRequires the raw request body and a valid Stripe-Signature header for verification.\nHandles checkout.session.completed, customer.subscription.updated,\ncustomer.subscription.deleted, and customer.updated events.\n", - "parameters": [ - { - "in": "header", - "name": "Stripe-Signature", - "required": true, - "schema": { - "type": "string" - }, - "description": "Stripe webhook signature for payload verification" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "Raw Stripe event payload" - } - } - } - }, - "responses": { - "200": { - "description": "Webhook received", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "received": { - "type": "boolean", - "example": true - } - } - } - } - } - }, - "400": { - "description": "Missing Stripe-Signature header or invalid signature" - }, - "500": { - "description": "Webhook secret not configured" - } - } - } - }, "/v1/convert/html": { "post": { "tags": [ @@ -314,6 +271,17 @@ "responses": { "200": { "description": "PDF document", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, "content": { "application/pdf": { "schema": { @@ -336,7 +304,12 @@ "description": "Unsupported Content-Type (must be application/json)" }, "429": { - "description": "Rate limit or usage limit exceeded" + "description": "Rate limit or usage limit exceeded", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + } }, "500": { "description": "PDF generation failed" @@ -393,6 +366,17 @@ "responses": { "200": { "description": "PDF document", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, "content": { "application/pdf": { "schema": { @@ -415,7 +399,12 @@ "description": "Unsupported Content-Type" }, "429": { - "description": "Rate limit or usage limit exceeded" + "description": "Rate limit or usage limit exceeded", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + } }, "500": { "description": "PDF generation failed" @@ -480,6 +469,17 @@ "responses": { "200": { "description": "PDF document", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, "content": { "application/pdf": { "schema": { @@ -502,7 +502,12 @@ "description": "Unsupported Content-Type" }, "429": { - "description": "Rate limit or usage limit exceeded" + "description": "Rate limit or usage limit exceeded", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + } }, "500": { "description": "PDF generation failed" @@ -551,6 +556,17 @@ "responses": { "200": { "description": "Watermarked PDF document", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, "content": { "application/pdf": { "schema": { @@ -575,6 +591,11 @@ }, "429": { "description": "Demo rate limit exceeded (5/hour)", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, "content": { "application/json": { "schema": { @@ -633,6 +654,17 @@ "responses": { "200": { "description": "Watermarked PDF document", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, "content": { "application/pdf": { "schema": { @@ -649,7 +681,12 @@ "description": "Unsupported Content-Type" }, "429": { - "description": "Demo rate limit exceeded (5/hour)" + "description": "Demo rate limit exceeded (5/hour)", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + } }, "503": { "description": "Server busy" @@ -660,6 +697,141 @@ } } }, + "/v1/email-change": { + "post": { + "tags": [ + "Account" + ], + "summary": "Request email change", + "description": "Sends a 6-digit verification code to the new email address.\nRate limited to 3 requests per hour per API key.\n", + "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" + } + } + } + }, + "/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" + } + } + } + }, "/health": { "get": { "tags": [ @@ -867,83 +1039,6 @@ } } }, - "/v1/signup/verify": { - "post": { - "tags": [ - "Account" - ], - "summary": "Verify email and get API key", - "description": "Verifies the 6-digit code sent to the user's email and provisions a free API key.\nRate limited to 15 attempts per 15 minutes.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email", - "code" - ], - "properties": { - "email": { - "type": "string", - "format": "email", - "description": "Email address used during signup", - "example": "user@example.com" - }, - "code": { - "type": "string", - "description": "6-digit verification code from email", - "example": "123456" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Email verified, API key issued", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "verified" - }, - "message": { - "type": "string" - }, - "apiKey": { - "type": "string", - "description": "The provisioned API key" - }, - "tier": { - "type": "string", - "example": "free" - } - } - } - } - } - }, - "400": { - "description": "Missing fields or invalid verification code" - }, - "409": { - "description": "Email already verified" - }, - "410": { - "description": "Verification code expired" - }, - "429": { - "description": "Too many failed attempts" - } - } - } - }, "/v1/templates": { "get": { "tags": [ diff --git a/src/__tests__/openapi-spec.test.ts b/src/__tests__/openapi-spec.test.ts index 250f9d8..e8f7d14 100644 --- a/src/__tests__/openapi-spec.test.ts +++ b/src/__tests__/openapi-spec.test.ts @@ -15,4 +15,94 @@ describe("OpenAPI spec accuracy", () => { it("should mark /v1/signup/verify as deprecated", () => { expect(spec.paths["/v1/signup/verify"]?.post?.deprecated).toBe(true); }); + + describe("Rate limit headers", () => { + it("should define rate limit header components", () => { + expect(spec.components.headers).toBeDefined(); + expect(spec.components.headers["X-RateLimit-Limit"]).toBeDefined(); + expect(spec.components.headers["X-RateLimit-Remaining"]).toBeDefined(); + expect(spec.components.headers["X-RateLimit-Reset"]).toBeDefined(); + expect(spec.components.headers["Retry-After"]).toBeDefined(); + }); + + it("X-RateLimit-Limit should be integer type with description", () => { + const header = spec.components.headers["X-RateLimit-Limit"]; + expect(header.schema.type).toBe("integer"); + expect(header.description).toContain("maximum"); + }); + + it("X-RateLimit-Remaining should be integer type with description", () => { + const header = spec.components.headers["X-RateLimit-Remaining"]; + expect(header.schema.type).toBe("integer"); + expect(header.description).toContain("remaining"); + }); + + it("X-RateLimit-Reset should be integer type with Unix timestamp description", () => { + const header = spec.components.headers["X-RateLimit-Reset"]; + expect(header.schema.type).toBe("integer"); + expect(header.description.toLowerCase()).toContain("unix"); + expect(header.description.toLowerCase()).toContain("timestamp"); + }); + + it("Retry-After should be integer type with description about seconds", () => { + const header = spec.components.headers["Retry-After"]; + expect(header.schema.type).toBe("integer"); + expect(header.description.toLowerCase()).toContain("second"); + }); + + const conversionEndpoints = [ + "/v1/convert/html", + "/v1/convert/markdown", + "/v1/convert/url", + ]; + + const demoEndpoints = ["/v1/demo/html", "/v1/demo/markdown"]; + + const allRateLimitedEndpoints = [...conversionEndpoints, ...demoEndpoints]; + + allRateLimitedEndpoints.forEach((endpoint) => { + describe(`${endpoint}`, () => { + it("should include rate limit headers in 200 response", () => { + const response200 = spec.paths[endpoint]?.post?.responses["200"]; + expect(response200).toBeDefined(); + expect(response200.headers).toBeDefined(); + expect(response200.headers["X-RateLimit-Limit"]).toBeDefined(); + expect(response200.headers["X-RateLimit-Remaining"]).toBeDefined(); + expect(response200.headers["X-RateLimit-Reset"]).toBeDefined(); + }); + + it("should reference header components in 200 response", () => { + const headers = spec.paths[endpoint]?.post?.responses["200"]?.headers; + expect(headers["X-RateLimit-Limit"].$ref).toBe( + "#/components/headers/X-RateLimit-Limit" + ); + expect(headers["X-RateLimit-Remaining"].$ref).toBe( + "#/components/headers/X-RateLimit-Remaining" + ); + expect(headers["X-RateLimit-Reset"].$ref).toBe( + "#/components/headers/X-RateLimit-Reset" + ); + }); + + it("should include Retry-After header in 429 response", () => { + const response429 = spec.paths[endpoint]?.post?.responses["429"]; + expect(response429).toBeDefined(); + expect(response429.headers).toBeDefined(); + expect(response429.headers["Retry-After"]).toBeDefined(); + expect(response429.headers["Retry-After"].$ref).toBe( + "#/components/headers/Retry-After" + ); + }); + }); + }); + + it("should mention rate limit headers in API description", () => { + const description = spec.info.description; + expect(description).toContain("X-RateLimit-Limit"); + expect(description).toContain("X-RateLimit-Remaining"); + expect(description).toContain("X-RateLimit-Reset"); + expect(description).toContain("Retry-After"); + expect(description).toContain("429"); + }); + }); }); diff --git a/src/routes/convert.ts b/src/routes/convert.ts index f3bad3c..b9d9efe 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -40,6 +40,13 @@ export const convertRouter = Router(); * responses: * 200: * description: PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' * content: * application/pdf: * schema: @@ -55,6 +62,9 @@ export const convertRouter = Router(); * description: Unsupported Content-Type (must be application/json) * 429: * description: Rate limit or usage limit exceeded + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' * 500: * description: PDF generation failed */ @@ -103,6 +113,13 @@ convertRouter.post("/html", async (req: Request, res: Response) => { * responses: * 200: * description: PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' * content: * application/pdf: * schema: @@ -118,6 +135,9 @@ convertRouter.post("/html", async (req: Request, res: Response) => { * description: Unsupported Content-Type * 429: * description: Rate limit or usage limit exceeded + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' * 500: * description: PDF generation failed */ @@ -169,6 +189,13 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => { * responses: * 200: * description: PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' * content: * application/pdf: * schema: @@ -184,6 +211,9 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => { * description: Unsupported Content-Type * 429: * description: Rate limit or usage limit exceeded + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' * 500: * description: PDF generation failed */ diff --git a/src/routes/demo.ts b/src/routes/demo.ts index 1ff9053..c6675d1 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -101,6 +101,13 @@ interface DemoBody { * responses: * 200: * description: Watermarked PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' * content: * application/pdf: * schema: @@ -116,6 +123,9 @@ interface DemoBody { * description: Unsupported Content-Type * 429: * description: Demo rate limit exceeded (5/hour) + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' * content: * application/json: * schema: @@ -187,6 +197,13 @@ router.post("/html", async (req: Request, res: Response) => { * responses: * 200: * description: Watermarked PDF document + * headers: + * X-RateLimit-Limit: + * $ref: '#/components/headers/X-RateLimit-Limit' + * X-RateLimit-Remaining: + * $ref: '#/components/headers/X-RateLimit-Remaining' + * X-RateLimit-Reset: + * $ref: '#/components/headers/X-RateLimit-Reset' * content: * application/pdf: * schema: @@ -198,6 +215,9 @@ router.post("/html", async (req: Request, res: Response) => { * description: Unsupported Content-Type * 429: * description: Demo rate limit exceeded (5/hour) + * headers: + * Retry-After: + * $ref: '#/components/headers/Retry-After' * 503: * description: Server busy * 504: diff --git a/src/swagger.ts b/src/swagger.ts index 9d48267..7d0c163 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -11,7 +11,7 @@ const options: swaggerJsdoc.Options = { title: "DocFast API", version, description: - "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header. Get your key at [docfast.dev](https://docfast.dev).\n\n## Rate Limits\n- Demo: 5 conversions/hour, watermarked output\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo endpoints (no auth required, 5/hour limit)\n2. Upgrade to Pro at [docfast.dev](https://docfast.dev) for clean output and higher limits\n3. Use your API key to convert documents", + "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header. Get your key at [docfast.dev](https://docfast.dev).\n\n## Rate Limits\n- Demo: 5 conversions/hour, watermarked output\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\nAll rate-limited endpoints return `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. On `429`, a `Retry-After` header indicates seconds until the next allowed request.\n\n## Getting Started\n1. Try the demo endpoints (no auth required, 5/hour limit)\n2. Upgrade to Pro at [docfast.dev](https://docfast.dev) for clean output and higher limits\n3. Use your API key to convert documents", contact: { name: "DocFast", url: "https://docfast.dev", @@ -41,6 +41,36 @@ const options: swaggerJsdoc.Options = { description: "API key via X-API-Key header", }, }, + headers: { + "X-RateLimit-Limit": { + description: "The maximum number of requests allowed in the current time window", + schema: { + type: "integer", + example: 30, + }, + }, + "X-RateLimit-Remaining": { + description: "The number of requests remaining in the current time window", + schema: { + type: "integer", + example: 29, + }, + }, + "X-RateLimit-Reset": { + description: "Unix timestamp (seconds since epoch) when the rate limit window resets", + schema: { + type: "integer", + example: 1679875200, + }, + }, + "Retry-After": { + description: "Number of seconds to wait before retrying the request (returned on 429 responses)", + schema: { + type: "integer", + example: 60, + }, + }, + }, schemas: { PdfOptions: { type: "object",