diff --git a/dist/__tests__/api.test.js b/dist/__tests__/api.test.js index 57b4d1b..93deda1 100644 --- a/dist/__tests__/api.test.js +++ b/dist/__tests__/api.test.js @@ -555,6 +555,21 @@ describe("OpenAPI spec", () => { expect(paths).toContain("/v1/convert/markdown"); 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", () => { it("returns proper JSON error format for API routes", async () => { diff --git a/dist/index.js b/dist/index.js index 444ba2b..daba546 100644 --- a/dist/index.js +++ b/dist/index.js @@ -14,13 +14,14 @@ import { templatesRouter } from "./routes/templates.js"; import { healthRouter } from "./routes/health.js"; import { demoRouter } from "./routes/demo.js"; import { recoverRouter } from "./routes/recover.js"; +import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; -import { usageMiddleware, loadUsageData } from "./middleware/usage.js"; -import { getUsageStats } from "./middleware/usage.js"; +import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js"; +import { getUsageStats, getUsageForKey } from "./middleware/usage.js"; import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.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 { initDatabase, pool, cleanupStaleData } from "./services/db.js"; import { swaggerSpec } from "./swagger.js"; @@ -53,7 +54,8 @@ app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || req.path.startsWith('/v1/recover') || req.path.startsWith('/v1/billing') || - req.path.startsWith('/v1/demo'); + req.path.startsWith('/v1/demo') || + req.path.startsWith('/v1/email-change'); if (isAuthBillingRoute) { 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 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/*" })); // Trust nginx proxy app.set("trust proxy", 1); @@ -116,12 +119,58 @@ app.use("/v1/signup", (_req, res) => { pro_url: "https://docfast.dev/#pricing" }); }); -app.use("/v1/recover", recoverRouter); -app.use("/v1/billing", billingRouter); +// Default 2MB JSON parser for standard routes +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) const convertBodyLimit = express.json({ limit: "500kb" }); 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) const adminAuth = (req, res, next) => { const adminKey = process.env.ADMIN_API_KEY; @@ -362,6 +411,14 @@ async function start() { 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 try { await closeBrowser(); diff --git a/dist/middleware/usage.js b/dist/middleware/usage.js index 6dd2f5e..1074018 100644 --- a/dist/middleware/usage.js +++ b/dist/middleware/usage.js @@ -30,53 +30,43 @@ export async function loadUsageData() { } } // Batch flush dirty entries to DB (Audit #10 + #12) -async function flushDirtyEntries() { +export async function flushDirtyEntries() { if (dirtyKeys.size === 0) return; const keysToFlush = [...dirtyKeys]; - const client = await connectWithRetry(); - try { - await client.query("BEGIN"); - for (const key of keysToFlush) { - const record = usage.get(key); - if (!record) - continue; - try { - await client.query(`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3) - ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`, [key, record.count, record.monthKey]); + for (const key of keysToFlush) { + const record = usage.get(key); + if (!record) + continue; + const client = await connectWithRetry(); + try { + await client.query(`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3) + ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`, [key, record.count, record.monthKey]); + dirtyKeys.delete(key); + 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); 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); - retryCount.delete(key); - } - else { - retryCount.set(key, retries); - logger.warn({ key: key.slice(0, 8) + "...", retries }, "Usage write failed, will retry"); - } + else { + retryCount.set(key, retries); + logger.warn({ key: key.slice(0, 8) + "...", retries }, "Usage write failed, will retry"); } } - await client.query("COMMIT"); - } - 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(); + finally { + client.release(); + } } } // Periodic flush setInterval(flushDirtyEntries, FLUSH_INTERVAL_MS); -// Flush on process exit -process.on("SIGTERM", () => { flushDirtyEntries().catch(() => { }); }); -process.on("SIGINT", () => { flushDirtyEntries().catch(() => { }); }); +// Note: SIGTERM/SIGINT flush is handled by the shutdown orchestrator in index.ts +// to avoid race conditions with pool.end(). export function usageMiddleware(req, res, next) { const keyInfo = req.apiKeyInfo; const key = keyInfo?.key || "unknown"; @@ -113,6 +103,14 @@ function trackUsage(key, monthKey) { 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) { const stats = {}; if (apiKey) { diff --git a/dist/routes/billing.js b/dist/routes/billing.js index 096d291..9651bc4 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -15,8 +15,29 @@ function getStripe() { return _stripe; } const router = Router(); -// Track provisioned session IDs to prevent duplicate key creation -const provisionedSessions = new Set(); +// Track provisioned session IDs with TTL to prevent duplicate key creation and memory leaks +// Map - 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"; // Returns true if the given Stripe subscription contains a DocFast product. // 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" }); } }); -/** - * @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 +// Success page — provision Pro API key after checkout (browser redirect, not a public API) router.get("/success", async (req, res) => { const sessionId = req.query.session_id; if (!sessionId) { res.status(400).json({ error: "Missing session_id" }); return; } + // Clean up old sessions before checking duplicates + cleanupOldSessions(); // Prevent duplicate provisioning from same session 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." }); @@ -151,10 +144,10 @@ router.get("/success", async (req, res) => { res.status(400).json({ error: "No customer found" }); 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); if (existingKey) { - provisionedSessions.add(session.id); + provisionedSessions.set(session.id, Date.now()); res.send(` DocFast Pro — Key Already Provisioned