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
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:
parent
a60d379e66
commit
da049b77e3
9 changed files with 89 additions and 18 deletions
46
src/__tests__/cors-staging.test.ts
Normal file
46
src/__tests__/cors-staging.test.ts
Normal 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("*");
|
||||
});
|
||||
});
|
||||
12
src/index.ts
12
src/index.ts
|
|
@ -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) {
|
||||
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 {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
14
src/types.ts
14
src/types.ts
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue