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

View file

@ -11,7 +11,8 @@ function getStripe(): Stripe {
if (!_stripe) {
const key = process.env.STRIPE_SECRET_KEY;
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;
}

View file

@ -78,7 +78,7 @@ interface ConvertBody {
* 500:
* 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;
try {
// Reject non-JSON content types
@ -188,7 +188,7 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
* 500:
* 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;
try {
// Reject non-JSON content types
@ -299,7 +299,7 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
* 500:
* 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;
try {
// Reject non-JSON content types

View file

@ -97,7 +97,7 @@ interface DemoBody {
* 504:
* 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;
try {
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));
const defaultOpts = {
format: "A4",
format: "A4" as const,
landscape: false,
printBackground: true,
margin: { top: "0", right: "0", bottom: "0", left: "0" },
@ -203,7 +203,7 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise<void
* 504:
* 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;
try {
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 defaultOpts = {
format: "A4",
format: "A4" as const,
landscape: false,
printBackground: true,
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 });
return;
}
const sanitizedPdf = { format: "A4" as string, ...validation.sanitized };
const sanitizedPdf = { format: "A4" as const, ...validation.sanitized };
const html = renderTemplate(id, data);
const { pdf, durationMs } = await renderPdf(html, sanitizedPdf);

View file

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