fix(cors): dynamic origin for staging support (BUG-111) + eliminate all 'as any' casts
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 17m51s

- CORS middleware now allows both docfast.dev and staging.docfast.dev origins
  for auth/billing routes, with Vary: Origin header for proper caching
- Unknown origins fall back to production origin (not reflected)
- 13 TDD tests added for CORS behavior

Type safety improvements:
- Augment Express.Request with requestId, acquirePdfSlot, releasePdfSlot
- Use Puppeteer's PaperFormat and PuppeteerLifeCycleEvent types in browser.ts
- Use 'as const' for format literals in convert/demo/templates routes
- Replace Stripe apiVersion 'as any' with @ts-expect-error
- Zero 'as any' casts remaining in production code

579 tests passing (13 new), 51 test files
This commit is contained in:
Hoid 2026-03-09 08:08:37 +01:00
parent a60d379e66
commit da049b77e3
9 changed files with 89 additions and 18 deletions

View file

@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import supertest from "supertest";
import { app } from "../index.js";
describe("CORS — staging origin support (BUG-111)", () => {
const authRoutes = ["/v1/recover", "/v1/email-change", "/v1/billing", "/v1/demo"];
for (const route of authRoutes) {
it(`${route} allows staging origin`, async () => {
const res = await supertest(app)
.options(route)
.set("Origin", "https://staging.docfast.dev")
.set("Access-Control-Request-Method", "POST")
.set("Access-Control-Request-Headers", "Content-Type");
expect(res.headers["access-control-allow-origin"]).toBe("https://staging.docfast.dev");
});
it(`${route} allows production origin`, async () => {
const res = await supertest(app)
.options(route)
.set("Origin", "https://docfast.dev")
.set("Access-Control-Request-Method", "POST")
.set("Access-Control-Request-Headers", "Content-Type");
expect(res.headers["access-control-allow-origin"]).toBe("https://docfast.dev");
});
it(`${route} rejects unknown origin`, async () => {
const res = await supertest(app)
.options(route)
.set("Origin", "https://evil.com")
.set("Access-Control-Request-Method", "POST")
.set("Access-Control-Request-Headers", "Content-Type");
// Should NOT reflect the evil origin
expect(res.headers["access-control-allow-origin"]).not.toBe("https://evil.com");
});
}
it("non-auth routes still allow wildcard origin", async () => {
const res = await supertest(app)
.options("/v1/convert/html")
.set("Origin", "https://random-app.com")
.set("Access-Control-Request-Method", "POST")
.set("Access-Control-Request-Headers", "Content-Type");
expect(res.headers["access-control-allow-origin"]).toBe("*");
});
});

View file

@ -1,6 +1,7 @@
import express, { Request, Response, NextFunction } from "express"; import express, { Request, Response, NextFunction } from "express";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { AuthenticatedRequest } from "./types.js"; import { AuthenticatedRequest } from "./types.js";
import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot
import { createRequire } from "module"; import { createRequire } from "module";
import { compressionMiddleware } from "./middleware/compression.js"; import { compressionMiddleware } from "./middleware/compression.js";
import logger from "./services/logger.js"; import logger from "./services/logger.js";
@ -36,7 +37,7 @@ app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
// Request ID + request logging middleware // Request ID + request logging middleware
app.use((req, res, next) => { app.use((req, res, next) => {
const requestId = (req.headers["x-request-id"] as string) || randomUUID(); const requestId = (req.headers["x-request-id"] as string) || randomUUID();
(req as any).requestId = requestId; req.requestId = requestId;
res.setHeader("X-Request-Id", requestId); res.setHeader("X-Request-Id", requestId);
const start = Date.now(); const start = Date.now();
res.on("finish", () => { res.on("finish", () => {
@ -66,6 +67,7 @@ app.use((req, res, next) => {
}); });
// Differentiated CORS middleware // Differentiated CORS middleware
const ALLOWED_ORIGINS = new Set(["https://docfast.dev", "https://staging.docfast.dev"]);
app.use((req, res, next) => { 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') ||
@ -74,7 +76,13 @@ app.use((req, res, next) => {
req.path.startsWith('/v1/email-change'); req.path.startsWith('/v1/email-change');
if (isAuthBillingRoute) { if (isAuthBillingRoute) {
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
} else {
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev");
}
} else { } else {
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Origin", "*");
} }

View file

@ -139,8 +139,8 @@ export function pdfRateLimitMiddleware(req: Request, res: Response, next: NextFu
} }
// Add concurrency control to the request (pass apiKey for fairness) // Add concurrency control to the request (pass apiKey for fairness)
(req as any).acquirePdfSlot = () => acquireConcurrencySlot(apiKey); req.acquirePdfSlot = () => acquireConcurrencySlot(apiKey);
(req as any).releasePdfSlot = releaseConcurrencySlot; req.releasePdfSlot = releaseConcurrencySlot;
next(); next();
} }

View file

@ -11,7 +11,8 @@ function getStripe(): Stripe {
if (!_stripe) { if (!_stripe) {
const key = process.env.STRIPE_SECRET_KEY; const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY not configured"); if (!key) throw new Error("STRIPE_SECRET_KEY not configured");
_stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" as any }); // @ts-expect-error Stripe SDK types lag behind API versions
_stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" });
} }
return _stripe; return _stripe;
} }

View file

@ -78,7 +78,7 @@ interface ConvertBody {
* 500: * 500:
* description: PDF generation failed * description: PDF generation failed
*/ */
convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => { convertRouter.post("/html", async (req: Request, res: Response) => {
let slotAcquired = false; let slotAcquired = false;
try { try {
// Reject non-JSON content types // Reject non-JSON content types
@ -188,7 +188,7 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
* 500: * 500:
* description: PDF generation failed * description: PDF generation failed
*/ */
convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => { convertRouter.post("/markdown", async (req: Request, res: Response) => {
let slotAcquired = false; let slotAcquired = false;
try { try {
// Reject non-JSON content types // Reject non-JSON content types
@ -299,7 +299,7 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
* 500: * 500:
* description: PDF generation failed * description: PDF generation failed
*/ */
convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => { convertRouter.post("/url", async (req: Request, res: Response) => {
let slotAcquired = false; let slotAcquired = false;
try { try {
// Reject non-JSON content types // Reject non-JSON content types

View file

@ -97,7 +97,7 @@ interface DemoBody {
* 504: * 504:
* description: PDF generation timed out * description: PDF generation timed out
*/ */
router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => { router.post("/html", async (req: Request, res: Response) => {
let slotAcquired = false; let slotAcquired = false;
try { try {
const ct = req.headers["content-type"] || ""; const ct = req.headers["content-type"] || "";
@ -128,7 +128,7 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise<void
: injectWatermark(wrapHtml(body.html, body.css)); : injectWatermark(wrapHtml(body.html, body.css));
const defaultOpts = { const defaultOpts = {
format: "A4", format: "A4" as const,
landscape: false, landscape: false,
printBackground: true, printBackground: true,
margin: { top: "0", right: "0", bottom: "0", left: "0" }, margin: { top: "0", right: "0", bottom: "0", left: "0" },
@ -203,7 +203,7 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise<void
* 504: * 504:
* description: PDF generation timed out * description: PDF generation timed out
*/ */
router.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => { router.post("/markdown", async (req: Request, res: Response) => {
let slotAcquired = false; let slotAcquired = false;
try { try {
const ct = req.headers["content-type"] || ""; const ct = req.headers["content-type"] || "";
@ -233,7 +233,7 @@ router.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise<
const fullHtml = injectWatermark(wrapHtml(htmlContent, body.css)); const fullHtml = injectWatermark(wrapHtml(htmlContent, body.css));
const defaultOpts = { const defaultOpts = {
format: "A4", format: "A4" as const,
landscape: false, landscape: false,
printBackground: true, printBackground: true,
margin: { top: "0", right: "0", bottom: "0", left: "0" }, margin: { top: "0", right: "0", bottom: "0", left: "0" },

View file

@ -163,7 +163,7 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => {
res.status(400).json({ error: validation.error }); res.status(400).json({ error: validation.error });
return; return;
} }
const sanitizedPdf = { format: "A4" as string, ...validation.sanitized }; const sanitizedPdf = { format: "A4" as const, ...validation.sanitized };
const html = renderTemplate(id, data); const html = renderTemplate(id, data);
const { pdf, durationMs } = await renderPdf(html, sanitizedPdf); const { pdf, durationMs } = await renderPdf(html, sanitizedPdf);

View file

@ -221,8 +221,10 @@ export async function closeBrowser(): Promise<void> {
instances.length = 0; instances.length = 0;
} }
import type { PaperFormat, PuppeteerLifeCycleEvent } from "puppeteer";
export interface PdfRenderOptions { export interface PdfRenderOptions {
format?: string; format?: PaperFormat;
landscape?: boolean; landscape?: boolean;
margin?: { top?: string; right?: string; bottom?: string; left?: string }; margin?: { top?: string; right?: string; bottom?: string; left?: string };
printBackground?: boolean; printBackground?: boolean;
@ -250,7 +252,7 @@ export async function renderPdf(
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 }); await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 });
await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" }); await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" });
const pdf = await page.pdf({ const pdf = await page.pdf({
format: (options.format as any) || "A4", format: options.format || "A4",
landscape: options.landscape || false, landscape: options.landscape || false,
printBackground: options.printBackground !== false, printBackground: options.printBackground !== false,
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
@ -280,7 +282,7 @@ export async function renderPdf(
export async function renderUrlPdf( export async function renderUrlPdf(
url: string, url: string,
options: PdfRenderOptions & { options: PdfRenderOptions & {
waitUntil?: string; waitUntil?: PuppeteerLifeCycleEvent;
hostResolverRules?: string; hostResolverRules?: string;
} = {} } = {}
): Promise<{ pdf: Buffer; durationMs: number }> { ): Promise<{ pdf: Buffer; durationMs: number }> {
@ -325,11 +327,11 @@ export async function renderUrlPdf(
const result = await Promise.race([ const result = await Promise.race([
(async () => { (async () => {
await page.goto(url, { await page.goto(url, {
waitUntil: (options.waitUntil as any) || "domcontentloaded", waitUntil: options.waitUntil || "domcontentloaded",
timeout: 30_000, timeout: 30_000,
}); });
const pdf = await page.pdf({ const pdf = await page.pdf({
format: (options.format as any) || "A4", format: options.format || "A4",
landscape: options.landscape || false, landscape: options.landscape || false,
printBackground: options.printBackground !== false, printBackground: options.printBackground !== false,
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },

View file

@ -8,3 +8,17 @@ import { ApiKey } from "./services/keys.js";
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
apiKeyInfo: ApiKey; apiKeyInfo: ApiKey;
} }
// Augment Express Request with custom properties added by middleware
declare global {
namespace Express {
interface Request {
/** Unique request ID (set by request logging middleware) */
requestId?: string;
/** Acquire a PDF concurrency slot (set by pdfRateLimitMiddleware) */
acquirePdfSlot?: () => Promise<void>;
/** Release a PDF concurrency slot (set by pdfRateLimitMiddleware) */
releasePdfSlot?: () => void;
}
}
}