fix: OpenAPI spec accuracy — hide internal endpoints, mark signup/verify deprecated
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m9s

- Remove @openapi annotations from /v1/billing/webhook (Stripe-internal)
- Remove @openapi annotations from /v1/billing/success (browser redirect)
- Mark /v1/signup/verify as deprecated (returns 410)
- Add 3 TDD tests in openapi-spec.test.ts
- Update 2 existing tests in app-routes.test.ts
- 530 tests passing (was 527)
This commit is contained in:
Hoid 2026-03-07 14:06:12 +01:00
parent 1d5d9adf08
commit 6b1b3d584e
15 changed files with 399 additions and 290 deletions

View file

@ -555,6 +555,21 @@ describe("OpenAPI spec", () => {
expect(paths).toContain("/v1/convert/markdown"); expect(paths).toContain("/v1/convert/markdown");
expect(paths).toContain("/health"); expect(paths).toContain("/health");
}); });
it("PdfOptions schema includes all valid format values and waitUntil field", async () => {
const res = await fetch(`${BASE}/openapi.json`);
const spec = await res.json();
const pdfOptions = spec.components.schemas.PdfOptions;
expect(pdfOptions).toBeDefined();
// Check that all 11 format values are included
const expectedFormats = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"];
expect(pdfOptions.properties.format.enum).toEqual(expectedFormats);
// Check that waitUntil field exists with correct enum values
expect(pdfOptions.properties.waitUntil).toBeDefined();
expect(pdfOptions.properties.waitUntil.enum).toEqual(["load", "domcontentloaded", "networkidle0", "networkidle2"]);
// Check that headerTemplate and footerTemplate descriptions mention 100KB limit
expect(pdfOptions.properties.headerTemplate.description).toContain("100KB");
expect(pdfOptions.properties.footerTemplate.description).toContain("100KB");
});
}); });
describe("404 handler", () => { describe("404 handler", () => {
it("returns proper JSON error format for API routes", async () => { it("returns proper JSON error format for API routes", async () => {

73
dist/index.js vendored
View file

@ -14,13 +14,14 @@ import { templatesRouter } from "./routes/templates.js";
import { healthRouter } from "./routes/health.js"; import { healthRouter } from "./routes/health.js";
import { demoRouter } from "./routes/demo.js"; import { demoRouter } from "./routes/demo.js";
import { recoverRouter } from "./routes/recover.js"; import { recoverRouter } from "./routes/recover.js";
import { emailChangeRouter } from "./routes/email-change.js";
import { billingRouter } from "./routes/billing.js"; import { billingRouter } from "./routes/billing.js";
import { authMiddleware } from "./middleware/auth.js"; import { authMiddleware } from "./middleware/auth.js";
import { usageMiddleware, loadUsageData } from "./middleware/usage.js"; import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js";
import { getUsageStats } from "./middleware/usage.js"; import { getUsageStats, getUsageForKey } from "./middleware/usage.js";
import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js";
import { initBrowser, closeBrowser } from "./services/browser.js"; import { initBrowser, closeBrowser } from "./services/browser.js";
import { loadKeys, getAllKeys } from "./services/keys.js"; import { loadKeys, getAllKeys, isProKey } from "./services/keys.js";
import { verifyToken, loadVerifications } from "./services/verification.js"; import { verifyToken, loadVerifications } from "./services/verification.js";
import { initDatabase, pool, cleanupStaleData } from "./services/db.js"; import { initDatabase, pool, cleanupStaleData } from "./services/db.js";
import { swaggerSpec } from "./swagger.js"; import { swaggerSpec } from "./swagger.js";
@ -53,7 +54,8 @@ app.use((req, res, next) => {
const isAuthBillingRoute = req.path.startsWith('/v1/signup') || const isAuthBillingRoute = req.path.startsWith('/v1/signup') ||
req.path.startsWith('/v1/recover') || req.path.startsWith('/v1/recover') ||
req.path.startsWith('/v1/billing') || req.path.startsWith('/v1/billing') ||
req.path.startsWith('/v1/demo'); req.path.startsWith('/v1/demo') ||
req.path.startsWith('/v1/email-change');
if (isAuthBillingRoute) { if (isAuthBillingRoute) {
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev");
} }
@ -71,7 +73,8 @@ app.use((req, res, next) => {
}); });
// Raw body for Stripe webhook signature verification // Raw body for Stripe webhook signature verification
app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
app.use(express.json({ limit: "2mb" })); // NOTE: No global express.json() here — route-specific parsers are applied
// per-route below to enforce correct body size limits (BUG-101 fix).
app.use(express.text({ limit: "2mb", type: "text/*" })); app.use(express.text({ limit: "2mb", type: "text/*" }));
// Trust nginx proxy // Trust nginx proxy
app.set("trust proxy", 1); app.set("trust proxy", 1);
@ -116,12 +119,58 @@ app.use("/v1/signup", (_req, res) => {
pro_url: "https://docfast.dev/#pricing" pro_url: "https://docfast.dev/#pricing"
}); });
}); });
app.use("/v1/recover", recoverRouter); // Default 2MB JSON parser for standard routes
app.use("/v1/billing", billingRouter); const defaultJsonParser = express.json({ limit: "2mb" });
app.use("/v1/recover", defaultJsonParser, recoverRouter);
app.use("/v1/email-change", defaultJsonParser, emailChangeRouter);
app.use("/v1/billing", defaultJsonParser, billingRouter);
// Authenticated routes — conversion routes get tighter body limits (500KB) // Authenticated routes — conversion routes get tighter body limits (500KB)
const convertBodyLimit = express.json({ limit: "500kb" }); const convertBodyLimit = express.json({ limit: "500kb" });
app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); 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) // Admin: usage stats (admin key required)
const adminAuth = (req, res, next) => { const adminAuth = (req, res, next) => {
const adminKey = process.env.ADMIN_API_KEY; const adminKey = process.env.ADMIN_API_KEY;
@ -362,6 +411,14 @@ async function start() {
resolve(); resolve();
}); });
}); });
// 1.5. Flush dirty usage entries while DB pool is still alive
try {
await flushDirtyEntries();
logger.info("Usage data flushed");
}
catch (err) {
logger.error({ err }, "Error flushing usage data during shutdown");
}
// 2. Close Puppeteer browser pool // 2. Close Puppeteer browser pool
try { try {
await closeBrowser(); await closeBrowser();

View file

@ -30,53 +30,43 @@ export async function loadUsageData() {
} }
} }
// Batch flush dirty entries to DB (Audit #10 + #12) // Batch flush dirty entries to DB (Audit #10 + #12)
async function flushDirtyEntries() { export async function flushDirtyEntries() {
if (dirtyKeys.size === 0) if (dirtyKeys.size === 0)
return; return;
const keysToFlush = [...dirtyKeys]; const keysToFlush = [...dirtyKeys];
const client = await connectWithRetry(); for (const key of keysToFlush) {
try { const record = usage.get(key);
await client.query("BEGIN"); if (!record)
for (const key of keysToFlush) { continue;
const record = usage.get(key); const client = await connectWithRetry();
if (!record) try {
continue; await client.query(`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3)
try { ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`, [key, record.count, record.monthKey]);
await client.query(`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3) dirtyKeys.delete(key);
ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`, [key, record.count, record.monthKey]); retryCount.delete(key);
}
catch (error) {
// Audit #12: retry logic for failed writes
const retries = (retryCount.get(key) || 0) + 1;
if (retries >= MAX_RETRIES) {
logger.error({ key: key.slice(0, 8) + "...", retries }, "CRITICAL: Usage write failed after max retries, data may diverge");
dirtyKeys.delete(key); dirtyKeys.delete(key);
retryCount.delete(key); retryCount.delete(key);
} }
catch (error) { else {
// Audit #12: retry logic for failed writes retryCount.set(key, retries);
const retries = (retryCount.get(key) || 0) + 1; logger.warn({ key: key.slice(0, 8) + "...", retries }, "Usage write failed, will retry");
if (retries >= MAX_RETRIES) {
logger.error({ key: key.slice(0, 8) + "...", retries }, "CRITICAL: Usage write failed after max retries, data may diverge");
dirtyKeys.delete(key);
retryCount.delete(key);
}
else {
retryCount.set(key, retries);
logger.warn({ key: key.slice(0, 8) + "...", retries }, "Usage write failed, will retry");
}
} }
} }
await client.query("COMMIT"); finally {
} client.release();
catch (error) { }
await client.query("ROLLBACK").catch(() => { });
logger.error({ err: error }, "Failed to flush usage batch");
// Keep all keys dirty for retry
}
finally {
client.release();
} }
} }
// Periodic flush // Periodic flush
setInterval(flushDirtyEntries, FLUSH_INTERVAL_MS); setInterval(flushDirtyEntries, FLUSH_INTERVAL_MS);
// Flush on process exit // Note: SIGTERM/SIGINT flush is handled by the shutdown orchestrator in index.ts
process.on("SIGTERM", () => { flushDirtyEntries().catch(() => { }); }); // to avoid race conditions with pool.end().
process.on("SIGINT", () => { flushDirtyEntries().catch(() => { }); });
export function usageMiddleware(req, res, next) { export function usageMiddleware(req, res, next) {
const keyInfo = req.apiKeyInfo; const keyInfo = req.apiKeyInfo;
const key = keyInfo?.key || "unknown"; const key = keyInfo?.key || "unknown";
@ -113,6 +103,14 @@ function trackUsage(key, monthKey) {
flushDirtyEntries().catch((err) => logger.error({ err }, "Threshold flush failed")); flushDirtyEntries().catch((err) => logger.error({ err }, "Threshold flush failed"));
} }
} }
export function getUsageForKey(key) {
const monthKey = getMonthKey();
const record = usage.get(key);
if (record && record.monthKey === monthKey) {
return { count: record.count, monthKey };
}
return { count: 0, monthKey };
}
export function getUsageStats(apiKey) { export function getUsageStats(apiKey) {
const stats = {}; const stats = {};
if (apiKey) { if (apiKey) {

110
dist/routes/billing.js vendored
View file

@ -15,8 +15,29 @@ function getStripe() {
return _stripe; return _stripe;
} }
const router = Router(); const router = Router();
// Track provisioned session IDs to prevent duplicate key creation // Track provisioned session IDs with TTL to prevent duplicate key creation and memory leaks
const provisionedSessions = new Set(); // Map<sessionId, timestamp> - entries older than 24h are periodically cleaned up
const provisionedSessions = new Map();
// TTL Configuration
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // Clean up every 1 hour
// Cleanup old provisioned session entries
function cleanupOldSessions() {
const now = Date.now();
const cutoff = now - SESSION_TTL_MS;
let cleanedCount = 0;
for (const [sessionId, timestamp] of provisionedSessions.entries()) {
if (timestamp < cutoff) {
provisionedSessions.delete(sessionId);
cleanedCount++;
}
}
if (cleanedCount > 0) {
logger.info({ cleanedCount, remainingCount: provisionedSessions.size }, "Cleaned up expired provisioned sessions");
}
}
// Start periodic cleanup
setInterval(cleanupOldSessions, CLEANUP_INTERVAL_MS);
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
// Returns true if the given Stripe subscription contains a DocFast product. // Returns true if the given Stripe subscription contains a DocFast product.
// Used to filter webhook events — this Stripe account is shared with other projects. // Used to filter webhook events — this Stripe account is shared with other projects.
@ -101,43 +122,15 @@ router.post("/checkout", checkoutLimiter, async (req, res) => {
res.status(500).json({ error: "Failed to create checkout session" }); res.status(500).json({ error: "Failed to create checkout session" });
} }
}); });
/** // Success page — provision Pro API key after checkout (browser redirect, not a public API)
* @openapi
* /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.
* Called by Stripe redirect after payment completion.
* 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
*/
// Success page — provision Pro API key after checkout
router.get("/success", async (req, res) => { router.get("/success", async (req, res) => {
const sessionId = req.query.session_id; const sessionId = req.query.session_id;
if (!sessionId) { if (!sessionId) {
res.status(400).json({ error: "Missing session_id" }); res.status(400).json({ error: "Missing session_id" });
return; return;
} }
// Clean up old sessions before checking duplicates
cleanupOldSessions();
// Prevent duplicate provisioning from same session // Prevent duplicate provisioning from same session
if (provisionedSessions.has(sessionId)) { if (provisionedSessions.has(sessionId)) {
res.status(409).json({ error: "This checkout session has already been used to provision a key. If you lost your key, use the key recovery feature." }); res.status(409).json({ error: "This checkout session has already been used to provision a key. If you lost your key, use the key recovery feature." });
@ -151,10 +144,10 @@ router.get("/success", async (req, res) => {
res.status(400).json({ error: "No customer found" }); res.status(400).json({ error: "No customer found" });
return; return;
} }
// Check DB for existing key (survives pod restarts, unlike provisionedSessions Set) // Check DB for existing key (survives pod restarts, unlike provisionedSessions Map)
const existingKey = await findKeyByCustomerId(customerId); const existingKey = await findKeyByCustomerId(customerId);
if (existingKey) { if (existingKey) {
provisionedSessions.add(session.id); provisionedSessions.set(session.id, Date.now());
res.send(`<!DOCTYPE html> res.send(`<!DOCTYPE html>
<html><head><title>DocFast Pro Key Already Provisioned</title> <html><head><title>DocFast Pro Key Already Provisioned</title>
<style> <style>
@ -173,7 +166,7 @@ a { color: #4f9; }
return; return;
} }
const keyInfo = await createProKey(email, customerId); const keyInfo = await createProKey(email, customerId);
provisionedSessions.add(session.id); provisionedSessions.set(session.id, Date.now());
// Return a nice HTML page instead of raw JSON // Return a nice HTML page instead of raw JSON
res.send(`<!DOCTYPE html> res.send(`<!DOCTYPE html>
<html><head><title>Welcome to DocFast Pro!</title> <html><head><title>Welcome to DocFast Pro!</title>
@ -202,48 +195,7 @@ a { color: #4f9; }
res.status(500).json({ error: "Failed to retrieve session" }); res.status(500).json({ error: "Failed to retrieve session" });
} }
}); });
/** // Stripe webhook for subscription lifecycle events (internal, not in public API docs)
* @openapi
* /v1/billing/webhook:
* post:
* tags: [Billing]
* summary: Stripe webhook endpoint
* description: |
* Receives Stripe webhook events for subscription lifecycle management.
* Requires the raw request body and a valid Stripe-Signature header for verification.
* Handles checkout.session.completed, customer.subscription.updated,
* customer.subscription.deleted, and customer.updated events.
* 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
*/
// Stripe webhook for subscription lifecycle events
router.post("/webhook", async (req, res) => { router.post("/webhook", async (req, res) => {
const sig = req.headers["stripe-signature"]; const sig = req.headers["stripe-signature"];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
@ -297,7 +249,7 @@ router.post("/webhook", async (req, res) => {
break; break;
} }
const keyInfo = await createProKey(email, customerId); const keyInfo = await createProKey(email, customerId);
provisionedSessions.add(session.id); provisionedSessions.set(session.id, Date.now());
logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key"); logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key");
break; break;
} }

View file

@ -5,6 +5,7 @@ import dns from "node:dns/promises";
import logger from "../services/logger.js"; import logger from "../services/logger.js";
import { isPrivateIP } from "../utils/network.js"; import { isPrivateIP } from "../utils/network.js";
import { sanitizeFilename } from "../utils/sanitize.js"; import { sanitizeFilename } from "../utils/sanitize.js";
import { validatePdfOptions } from "../utils/pdf-options.js";
export const convertRouter = Router(); export const convertRouter = Router();
/** /**
* @openapi * @openapi
@ -69,6 +70,12 @@ convertRouter.post("/html", async (req, res) => {
res.status(400).json({ error: "Missing 'html' field" }); res.status(400).json({ error: "Missing 'html' field" });
return; return;
} }
// Validate PDF options
const validation = validatePdfOptions(body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
// Acquire concurrency slot // Acquire concurrency slot
if (req.acquirePdfSlot) { if (req.acquirePdfSlot) {
await req.acquirePdfSlot(); await req.acquirePdfSlot();
@ -78,23 +85,13 @@ convertRouter.post("/html", async (req, res) => {
const fullHtml = body.html.includes("<html") const fullHtml = body.html.includes("<html")
? body.html ? body.html
: wrapHtml(body.html, body.css); : wrapHtml(body.html, body.css);
const pdf = await renderPdf(fullHtml, { const { pdf, durationMs } = await renderPdf(fullHtml, {
format: body.format, ...validation.sanitized,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
headerTemplate: body.headerTemplate,
footerTemplate: body.footerTemplate,
displayHeaderFooter: body.displayHeaderFooter,
scale: body.scale,
pageRanges: body.pageRanges,
preferCSSPageSize: body.preferCSSPageSize,
width: body.width,
height: body.height,
}); });
const filename = sanitizeFilename(body.filename || "document.pdf"); const filename = sanitizeFilename(body.filename || "document.pdf");
res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.setHeader("X-Render-Time", String(durationMs));
res.send(pdf); res.send(pdf);
} }
catch (err) { catch (err) {
@ -173,29 +170,25 @@ convertRouter.post("/markdown", async (req, res) => {
res.status(400).json({ error: "Missing 'markdown' field" }); res.status(400).json({ error: "Missing 'markdown' field" });
return; return;
} }
// Validate PDF options
const validation = validatePdfOptions(body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
// Acquire concurrency slot // Acquire concurrency slot
if (req.acquirePdfSlot) { if (req.acquirePdfSlot) {
await req.acquirePdfSlot(); await req.acquirePdfSlot();
slotAcquired = true; slotAcquired = true;
} }
const html = markdownToHtml(body.markdown, body.css); const html = markdownToHtml(body.markdown, body.css);
const pdf = await renderPdf(html, { const { pdf, durationMs } = await renderPdf(html, {
format: body.format, ...validation.sanitized,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
headerTemplate: body.headerTemplate,
footerTemplate: body.footerTemplate,
displayHeaderFooter: body.displayHeaderFooter,
scale: body.scale,
pageRanges: body.pageRanges,
preferCSSPageSize: body.preferCSSPageSize,
width: body.width,
height: body.height,
}); });
const filename = sanitizeFilename(body.filename || "document.pdf"); const filename = sanitizeFilename(body.filename || "document.pdf");
res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.setHeader("X-Render-Time", String(durationMs));
res.send(pdf); res.send(pdf);
} }
catch (err) { catch (err) {
@ -306,30 +299,25 @@ convertRouter.post("/url", async (req, res) => {
res.status(400).json({ error: "DNS lookup failed for URL hostname" }); res.status(400).json({ error: "DNS lookup failed for URL hostname" });
return; return;
} }
// Validate PDF options
const validation = validatePdfOptions(body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
// Acquire concurrency slot // Acquire concurrency slot
if (req.acquirePdfSlot) { if (req.acquirePdfSlot) {
await req.acquirePdfSlot(); await req.acquirePdfSlot();
slotAcquired = true; slotAcquired = true;
} }
const pdf = await renderUrlPdf(body.url, { const { pdf, durationMs } = await renderUrlPdf(body.url, {
format: body.format, ...validation.sanitized,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
headerTemplate: body.headerTemplate,
footerTemplate: body.footerTemplate,
displayHeaderFooter: body.displayHeaderFooter,
scale: body.scale,
pageRanges: body.pageRanges,
preferCSSPageSize: body.preferCSSPageSize,
width: body.width,
height: body.height,
waitUntil: body.waitUntil,
hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`, hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`,
}); });
const filename = sanitizeFilename(body.filename || "page.pdf"); const filename = sanitizeFilename(body.filename || "page.pdf");
res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.setHeader("X-Render-Time", String(durationMs));
res.send(pdf); res.send(pdf);
} }
catch (err) { catch (err) {

View file

@ -2,70 +2,162 @@ import { Router } from "express";
import rateLimit from "express-rate-limit"; import rateLimit from "express-rate-limit";
import { createPendingVerification, verifyCode } from "../services/verification.js"; import { createPendingVerification, verifyCode } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js"; import { sendVerificationEmail } from "../services/email.js";
import { getAllKeys, updateKeyEmail } from "../services/keys.js"; import { queryWithRetry } from "../services/db.js";
import logger from "../services/logger.js"; import logger from "../services/logger.js";
const router = Router(); const router = Router();
const changeLimiter = rateLimit({ const emailChangeLimiter = rateLimit({
windowMs: 60 * 60 * 1000, windowMs: 60 * 60 * 1000,
max: 3, max: 3,
message: { error: "Too many attempts. Please try again in 1 hour." }, message: { error: "Too many email change attempts. Please try again in 1 hour." },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
keyGenerator: (req) => req.body?.apiKey || req.ip || "unknown",
}); });
router.post("/", changeLimiter, async (req, res) => { const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey; async function validateApiKey(apiKey) {
const newEmail = req.body?.newEmail; const result = await queryWithRetry(`SELECT key, email, tier FROM api_keys WHERE key = $1`, [apiKey]);
return result.rows[0] || null;
}
/**
* @openapi
* /v1/email-change:
* post:
* tags: [Account]
* summary: Request email change
* description: |
* Sends a 6-digit verification code to the new email address.
* Rate limited to 3 requests per hour per API key.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [apiKey, newEmail]
* properties:
* apiKey:
* type: string
* newEmail:
* type: string
* format: email
* responses:
* 200:
* description: Verification code sent
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: verification_sent
* message:
* type: string
* 400:
* description: Missing or invalid fields
* 403:
* description: Invalid API key
* 409:
* description: Email already taken
* 429:
* description: Too many attempts
*/
router.post("/", emailChangeLimiter, async (req, res) => {
const { apiKey, newEmail } = req.body || {};
if (!apiKey || typeof apiKey !== "string") { if (!apiKey || typeof apiKey !== "string") {
res.status(400).json({ error: "API key is required (Authorization header or body)." }); res.status(400).json({ error: "apiKey is required." });
return; return;
} }
if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { if (!newEmail || typeof newEmail !== "string") {
res.status(400).json({ error: "A valid new email address is required." }); res.status(400).json({ error: "newEmail is required." });
return; return;
} }
const cleanEmail = newEmail.trim().toLowerCase(); const cleanEmail = newEmail.trim().toLowerCase();
const keys = getAllKeys(); if (!EMAIL_RE.test(cleanEmail)) {
const userKey = keys.find((k) => k.key === apiKey); res.status(400).json({ error: "Invalid email format." });
if (!userKey) {
res.status(401).json({ error: "Invalid API key." });
return; return;
} }
const existing = keys.find((k) => k.email === cleanEmail); const keyRow = await validateApiKey(apiKey);
if (existing) { 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." }); res.status(409).json({ error: "This email is already associated with another account." });
return; return;
} }
const pending = await createPendingVerification(cleanEmail); const pending = await createPendingVerification(cleanEmail);
sendVerificationEmail(cleanEmail, pending.code).catch((err) => { sendVerificationEmail(cleanEmail, pending.code).catch(err => {
logger.error({ err, email: cleanEmail }, "Failed to send email change verification"); logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
}); });
res.json({ status: "verification_sent", message: "Verification code sent to your new email address." }); res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." });
}); });
router.post("/verify", changeLimiter, async (req, res) => { /**
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey; * @openapi
const { newEmail, code } = req.body || {}; * /v1/email-change/verify:
* post:
* tags: [Account]
* summary: Verify email change code
* description: Verifies the 6-digit code and updates the account email.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [apiKey, newEmail, code]
* properties:
* apiKey:
* type: string
* newEmail:
* type: string
* format: email
* code:
* type: string
* pattern: '^\d{6}$'
* responses:
* 200:
* description: Email updated
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: ok
* newEmail:
* type: string
* 400:
* description: Missing fields or invalid code
* 403:
* description: Invalid API key
* 410:
* description: Code expired
* 429:
* description: Too many failed attempts
*/
router.post("/verify", async (req, res) => {
const { apiKey, newEmail, code } = req.body || {};
if (!apiKey || !newEmail || !code) { if (!apiKey || !newEmail || !code) {
res.status(400).json({ error: "API key, new email, and code are required." }); res.status(400).json({ error: "apiKey, newEmail, and code are required." });
return; return;
} }
const cleanEmail = newEmail.trim().toLowerCase(); const cleanEmail = newEmail.trim().toLowerCase();
const cleanCode = String(code).trim(); const cleanCode = String(code).trim();
const keys = getAllKeys(); const keyRow = await validateApiKey(apiKey);
const userKey = keys.find((k) => k.key === apiKey); if (!keyRow) {
if (!userKey) { res.status(403).json({ error: "Invalid API key." });
res.status(401).json({ error: "Invalid API key." });
return; return;
} }
const result = await verifyCode(cleanEmail, cleanCode); const result = await verifyCode(cleanEmail, cleanCode);
switch (result.status) { switch (result.status) {
case "ok": { case "ok": {
const updated = await updateKeyEmail(apiKey, cleanEmail); await queryWithRetry(`UPDATE api_keys SET email = $1 WHERE key = $2`, [cleanEmail, apiKey]);
if (updated) { logger.info({ apiKey: apiKey.slice(0, 10) + "...", newEmail: cleanEmail }, "Email changed");
res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail }); res.json({ status: "ok", newEmail: cleanEmail });
}
else {
res.status(500).json({ error: "Failed to update email." });
}
break; break;
} }
case "expired": case "expired":

View file

@ -3,6 +3,7 @@ import rateLimit from "express-rate-limit";
import { createPendingVerification, verifyCode } from "../services/verification.js"; import { createPendingVerification, verifyCode } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js"; import { sendVerificationEmail } from "../services/email.js";
import { getAllKeys } from "../services/keys.js"; import { getAllKeys } from "../services/keys.js";
import { queryWithRetry } from "../services/db.js";
import logger from "../services/logger.js"; import logger from "../services/logger.js";
const router = Router(); const router = Router();
const recoverLimiter = rateLimit({ const recoverLimiter = rateLimit({
@ -129,7 +130,22 @@ router.post("/verify", recoverLimiter, async (req, res) => {
switch (result.status) { switch (result.status) {
case "ok": { case "ok": {
const keys = getAllKeys(); const keys = getAllKeys();
const userKey = keys.find(k => k.email === cleanEmail); 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) { if (userKey) {
res.json({ res.json({
status: "recovered", status: "recovered",

View file

@ -56,9 +56,10 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => {
* /v1/signup/verify: * /v1/signup/verify:
* post: * post:
* tags: [Account] * tags: [Account]
* summary: Verify email and get API key * summary: Verify email and get API key (discontinued)
* deprecated: true
* description: | * description: |
* Verifies the 6-digit code sent to the user's email and provisions a free API key. * **Discontinued.** Free accounts are no longer available. Try the demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev.
* Rate limited to 15 attempts per 15 minutes. * Rate limited to 15 attempts per 15 minutes.
* requestBody: * requestBody:
* required: true * required: true

View file

@ -3,6 +3,7 @@ import { renderPdf } from "../services/browser.js";
import logger from "../services/logger.js"; import logger from "../services/logger.js";
import { templates, renderTemplate } from "../services/templates.js"; import { templates, renderTemplate } from "../services/templates.js";
import { sanitizeFilename } from "../utils/sanitize.js"; import { sanitizeFilename } from "../utils/sanitize.js";
import { validatePdfOptions } from "../utils/pdf-options.js";
export const templatesRouter = Router(); export const templatesRouter = Router();
/** /**
* @openapi * @openapi
@ -146,11 +147,20 @@ templatesRouter.post("/:id/render", async (req, res) => {
}); });
return; return;
} }
// Validate PDF options from underscore-prefixed fields (BUG-103)
const pdfOpts = {};
if (data._format !== undefined)
pdfOpts.format = data._format;
if (data._margin !== undefined)
pdfOpts.margin = data._margin;
const validation = validatePdfOptions(pdfOpts);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
const sanitizedPdf = { format: "A4", ...validation.sanitized };
const html = renderTemplate(id, data); const html = renderTemplate(id, data);
const pdf = await renderPdf(html, { const { pdf, durationMs } = await renderPdf(html, sanitizedPdf);
format: data._format || "A4",
margin: data._margin,
});
const filename = sanitizeFilename(data._filename || `${id}.pdf`); const filename = sanitizeFilename(data._filename || `${id}.pdf`);
res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("Content-Disposition", `inline; filename="${filename}"`);

View file

@ -27,11 +27,14 @@ export function getPoolStats() {
})), })),
}; };
} }
async function recyclePage(page) { export async function recyclePage(page) {
try { try {
const client = await page.createCDPSession(); const client = await page.createCDPSession();
await client.send("Network.clearBrowserCache").catch(() => { }); await client.send("Network.clearBrowserCache").catch(() => { });
await client.detach().catch(() => { }); await client.detach().catch(() => { });
// Clean up request interception (set by renderUrlPdf for SSRF protection)
page.removeAllListeners("request");
await page.setRequestInterception(false).catch(() => { });
const cookies = await page.cookies(); const cookies = await page.cookies();
if (cookies.length > 0) { if (cookies.length > 0) {
await page.deleteCookie(...cookies); await page.deleteCookie(...cookies);
@ -197,6 +200,8 @@ export async function renderPdf(html, options = {}) {
const { page, instance } = await acquirePage(); const { page, instance } = await acquirePage();
try { try {
await page.setJavaScriptEnabled(false); await page.setJavaScriptEnabled(false);
const startTime = Date.now();
let timeoutId;
const result = await Promise.race([ const result = await Promise.race([
(async () => { (async () => {
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 }); await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 });
@ -217,9 +222,13 @@ export async function renderPdf(html, options = {}) {
}); });
return Buffer.from(pdf); return Buffer.from(pdf);
})(), })(),
new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)), new Promise((_, reject) => {
]); timeoutId = setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000);
return result; }),
]).finally(() => clearTimeout(timeoutId));
const durationMs = Date.now() - startTime;
logger.info(`PDF rendered in ${durationMs}ms (html, ${result.length} bytes)`);
return { pdf: result, durationMs };
} }
finally { finally {
releasePage(page, instance); releasePage(page, instance);
@ -264,6 +273,8 @@ export async function renderUrlPdf(url, options = {}) {
}); });
} }
} }
const startTime = Date.now();
let timeoutId;
const result = await Promise.race([ const result = await Promise.race([
(async () => { (async () => {
await page.goto(url, { await page.goto(url, {
@ -286,9 +297,13 @@ export async function renderUrlPdf(url, options = {}) {
}); });
return Buffer.from(pdf); return Buffer.from(pdf);
})(), })(),
new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)), new Promise((_, reject) => {
]); timeoutId = setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000);
return result; }),
]).finally(() => clearTimeout(timeoutId));
const durationMs = Date.now() - startTime;
logger.info(`PDF rendered in ${durationMs}ms (url, ${result.length} bytes)`);
return { pdf: result, durationMs };
} }
finally { finally {
releasePage(page, instance); releasePage(page, instance);

21
dist/services/keys.js vendored
View file

@ -100,7 +100,26 @@ export async function downgradeByCustomer(stripeCustomerId) {
await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]); await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
return true; return true;
} }
return false; // 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) {
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");
return true;
} }
export async function findKeyByCustomerId(stripeCustomerId) { export async function findKeyByCustomerId(stripeCustomerId) {
// Check DB directly — survives pod restarts unlike in-memory cache // Check DB directly — survives pod restarts unlike in-memory cache

View file

@ -106,14 +106,12 @@ describe("App-level routes", () => {
expect(spec.paths["/v1/signup/verify"].post).toBeDefined(); expect(spec.paths["/v1/signup/verify"].post).toBeDefined();
}); });
it("includes GET /v1/billing/success", () => { it("excludes GET /v1/billing/success (browser redirect, not public API)", () => {
expect(spec.paths["/v1/billing/success"]).toBeDefined(); expect(spec.paths["/v1/billing/success"]).toBeUndefined();
expect(spec.paths["/v1/billing/success"].get).toBeDefined();
}); });
it("includes POST /v1/billing/webhook", () => { it("excludes POST /v1/billing/webhook (internal Stripe endpoint)", () => {
expect(spec.paths["/v1/billing/webhook"]).toBeDefined(); expect(spec.paths["/v1/billing/webhook"]).toBeUndefined();
expect(spec.paths["/v1/billing/webhook"].post).toBeDefined();
}); });
}); });

View file

@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { swaggerSpec } from "../swagger.js";
describe("OpenAPI spec accuracy", () => {
const spec = swaggerSpec as any;
it("should NOT include /v1/billing/webhook (internal Stripe endpoint)", () => {
expect(spec.paths).not.toHaveProperty("/v1/billing/webhook");
});
it("should NOT include /v1/billing/success (browser redirect page)", () => {
expect(spec.paths).not.toHaveProperty("/v1/billing/success");
});
it("should mark /v1/signup/verify as deprecated", () => {
expect(spec.paths["/v1/signup/verify"]?.post?.deprecated).toBe(true);
});
});

View file

@ -139,37 +139,7 @@ router.post("/checkout", checkoutLimiter, async (req: Request, res: Response) =>
} }
}); });
/** // Success page — provision Pro API key after checkout (browser redirect, not a public API)
* @openapi
* /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.
* Called by Stripe redirect after payment completion.
* 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
*/
// Success page — provision Pro API key after checkout
router.get("/success", async (req: Request, res: Response) => { router.get("/success", async (req: Request, res: Response) => {
const sessionId = req.query.session_id as string; const sessionId = req.query.session_id as string;
if (!sessionId) { if (!sessionId) {
@ -249,48 +219,7 @@ a { color: #4f9; }
} }
}); });
/** // Stripe webhook for subscription lifecycle events (internal, not in public API docs)
* @openapi
* /v1/billing/webhook:
* post:
* tags: [Billing]
* summary: Stripe webhook endpoint
* description: |
* Receives Stripe webhook events for subscription lifecycle management.
* Requires the raw request body and a valid Stripe-Signature header for verification.
* Handles checkout.session.completed, customer.subscription.updated,
* customer.subscription.deleted, and customer.updated events.
* 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
*/
// Stripe webhook for subscription lifecycle events
router.post("/webhook", async (req: Request, res: Response) => { router.post("/webhook", async (req: Request, res: Response) => {
const sig = req.headers["stripe-signature"] as string; const sig = req.headers["stripe-signature"] as string;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

View file

@ -68,9 +68,10 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, r
* /v1/signup/verify: * /v1/signup/verify:
* post: * post:
* tags: [Account] * tags: [Account]
* summary: Verify email and get API key * summary: Verify email and get API key (discontinued)
* deprecated: true
* description: | * description: |
* Verifies the 6-digit code sent to the user's email and provisions a free API key. * **Discontinued.** Free accounts are no longer available. Try the demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev.
* Rate limited to 15 attempts per 15 minutes. * Rate limited to 15 attempts per 15 minutes.
* requestBody: * requestBody:
* required: true * required: true