fix: OpenAPI spec accuracy — hide internal endpoints, mark signup/verify deprecated
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m9s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 13m9s
- Remove @openapi annotations from /v1/billing/webhook (Stripe-internal) - Remove @openapi annotations from /v1/billing/success (browser redirect) - Mark /v1/signup/verify as deprecated (returns 410) - Add 3 TDD tests in openapi-spec.test.ts - Update 2 existing tests in app-routes.test.ts - 530 tests passing (was 527)
This commit is contained in:
parent
1d5d9adf08
commit
6b1b3d584e
15 changed files with 399 additions and 290 deletions
15
dist/__tests__/api.test.js
vendored
15
dist/__tests__/api.test.js
vendored
|
|
@ -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 () => {
|
||||
|
|
|
|||
73
dist/index.js
vendored
73
dist/index.js
vendored
|
|
@ -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();
|
||||
|
|
|
|||
68
dist/middleware/usage.js
vendored
68
dist/middleware/usage.js
vendored
|
|
@ -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) {
|
||||
|
|
|
|||
110
dist/routes/billing.js
vendored
110
dist/routes/billing.js
vendored
|
|
@ -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<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";
|
||||
// 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(`<!DOCTYPE html>
|
||||
<html><head><title>DocFast Pro — Key Already Provisioned</title>
|
||||
<style>
|
||||
|
|
@ -173,7 +166,7 @@ a { color: #4f9; }
|
|||
return;
|
||||
}
|
||||
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
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html><head><title>Welcome to DocFast Pro!</title>
|
||||
|
|
@ -202,48 +195,7 @@ a { color: #4f9; }
|
|||
res.status(500).json({ error: "Failed to retrieve session" });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* @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
|
||||
// Stripe webhook for subscription lifecycle events (internal, not in public API docs)
|
||||
router.post("/webhook", async (req, res) => {
|
||||
const sig = req.headers["stripe-signature"];
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
|
@ -297,7 +249,7 @@ router.post("/webhook", async (req, res) => {
|
|||
break;
|
||||
}
|
||||
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");
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
68
dist/routes/convert.js
vendored
68
dist/routes/convert.js
vendored
|
|
@ -5,6 +5,7 @@ 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";
|
||||
export const convertRouter = Router();
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -69,6 +70,12 @@ convertRouter.post("/html", async (req, res) => {
|
|||
res.status(400).json({ error: "Missing 'html' 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();
|
||||
|
|
@ -78,23 +85,13 @@ convertRouter.post("/html", async (req, res) => {
|
|||
const fullHtml = body.html.includes("<html")
|
||||
? body.html
|
||||
: wrapHtml(body.html, body.css);
|
||||
const pdf = await renderPdf(fullHtml, {
|
||||
format: body.format,
|
||||
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 { pdf, durationMs } = await renderPdf(fullHtml, {
|
||||
...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) {
|
||||
|
|
@ -173,29 +170,25 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
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;
|
||||
}
|
||||
const html = markdownToHtml(body.markdown, body.css);
|
||||
const pdf = await renderPdf(html, {
|
||||
format: body.format,
|
||||
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 { 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) {
|
||||
|
|
@ -306,30 +299,25 @@ convertRouter.post("/url", async (req, res) => {
|
|||
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;
|
||||
}
|
||||
const pdf = await renderUrlPdf(body.url, {
|
||||
format: body.format,
|
||||
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,
|
||||
const { pdf, durationMs } = await renderUrlPdf(body.url, {
|
||||
...validation.sanitized,
|
||||
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) {
|
||||
|
|
|
|||
156
dist/routes/email-change.js
vendored
156
dist/routes/email-change.js
vendored
|
|
@ -2,70 +2,162 @@ import { Router } from "express";
|
|||
import rateLimit from "express-rate-limit";
|
||||
import { createPendingVerification, verifyCode } from "../services/verification.js";
|
||||
import { sendVerificationEmail } from "../services/email.js";
|
||||
import { getAllKeys, updateKeyEmail } from "../services/keys.js";
|
||||
import { queryWithRetry } from "../services/db.js";
|
||||
import logger from "../services/logger.js";
|
||||
const router = Router();
|
||||
const changeLimiter = rateLimit({
|
||||
const emailChangeLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
message: { error: "Too many attempts. Please try again in 1 hour." },
|
||||
message: { error: "Too many email change attempts. Please try again in 1 hour." },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.body?.apiKey || req.ip || "unknown",
|
||||
});
|
||||
router.post("/", changeLimiter, async (req, res) => {
|
||||
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
|
||||
const newEmail = req.body?.newEmail;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
async function validateApiKey(apiKey) {
|
||||
const result = await queryWithRetry(`SELECT key, email, tier FROM api_keys WHERE key = $1`, [apiKey]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/email-change:
|
||||
* post:
|
||||
* tags: [Account]
|
||||
* summary: Request email change
|
||||
* description: |
|
||||
* Sends a 6-digit verification code to the new email address.
|
||||
* Rate limited to 3 requests per hour per API key.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [apiKey, newEmail]
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* newEmail:
|
||||
* type: string
|
||||
* format: email
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Verification code sent
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: verification_sent
|
||||
* message:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Missing or invalid fields
|
||||
* 403:
|
||||
* description: Invalid API key
|
||||
* 409:
|
||||
* description: Email already taken
|
||||
* 429:
|
||||
* description: Too many attempts
|
||||
*/
|
||||
router.post("/", emailChangeLimiter, async (req, res) => {
|
||||
const { apiKey, newEmail } = req.body || {};
|
||||
if (!apiKey || typeof apiKey !== "string") {
|
||||
res.status(400).json({ error: "API key is required (Authorization header or body)." });
|
||||
res.status(400).json({ error: "apiKey is required." });
|
||||
return;
|
||||
}
|
||||
if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||
res.status(400).json({ error: "A valid new email address is required." });
|
||||
if (!newEmail || typeof newEmail !== "string") {
|
||||
res.status(400).json({ error: "newEmail is required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
const keys = getAllKeys();
|
||||
const userKey = keys.find((k) => k.key === apiKey);
|
||||
if (!userKey) {
|
||||
res.status(401).json({ error: "Invalid API key." });
|
||||
if (!EMAIL_RE.test(cleanEmail)) {
|
||||
res.status(400).json({ error: "Invalid email format." });
|
||||
return;
|
||||
}
|
||||
const existing = keys.find((k) => k.email === cleanEmail);
|
||||
if (existing) {
|
||||
const keyRow = await validateApiKey(apiKey);
|
||||
if (!keyRow) {
|
||||
res.status(403).json({ error: "Invalid API key." });
|
||||
return;
|
||||
}
|
||||
// Check if email is already taken by another key
|
||||
const existing = await queryWithRetry(`SELECT key FROM api_keys WHERE email = $1 AND key != $2`, [cleanEmail, apiKey]);
|
||||
if (existing.rows.length > 0) {
|
||||
res.status(409).json({ error: "This email is already associated with another account." });
|
||||
return;
|
||||
}
|
||||
const pending = await createPendingVerification(cleanEmail);
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch((err) => {
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
|
||||
});
|
||||
res.json({ status: "verification_sent", message: "Verification code sent to your new email address." });
|
||||
res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." });
|
||||
});
|
||||
router.post("/verify", changeLimiter, async (req, res) => {
|
||||
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
|
||||
const { newEmail, code } = req.body || {};
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/email-change/verify:
|
||||
* post:
|
||||
* tags: [Account]
|
||||
* summary: Verify email change code
|
||||
* description: Verifies the 6-digit code and updates the account email.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [apiKey, newEmail, code]
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* newEmail:
|
||||
* type: string
|
||||
* format: email
|
||||
* code:
|
||||
* type: string
|
||||
* pattern: '^\d{6}$'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Email updated
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
* newEmail:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Missing fields or invalid code
|
||||
* 403:
|
||||
* description: Invalid API key
|
||||
* 410:
|
||||
* description: Code expired
|
||||
* 429:
|
||||
* description: Too many failed attempts
|
||||
*/
|
||||
router.post("/verify", async (req, res) => {
|
||||
const { apiKey, newEmail, code } = req.body || {};
|
||||
if (!apiKey || !newEmail || !code) {
|
||||
res.status(400).json({ error: "API key, new email, and code are required." });
|
||||
res.status(400).json({ error: "apiKey, newEmail, and code are required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
const cleanCode = String(code).trim();
|
||||
const keys = getAllKeys();
|
||||
const userKey = keys.find((k) => k.key === apiKey);
|
||||
if (!userKey) {
|
||||
res.status(401).json({ error: "Invalid API key." });
|
||||
const keyRow = await validateApiKey(apiKey);
|
||||
if (!keyRow) {
|
||||
res.status(403).json({ error: "Invalid API key." });
|
||||
return;
|
||||
}
|
||||
const result = await verifyCode(cleanEmail, cleanCode);
|
||||
switch (result.status) {
|
||||
case "ok": {
|
||||
const updated = await updateKeyEmail(apiKey, cleanEmail);
|
||||
if (updated) {
|
||||
res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail });
|
||||
}
|
||||
else {
|
||||
res.status(500).json({ error: "Failed to update email." });
|
||||
}
|
||||
await queryWithRetry(`UPDATE api_keys SET email = $1 WHERE key = $2`, [cleanEmail, apiKey]);
|
||||
logger.info({ apiKey: apiKey.slice(0, 10) + "...", newEmail: cleanEmail }, "Email changed");
|
||||
res.json({ status: "ok", newEmail: cleanEmail });
|
||||
break;
|
||||
}
|
||||
case "expired":
|
||||
|
|
|
|||
18
dist/routes/recover.js
vendored
18
dist/routes/recover.js
vendored
|
|
@ -3,6 +3,7 @@ import rateLimit from "express-rate-limit";
|
|||
import { createPendingVerification, verifyCode } from "../services/verification.js";
|
||||
import { sendVerificationEmail } from "../services/email.js";
|
||||
import { getAllKeys } from "../services/keys.js";
|
||||
import { queryWithRetry } from "../services/db.js";
|
||||
import logger from "../services/logger.js";
|
||||
const router = Router();
|
||||
const recoverLimiter = rateLimit({
|
||||
|
|
@ -129,7 +130,22 @@ router.post("/verify", recoverLimiter, async (req, res) => {
|
|||
switch (result.status) {
|
||||
case "ok": {
|
||||
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) {
|
||||
res.json({
|
||||
status: "recovered",
|
||||
|
|
|
|||
5
dist/routes/signup.js
vendored
5
dist/routes/signup.js
vendored
|
|
@ -56,9 +56,10 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => {
|
|||
* /v1/signup/verify:
|
||||
* post:
|
||||
* tags: [Account]
|
||||
* summary: Verify email and get API key
|
||||
* summary: Verify email and get API key (discontinued)
|
||||
* deprecated: true
|
||||
* 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.
|
||||
* requestBody:
|
||||
* required: true
|
||||
|
|
|
|||
18
dist/routes/templates.js
vendored
18
dist/routes/templates.js
vendored
|
|
@ -3,6 +3,7 @@ import { renderPdf } from "../services/browser.js";
|
|||
import logger from "../services/logger.js";
|
||||
import { templates, renderTemplate } from "../services/templates.js";
|
||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||
import { validatePdfOptions } from "../utils/pdf-options.js";
|
||||
export const templatesRouter = Router();
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -146,11 +147,20 @@ templatesRouter.post("/:id/render", async (req, res) => {
|
|||
});
|
||||
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 pdf = await renderPdf(html, {
|
||||
format: data._format || "A4",
|
||||
margin: data._margin,
|
||||
});
|
||||
const { pdf, durationMs } = await renderPdf(html, sanitizedPdf);
|
||||
const filename = sanitizeFilename(data._filename || `${id}.pdf`);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
|
|
|
|||
29
dist/services/browser.js
vendored
29
dist/services/browser.js
vendored
|
|
@ -27,11 +27,14 @@ export function getPoolStats() {
|
|||
})),
|
||||
};
|
||||
}
|
||||
async function recyclePage(page) {
|
||||
export async function recyclePage(page) {
|
||||
try {
|
||||
const client = await page.createCDPSession();
|
||||
await client.send("Network.clearBrowserCache").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();
|
||||
if (cookies.length > 0) {
|
||||
await page.deleteCookie(...cookies);
|
||||
|
|
@ -197,6 +200,8 @@ export async function renderPdf(html, options = {}) {
|
|||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
await page.setJavaScriptEnabled(false);
|
||||
const startTime = Date.now();
|
||||
let timeoutId;
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
||||
|
|
@ -217,9 +222,13 @@ export async function renderPdf(html, options = {}) {
|
|||
});
|
||||
return Buffer.from(pdf);
|
||||
})(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)),
|
||||
]);
|
||||
return result;
|
||||
new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timeoutId));
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info(`PDF rendered in ${durationMs}ms (html, ${result.length} bytes)`);
|
||||
return { pdf: result, durationMs };
|
||||
}
|
||||
finally {
|
||||
releasePage(page, instance);
|
||||
|
|
@ -264,6 +273,8 @@ export async function renderUrlPdf(url, options = {}) {
|
|||
});
|
||||
}
|
||||
}
|
||||
const startTime = Date.now();
|
||||
let timeoutId;
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(url, {
|
||||
|
|
@ -286,9 +297,13 @@ export async function renderUrlPdf(url, options = {}) {
|
|||
});
|
||||
return Buffer.from(pdf);
|
||||
})(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)),
|
||||
]);
|
||||
return result;
|
||||
new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timeoutId));
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info(`PDF rendered in ${durationMs}ms (url, ${result.length} bytes)`);
|
||||
return { pdf: result, durationMs };
|
||||
}
|
||||
finally {
|
||||
releasePage(page, instance);
|
||||
|
|
|
|||
21
dist/services/keys.js
vendored
21
dist/services/keys.js
vendored
|
|
@ -100,7 +100,26 @@ export async function downgradeByCustomer(stripeCustomerId) {
|
|||
await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
|
||||
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) {
|
||||
// Check DB directly — survives pod restarts unlike in-memory cache
|
||||
|
|
|
|||
|
|
@ -106,14 +106,12 @@ describe("App-level routes", () => {
|
|||
expect(spec.paths["/v1/signup/verify"].post).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes GET /v1/billing/success", () => {
|
||||
expect(spec.paths["/v1/billing/success"]).toBeDefined();
|
||||
expect(spec.paths["/v1/billing/success"].get).toBeDefined();
|
||||
it("excludes GET /v1/billing/success (browser redirect, not public API)", () => {
|
||||
expect(spec.paths["/v1/billing/success"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes POST /v1/billing/webhook", () => {
|
||||
expect(spec.paths["/v1/billing/webhook"]).toBeDefined();
|
||||
expect(spec.paths["/v1/billing/webhook"].post).toBeDefined();
|
||||
it("excludes POST /v1/billing/webhook (internal Stripe endpoint)", () => {
|
||||
expect(spec.paths["/v1/billing/webhook"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
18
src/__tests__/openapi-spec.test.ts
Normal file
18
src/__tests__/openapi-spec.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -139,37 +139,7 @@ router.post("/checkout", checkoutLimiter, async (req: Request, res: Response) =>
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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: Request, res: Response) => {
|
||||
const sessionId = req.query.session_id as string;
|
||||
if (!sessionId) {
|
||||
|
|
@ -249,48 +219,7 @@ a { color: #4f9; }
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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
|
||||
// Stripe webhook for subscription lifecycle events (internal, not in public API docs)
|
||||
router.post("/webhook", async (req: Request, res: Response) => {
|
||||
const sig = req.headers["stripe-signature"] as string;
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
|
|
|||
|
|
@ -68,9 +68,10 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, r
|
|||
* /v1/signup/verify:
|
||||
* post:
|
||||
* tags: [Account]
|
||||
* summary: Verify email and get API key
|
||||
* summary: Verify email and get API key (discontinued)
|
||||
* deprecated: true
|
||||
* 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.
|
||||
* requestBody:
|
||||
* required: true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue