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 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", "*");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
14
src/types.ts
14
src/types.ts
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue