diff --git a/src/config/database.ts b/src/config/database.ts index 4dbb2f6..fb57b4f 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -8,6 +8,82 @@ import { poolExhaustedCounter, } from "./promMetrics"; +function buildPrismaClient(url: string): PrismaClient { + return new PrismaClient({ + datasources: { db: { url } }, + log: [ + { level: "query", emit: "event" }, + { level: "error", emit: "stdout" }, + { level: "warn", emit: "stdout" }, + ], + }); +} + +function applyPrismaClientMiddleware(client: PrismaClient): void { + client.$use(async (params: Prisma.MiddlewareParams, next: Prisma.MiddlewareFn) => { + const tracer = trace.getTracer("prisma"); + const spanName = `prisma.${params.model ?? "raw"}.${params.action}`; + return tracer.startActiveSpan(spanName, async (span) => { + span.setAttributes({ + "db.system": "postgresql", + "db.operation": params.action, + ...(params.model ? { "db.prisma.model": params.model } : {}), + }); + try { + const result = await next(params); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (err) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }); + throw err; + } finally { + span.end(); + } + }); + }); + + client.$use(async (params: Prisma.MiddlewareParams, next: Prisma.MiddlewareFn) => { + const end = poolAcquireHistogram.startTimer({ + model: params.model ?? "raw", + action: params.action, + }); + try { + return await next(params); + } finally { + end(); + } + }); + + client.$use(async (params: Prisma.MiddlewareParams, next: Prisma.MiddlewareFn) => { + let lastError: unknown; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + return await next(params); + } catch (err) { + if (!isPoolExhaustionError(err)) { + throw err; + } + poolExhaustedCounter.inc(); + if (attempt < MAX_RETRIES) { + lastError = err; + const backoff = BASE_BACKOFF_MS * 2 ** (attempt - 1); + logger.warn("Prisma connection pool exhausted, retrying", { + model: params.model, + action: params.action, + attempt, + maxRetries: MAX_RETRIES, + backoffMs: backoff, + }); + await new Promise((r) => setTimeout(r, backoff)); + } else { + throw err; + } + } + } + throw lastError; + }); +} + // B-056: Validate URL assignments at boot to prevent runtime/migration confusion. // DATABASE_URL → direct PostgreSQL only (used by prisma migrate) // PRISMA_ACCELERATE_URL → prisma:// or prisma+postgres:// protocol (runtime connection pooling) @@ -31,8 +107,6 @@ if (config.prismaAccelerateUrl && !ACCELERATE_PROTOCOL_RE.test(config.prismaAcce ); } -const useAccelerate = Boolean(config.prismaAccelerateUrl); - // #386: When routing through Prisma Accelerate, the proxy enforces its own // query timeout (default 10 s, configurable via ACCELERATE_QUERY_TIMEOUT_MS in // the Accelerate dashboard). If Accelerate cancels a query before PostgreSQL @@ -65,42 +139,42 @@ function appendStatementTimeout(url: string, timeoutMs: number): string { } } -const databaseUrl = useAccelerate - ? config.prismaAccelerateUrl! - : appendStatementTimeout(config.databaseUrl, STATEMENT_TIMEOUT_MS); +// Retry config for connection pool exhaustion (Prisma Accelerate) +const MAX_RETRIES = 3; +const BASE_BACKOFF_MS = 200; + +function resolveDatabaseUrls(): { runtimeUrl: string; replicaUrl: string; useAccelerate: boolean } { + const configuredDatabaseUrl = process.env.DATABASE_URL || config.databaseUrl; + const configuredAccelerateUrl = process.env.PRISMA_ACCELERATE_URL || config.prismaAccelerateUrl; + const configuredReplicaUrl = process.env.DATABASE_URL_REPLICA || config.databaseUrlReplica; + + const latestStatementTimeoutMs = parseInt(process.env.DB_STATEMENT_TIMEOUT_MS ?? "9000", 10); + const useAccelerate = Boolean(configuredAccelerateUrl); + const runtimeUrl = useAccelerate + ? configuredAccelerateUrl! + : appendStatementTimeout(configuredDatabaseUrl, latestStatementTimeoutMs); + const replicaUrl = configuredReplicaUrl || runtimeUrl; + + return { runtimeUrl, replicaUrl, useAccelerate }; +} + +let basePrisma: PrismaClient = buildPrismaClient(resolveDatabaseUrls().runtimeUrl); +let basePrismaReplica: PrismaClient = buildPrismaClient(resolveDatabaseUrls().replicaUrl); +let currentRuntimeUrl = resolveDatabaseUrls().runtimeUrl; +let currentReplicaUrl = resolveDatabaseUrls().replicaUrl; +let currentUseAccelerate = resolveDatabaseUrls().useAccelerate; + +applyPrismaClientMiddleware(basePrisma); +applyPrismaClientMiddleware(basePrismaReplica); logger.info( - `[database] Runtime connection: ${useAccelerate ? "Prisma Accelerate (pooled)" : "direct PostgreSQL"}`, + `[database] Runtime connection: ${currentUseAccelerate ? "Prisma Accelerate (pooled)" : "direct PostgreSQL"}`, ); logger.info( "[database] Migration connection: direct PostgreSQL via DATABASE_URL " + "(run prisma migrate against DATABASE_URL, never against PRISMA_ACCELERATE_URL)", ); -const basePrisma = new PrismaClient({ - datasources: { db: { url: databaseUrl } }, - log: [ - { level: "query", emit: "event" }, - { level: "error", emit: "stdout" }, - { level: "warn", emit: "stdout" }, - ], -}); - -const replicaUrl = config.databaseUrlReplica || databaseUrl; - -const basePrismaReplica = new PrismaClient({ - datasources: { db: { url: replicaUrl } }, - log: [ - { level: "query", emit: "event" }, - { level: "error", emit: "stdout" }, - { level: "warn", emit: "stdout" }, - ], -}); - -// Retry config for connection pool exhaustion (Prisma Accelerate) -const MAX_RETRIES = 3; -const BASE_BACKOFF_MS = 200; - function isPoolExhaustionError(err: unknown): boolean { if (err instanceof Prisma.PrismaClientKnownRequestError) { return err.code === "P2024"; @@ -108,108 +182,48 @@ function isPoolExhaustionError(err: unknown): boolean { return false; } -// OTel: wrap every Prisma query in a span so traces link DB calls to parent spans -basePrisma.$use(async (params, next) => { - const tracer = trace.getTracer("prisma"); - const spanName = `prisma.${params.model ?? "raw"}.${params.action}`; - return tracer.startActiveSpan(spanName, async (span) => { - span.setAttributes({ - "db.system": "postgresql", - "db.operation": params.action, - ...(params.model ? { "db.prisma.model": params.model } : {}), - }); - try { - const result = await next(params); - span.setStatus({ code: SpanStatusCode.OK }); - return result; - } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }); - throw err; - } finally { - span.end(); - } - }); -}); +function refreshPrismaClientsIfNeeded(): void { + const resolved = resolveDatabaseUrls(); + if ( + resolved.runtimeUrl === currentRuntimeUrl && + resolved.replicaUrl === currentReplicaUrl && + resolved.useAccelerate === currentUseAccelerate + ) { + return; + } -// #388: Track per-query acquisition + execution latency for Prometheus. -// The "wait" component is estimated as the time before `next()` returns minus -// the query execution time; since Prisma doesn't expose a direct wait signal, -// we instrument total elapsed time labelled by model/action. -basePrisma.$use(async (params, next) => { - const end = poolAcquireHistogram.startTimer({ - model: params.model ?? "raw", - action: params.action, + logger.info("[database] Refreshing Prisma clients with updated connection settings", { + runtimeUrl: resolved.runtimeUrl, + replicaUrl: resolved.replicaUrl, + useAccelerate: resolved.useAccelerate, }); - try { - return await next(params); - } finally { - end(); - } -}); -// Connection pool exhaustion retry middleware: retry with exponential backoff -// when Prisma Accelerate returns P2024 (connection pool timeout). -// Retries up to MAX_RETRIES-1 times (attempt 1 = first try, attempt 4 throws). -basePrisma.$use(async (params, next) => { - let lastError: unknown; - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - return await next(params); - } catch (err) { - if (!isPoolExhaustionError(err)) { - throw err; - } - // #388: count every pool exhaustion regardless of retry outcome - poolExhaustedCounter.inc(); - if (attempt < MAX_RETRIES) { - lastError = err; - const backoff = BASE_BACKOFF_MS * 2 ** (attempt - 1); - logger.warn("Prisma connection pool exhausted, retrying", { - model: params.model, - action: params.action, - attempt, - maxRetries: MAX_RETRIES, - backoffMs: backoff, - }); - await new Promise((r) => setTimeout(r, backoff)); - } else { - throw err; - } - } - } - throw lastError; -}); + const previousBasePrisma = basePrisma; + const previousReplicaPrisma = basePrismaReplica; -basePrismaReplica.$use(async (params, next) => { - let lastError: unknown; - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - return await next(params); - } catch (err) { - if (!isPoolExhaustionError(err)) { - throw err; - } - if (attempt < MAX_RETRIES) { - lastError = err; - const backoff = BASE_BACKOFF_MS * 2 ** (attempt - 1); - logger.warn("Prisma replica connection pool exhausted, retrying", { - model: params.model, - action: params.action, - attempt, - maxRetries: MAX_RETRIES, - backoffMs: backoff, - }); - await new Promise((r) => setTimeout(r, backoff)); - } else { - throw err; - } - } - } - throw lastError; -}); + basePrisma = buildPrismaClient(resolved.runtimeUrl); + basePrismaReplica = buildPrismaClient(resolved.replicaUrl); + currentRuntimeUrl = resolved.runtimeUrl; + currentReplicaUrl = resolved.replicaUrl; + currentUseAccelerate = resolved.useAccelerate; + + applyPrismaClientMiddleware(basePrisma); + applyPrismaClientMiddleware(basePrismaReplica); + + void previousBasePrisma.$disconnect().catch((err: unknown) => { + logger.warn("[database] Failed to disconnect previous Prisma client", { error: err }); + }); + void previousReplicaPrisma.$disconnect().catch((err: unknown) => { + logger.warn("[database] Failed to disconnect previous Prisma replica client", { error: err }); + }); + + logger.info( + `[database] Runtime connection: ${currentUseAccelerate ? "Prisma Accelerate (pooled)" : "direct PostgreSQL"}`, + ); +} -export const prisma = useAccelerate ? basePrisma.$extends(withAccelerate()) : basePrisma; -export const prismaReplica = useAccelerate +export let prisma = currentUseAccelerate ? basePrisma.$extends(withAccelerate()) : basePrisma; +export let prismaReplica = currentUseAccelerate ? basePrismaReplica.$extends(withAccelerate()) : basePrismaReplica; @@ -255,6 +269,9 @@ export async function connectWithRetry(): Promise { let lastError: unknown; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { + refreshPrismaClientsIfNeeded(); + prisma = currentUseAccelerate ? basePrisma.$extends(withAccelerate()) : basePrisma; + prismaReplica = currentUseAccelerate ? basePrismaReplica.$extends(withAccelerate()) : basePrismaReplica; await Promise.all([basePrisma.$connect(), basePrismaReplica.$connect()]); if (attempt > 1) { logger.info("[database] Connected after retry", { attempt }); diff --git a/src/index.ts b/src/index.ts index 5949cc4..e09a199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,7 @@ app.use( // the CDN domain early, avoiding extra round-trip latency on every load. // When no CDN is in use, keep it off (default) to prevent information leakage. dnsPrefetchControl: { allow: !!config.cdnUrl }, + crossOriginOpenerPolicy: { policy: "same-origin" }, hsts: { maxAge: 31536000, includeSubDomains: true, @@ -102,24 +103,29 @@ function validateWebhookContentType(req: Request, _res: Response, next: NextFunc next(); } -// Webhooks need raw body for signature verification; mount before json() +// Webhooks need raw body for signature verification; mount before the generic JSON parser. app.use( `/${config.apiVersion}/webhooks`, validateWebhookContentType, - express.raw({ inflate: true, limit: MAX_REQUEST_BODY_SIZE, type: "application/json" }), - (req: express.Request, res: express.Response, next) => { - const raw = req.body as Buffer; - (req as unknown as { rawBody: Buffer }).rawBody = raw; - try { - (req as unknown as { body: unknown }).body = JSON.parse(raw.toString()); - } catch { - throw new AppError("Invalid JSON payload", 400, ErrorCodes.INVALID_JSON); - } - next(); - }, + express.json({ + inflate: true, + limit: MAX_REQUEST_BODY_SIZE, + type: "application/json", + verify: (req: express.Request, _res: express.Response, buf: Buffer) => { + (req as unknown as { rawBody?: Buffer }).rawBody = Buffer.from(buf); + }, + }), webhookRoutes, ); -app.use(express.json({ inflate: true, limit: MAX_REQUEST_BODY_SIZE })); +app.use( + express.json({ + inflate: true, + limit: MAX_REQUEST_BODY_SIZE, + verify: (req: express.Request, _res: express.Response, buf: Buffer) => { + (req as unknown as { rawBody?: Buffer }).rawBody = Buffer.from(buf); + }, + }), +); app.use( ( diff --git a/src/middleware/securityHeaders.ts b/src/middleware/securityHeaders.ts index dadaf8e..bd400e2 100644 --- a/src/middleware/securityHeaders.ts +++ b/src/middleware/securityHeaders.ts @@ -5,6 +5,7 @@ import helmet from "helmet"; */ export const securityHeadersMiddleware = helmet({ crossOriginEmbedderPolicy: true, + crossOriginOpenerPolicy: { policy: "same-origin" }, hsts: { maxAge: 31536000, includeSubDomains: true,