feat: wire up swagger-jsdoc dynamic spec, delete static openapi.json
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled

- Create src/swagger.ts config module for swagger-jsdoc
- Add GET /openapi.json dynamic route (generated from @openapi annotations)
- Delete static public/openapi.json (was drifting from code)
- Add @openapi annotation for deprecated /v1/signup/free in index.ts
- Import swaggerSpec into index.ts
- All 12 endpoints now code-driven: demo/html, demo/markdown, convert/html,
  convert/markdown, convert/url, templates, templates/{id}/render,
  recover, recover/verify, billing/checkout, signup/free, health
This commit is contained in:
OpenClaw 2026-02-20 07:56:56 +00:00
parent 792e2d9142
commit 825c6562ba
11 changed files with 624 additions and 1070 deletions

58
dist/index.js vendored
View file

@ -1,15 +1,18 @@
import express from "express";
import { randomUUID } from "crypto";
import { createRequire } from "module";
import { compressionMiddleware } from "./middleware/compression.js";
import logger from "./services/logger.js";
import helmet from "helmet";
const _require = createRequire(import.meta.url);
const APP_VERSION = _require("../package.json").version;
import path from "path";
import { fileURLToPath } from "url";
import rateLimit from "express-rate-limit";
import { convertRouter } from "./routes/convert.js";
import { templatesRouter } from "./routes/templates.js";
import { healthRouter } from "./routes/health.js";
import { signupRouter } from "./routes/signup.js";
import { demoRouter } from "./routes/demo.js";
import { recoverRouter } from "./routes/recover.js";
import { billingRouter } from "./routes/billing.js";
import { authMiddleware } from "./middleware/auth.js";
@ -20,6 +23,7 @@ import { initBrowser, closeBrowser } from "./services/browser.js";
import { loadKeys, getAllKeys } from "./services/keys.js";
import { verifyToken, loadVerifications } from "./services/verification.js";
import { initDatabase, pool } from "./services/db.js";
import { swaggerSpec } from "./swagger.js";
const app = express();
const PORT = parseInt(process.env.PORT || "3100", 10);
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
@ -48,7 +52,8 @@ app.use(compressionMiddleware);
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/billing') ||
req.path.startsWith('/v1/demo');
if (isAuthBillingRoute) {
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev");
}
@ -80,7 +85,36 @@ const limiter = rateLimit({
app.use(limiter);
// Public routes
app.use("/health", healthRouter);
app.use("/v1/signup", signupRouter);
app.use("/v1/demo", express.json({ limit: "50kb" }), pdfRateLimitMiddleware, demoRouter);
/**
* @openapi
* /v1/signup/free:
* post:
* tags: [Account]
* summary: Request a free API key (discontinued)
* description: Free accounts have been discontinued. Use the demo endpoints or upgrade to Pro.
* responses:
* 410:
* description: Feature discontinued
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* demo_endpoint:
* type: string
* pro_url:
* type: string
*/
app.use("/v1/signup", (_req, res) => {
res.status(410).json({
error: "Free accounts have been discontinued. Try our demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev",
demo_endpoint: "/v1/demo/html",
pro_url: "https://docfast.dev/#pricing"
});
});
app.use("/v1/recover", recoverRouter);
app.use("/v1/billing", billingRouter);
// Authenticated routes — conversion routes get tighter body limits (500KB)
@ -156,7 +190,7 @@ p{color:#7a8194;margin-bottom:24px;line-height:1.6}
${apiKey ? `
<div class="warning"> Save your API key securely. You can recover it via email if needed.</div>
<div class="key-box" onclick="navigator.clipboard.writeText('${apiKey}');this.style.borderColor='#5eead4';setTimeout(()=>this.style.borderColor='#34d399',1500)">${apiKey}</div>
<div class="links">100 free PDFs/month · <a href="/docs">Read the docs </a></div>
<div class="links">Upgrade to Pro for 5,000 PDFs/month · <a href="/docs">Read the docs </a></div>
` : `<div class="links"><a href="/"> Back to DocFast</a></div>`}
</div></body></html>`;
}
@ -168,6 +202,10 @@ app.get("/favicon.ico", (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=604800');
res.sendFile(path.join(__dirname, "../public/favicon.svg"));
});
// Dynamic OpenAPI spec — generated from @openapi JSDoc annotations at startup
app.get("/openapi.json", (_req, res) => {
res.json(swaggerSpec);
});
// Docs page (clean URL)
app.get("/docs", (_req, res) => {
// Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation.
@ -179,7 +217,6 @@ app.get("/docs", (_req, res) => {
// Static asset cache headers middleware
app.use((req, res, next) => {
if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) {
console.log("CACHE HIT:", req.path);
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
}
next();
@ -209,12 +246,13 @@ app.get("/status", (_req, res) => {
app.get("/api", (_req, res) => {
res.json({
name: "DocFast API",
version: "0.2.9",
version: APP_VERSION,
endpoints: [
"POST /v1/signup/free — Get a free API key",
"POST /v1/convert/html",
"POST /v1/convert/markdown",
"POST /v1/convert/url",
"POST /v1/demo/html — Try HTML→PDF (no auth, watermarked, 5/hour)",
"POST /v1/demo/markdown — Try Markdown→PDF (no auth, watermarked, 5/hour)",
"POST /v1/convert/html — HTML→PDF (requires API key)",
"POST /v1/convert/markdown — Markdown→PDF (requires API key)",
"POST /v1/convert/url — URL→PDF (requires API key)",
"POST /v1/templates/:id/render",
"GET /v1/templates",
"POST /v1/billing/checkout — Start Pro subscription",

View file

@ -1,4 +1,5 @@
import { Router } from "express";
import rateLimit from "express-rate-limit";
import Stripe from "stripe";
import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js";
import logger from "../services/logger.js";
@ -39,8 +40,51 @@ async function isDocFastSubscription(subscriptionId) {
return false;
}
}
// Create a Stripe Checkout session for Pro subscription
router.post("/checkout", async (_req, res) => {
// Rate limit checkout: max 3 requests per IP per hour
const checkoutLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown",
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many checkout requests. Please try again later." },
});
/**
* @openapi
* /v1/billing/checkout:
* post:
* tags: [Billing]
* summary: Create a Stripe checkout session
* description: |
* Creates a Stripe Checkout session for a Pro subscription (9/month).
* Returns a URL to redirect the user to Stripe's hosted payment page.
* Rate limited to 3 requests per hour per IP.
* responses:
* 200:
* description: Checkout session created
* content:
* application/json:
* schema:
* type: object
* properties:
* url:
* type: string
* format: uri
* description: Stripe Checkout URL to redirect the user to
* 413:
* description: Request body too large
* 429:
* description: Too many checkout requests
* 500:
* description: Failed to create checkout session
*/
router.post("/checkout", checkoutLimiter, async (req, res) => {
// Reject suspiciously large request bodies (>1KB)
const contentLength = parseInt(req.headers["content-length"] || "0", 10);
if (contentLength > 1024) {
res.status(413).json({ error: "Request body too large" });
return;
}
try {
const priceId = await getOrCreateProPrice();
const session = await getStripe().checkout.sessions.create({
@ -50,6 +94,8 @@ router.post("/checkout", async (_req, res) => {
success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`,
});
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
logger.info({ clientIp, sessionId: session.id }, "Checkout session created");
res.json({ url: session.url });
}
catch (err) {

153
dist/routes/convert.js vendored
View file

@ -41,7 +41,55 @@ function sanitizeFilename(name) {
return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf";
}
export const convertRouter = Router();
// POST /v1/convert/html
/**
* @openapi
* /v1/convert/html:
* post:
* tags: [Conversion]
* summary: Convert HTML to PDF
* description: Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document.
* security:
* - BearerAuth: []
* - ApiKeyHeader: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* allOf:
* - type: object
* required: [html]
* properties:
* html:
* type: string
* description: HTML content to convert. Can be a full document or a fragment.
* example: '<h1>Hello World</h1><p>My first PDF</p>'
* css:
* type: string
* description: Optional CSS to inject (only used when html is a fragment, not a full document)
* example: 'body { font-family: sans-serif; padding: 40px; }'
* - $ref: '#/components/schemas/PdfOptions'
* responses:
* 200:
* description: PDF document
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* 400:
* description: Missing html field
* 401:
* description: Missing API key
* 403:
* description: Invalid API key
* 415:
* description: Unsupported Content-Type (must be application/json)
* 429:
* description: Rate limit or usage limit exceeded
* 500:
* description: PDF generation failed
*/
convertRouter.post("/html", async (req, res) => {
let slotAcquired = false;
try {
@ -90,7 +138,54 @@ convertRouter.post("/html", async (req, res) => {
}
}
});
// POST /v1/convert/markdown
/**
* @openapi
* /v1/convert/markdown:
* post:
* tags: [Conversion]
* summary: Convert Markdown to PDF
* description: Converts Markdown content to HTML and then to a PDF document.
* security:
* - BearerAuth: []
* - ApiKeyHeader: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* allOf:
* - type: object
* required: [markdown]
* properties:
* markdown:
* type: string
* description: Markdown content to convert
* example: '# Hello World\n\nThis is **bold** and *italic*.'
* css:
* type: string
* description: Optional CSS to inject into the rendered HTML
* - $ref: '#/components/schemas/PdfOptions'
* responses:
* 200:
* description: PDF document
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* 400:
* description: Missing markdown field
* 401:
* description: Missing API key
* 403:
* description: Invalid API key
* 415:
* description: Unsupported Content-Type
* 429:
* description: Rate limit or usage limit exceeded
* 500:
* description: PDF generation failed
*/
convertRouter.post("/markdown", async (req, res) => {
let slotAcquired = false;
try {
@ -136,7 +231,59 @@ convertRouter.post("/markdown", async (req, res) => {
}
}
});
// POST /v1/convert/url
/**
* @openapi
* /v1/convert/url:
* post:
* tags: [Conversion]
* summary: Convert URL to PDF
* description: |
* Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security.
* Private/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding.
* security:
* - BearerAuth: []
* - ApiKeyHeader: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* allOf:
* - type: object
* required: [url]
* properties:
* url:
* type: string
* format: uri
* description: URL to convert (http or https only)
* example: 'https://example.com'
* waitUntil:
* type: string
* enum: [load, domcontentloaded, networkidle0, networkidle2]
* default: domcontentloaded
* description: When to consider navigation finished
* - $ref: '#/components/schemas/PdfOptions'
* responses:
* 200:
* description: PDF document
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* 400:
* description: Missing/invalid URL or URL resolves to private IP
* 401:
* description: Missing API key
* 403:
* description: Invalid API key
* 415:
* description: Unsupported Content-Type
* 429:
* description: Rate limit or usage limit exceeded
* 500:
* description: PDF generation failed
*/
convertRouter.post("/url", async (req, res) => {
let slotAcquired = false;
try {

50
dist/routes/health.js vendored
View file

@ -6,6 +6,56 @@ const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require("../../package.json");
export const healthRouter = Router();
const HEALTH_CHECK_TIMEOUT_MS = 3000;
/**
* @openapi
* /health:
* get:
* tags: [System]
* summary: Health check
* description: Returns service health status including database connectivity and browser pool stats.
* responses:
* 200:
* description: Service is healthy
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* enum: [ok, degraded]
* version:
* type: string
* example: '0.4.0'
* database:
* type: object
* properties:
* status:
* type: string
* enum: [ok, error]
* version:
* type: string
* example: 'PostgreSQL 17.4'
* pool:
* type: object
* properties:
* size:
* type: integer
* active:
* type: integer
* available:
* type: integer
* queueDepth:
* type: integer
* pdfCount:
* type: integer
* restarting:
* type: boolean
* uptimeSeconds:
* type: integer
* 503:
* description: Service is degraded (database issue)
*/
healthRouter.get("/", async (_req, res) => {
const poolStats = getPoolStats();
let databaseStatus;

View file

@ -12,6 +12,46 @@ const recoverLimiter = rateLimit({
standardHeaders: true,
legacyHeaders: false,
});
/**
* @openapi
* /v1/recover:
* post:
* tags: [Account]
* summary: Request API key recovery
* description: |
* Sends a 6-digit verification code to the email address if an account exists.
* Response is always the same regardless of whether the email exists (to prevent enumeration).
* Rate limited to 3 requests per hour.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email]
* properties:
* email:
* type: string
* format: email
* description: Email address associated with the API key
* responses:
* 200:
* description: Recovery code sent (or no-op if email not found)
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: recovery_sent
* message:
* type: string
* 400:
* description: Invalid email format
* 429:
* description: Too many recovery attempts
*/
router.post("/", recoverLimiter, async (req, res) => {
const { email } = req.body || {};
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
@ -31,6 +71,52 @@ router.post("/", recoverLimiter, async (req, res) => {
});
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
});
/**
* @openapi
* /v1/recover/verify:
* post:
* tags: [Account]
* summary: Verify recovery code and retrieve API key
* description: Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, code]
* properties:
* email:
* type: string
* format: email
* code:
* type: string
* pattern: '^\d{6}$'
* description: 6-digit verification code
* responses:
* 200:
* description: API key recovered
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: recovered
* apiKey:
* type: string
* description: The recovered API key
* tier:
* type: string
* enum: [free, pro]
* 400:
* description: Invalid verification code or missing fields
* 410:
* description: Verification code expired
* 429:
* description: Too many failed attempts
*/
router.post("/verify", recoverLimiter, async (req, res) => {
const { email, code } = req.body || {};
if (!email || !code) {

View file

@ -6,7 +6,53 @@ function sanitizeFilename(name) {
return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200);
}
export const templatesRouter = Router();
// GET /v1/templates — list available templates
/**
* @openapi
* /v1/templates:
* get:
* tags: [Templates]
* summary: List available templates
* description: Returns a list of all built-in document templates with their required fields.
* security:
* - BearerAuth: []
* - ApiKeyHeader: []
* responses:
* 200:
* description: List of templates
* content:
* application/json:
* schema:
* type: object
* properties:
* templates:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* example: invoice
* name:
* type: string
* example: Invoice
* description:
* type: string
* fields:
* type: array
* items:
* type: object
* properties:
* name:
* type: string
* required:
* type: boolean
* description:
* type: string
* 401:
* description: Missing API key
* 403:
* description: Invalid API key
*/
templatesRouter.get("/", (_req, res) => {
const list = Object.entries(templates).map(([id, t]) => ({
id,
@ -16,7 +62,71 @@ templatesRouter.get("/", (_req, res) => {
}));
res.json({ templates: list });
});
// POST /v1/templates/:id/render — render template to PDF
/**
* @openapi
* /v1/templates/{id}/render:
* post:
* tags: [Templates]
* summary: Render a template to PDF
* description: |
* Renders a built-in template with the provided data and returns a PDF.
* Use GET /v1/templates to see available templates and their required fields.
* Special fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename).
* security:
* - BearerAuth: []
* - ApiKeyHeader: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Template ID (e.g. "invoice", "receipt")
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: object
* description: Template data (fields depend on template). Can also be passed at root level.
* _format:
* type: string
* enum: [A4, Letter, Legal, A3, A5, Tabloid]
* default: A4
* description: Page size override
* _margin:
* type: object
* properties:
* top: { type: string }
* right: { type: string }
* bottom: { type: string }
* left: { type: string }
* description: Page margin override
* _filename:
* type: string
* description: Custom output filename
* responses:
* 200:
* description: PDF document
* content:
* application/pdf:
* schema:
* type: string
* format: binary
* 400:
* description: Missing required template fields
* 401:
* description: Missing API key
* 403:
* description: Invalid API key
* 404:
* description: Template not found
* 500:
* description: Template rendering failed
*/
templatesRouter.post("/:id/render", async (req, res) => {
try {
const id = req.params.id;