diff --git a/.env.example b/.env.example index 490b0f0..c5ff4fc 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ # Database DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park" +PG_SSL_ROOT_CERT_B64="" +PG_SSL_IDENTITY_P12_B64="" +PG_SSL_IDENTITY_PASSWORD="" +# Optional override when your proxy hostname or certificate policy differs +# PG_SSL_MODE="verify-full" # Webhook API - Generate a secure key for production WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars" diff --git a/src/lib/prisma-url.test.ts b/src/lib/prisma-url.test.ts new file mode 100644 index 0000000..af20f25 --- /dev/null +++ b/src/lib/prisma-url.test.ts @@ -0,0 +1,64 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildPrismaDataSourceUrl } from "./prisma-url"; + +const ENV_KEYS = [ + "DATABASE_URL", + "PG_SSL_ROOT_CERT_B64", + "PG_SSL_IDENTITY_P12_B64", + "PG_SSL_IDENTITY_PASSWORD", + "PG_SSL_MODE", + "PG_SSL_CERT_DIR", +] as const; + +const envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + +afterEach(() => { + vi.restoreAllMocks(); + for (const key of ENV_KEYS) { + const value = envSnapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe("buildPrismaDataSourceUrl", () => { + it("returns the original url when TLS env vars are absent", () => { + const url = "postgresql://root:rootroot@103.112.185.248:6432/agent_park"; + + delete process.env.PG_SSL_ROOT_CERT_B64; + delete process.env.PG_SSL_IDENTITY_P12_B64; + + expect(buildPrismaDataSourceUrl(url)).toBe(url); + }); + + it("writes decoded TLS artifacts and appends Prisma SSL params", () => { + const certDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-park-prisma-test-")); + const baseUrl = "postgresql://root:rootroot@103.112.185.248:6432/agent_park"; + + process.env.PG_SSL_CERT_DIR = certDir; + process.env.PG_SSL_ROOT_CERT_B64 = Buffer.from("root-cert").toString("base64"); + process.env.PG_SSL_IDENTITY_P12_B64 = Buffer.from("identity-p12").toString("base64"); + process.env.PG_SSL_IDENTITY_PASSWORD = "topsecret"; + + const builtUrl = buildPrismaDataSourceUrl(baseUrl); + const parsed = new URL(builtUrl!); + + const rootCertPath = path.join(certDir, "ca.crt"); + const identityPath = path.join(certDir, "client-identity.p12"); + + expect(fs.readFileSync(rootCertPath, "utf8")).toBe("root-cert"); + expect(fs.readFileSync(identityPath, "utf8")).toBe("identity-p12"); + expect(parsed.searchParams.get("sslmode")).toBe("verify-full"); + expect(parsed.searchParams.get("sslrootcert")).toBe(rootCertPath); + expect(parsed.searchParams.get("sslidentity")).toBe(identityPath); + expect(parsed.searchParams.get("sslpassword")).toBe("topsecret"); + }); +}); diff --git a/src/lib/prisma-url.ts b/src/lib/prisma-url.ts new file mode 100644 index 0000000..6a7da42 --- /dev/null +++ b/src/lib/prisma-url.ts @@ -0,0 +1,53 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +const DEFAULT_CERT_DIR = path.join(os.tmpdir(), "agent-park-db-mtls"); + +function writeFileIfChanged(filePath: string, content: Buffer | string, mode?: number) { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + + const nextContent = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"); + const currentContent = fs.existsSync(filePath) ? fs.readFileSync(filePath) : null; + + if (!currentContent || !currentContent.equals(nextContent)) { + fs.writeFileSync(filePath, nextContent); + } + + if (mode !== undefined) { + fs.chmodSync(filePath, mode); + } +} + +export function buildPrismaDataSourceUrl(baseUrl = process.env.DATABASE_URL): string | undefined { + if (!baseUrl) { + return undefined; + } + + const rootCertB64 = process.env.PG_SSL_ROOT_CERT_B64; + const identityP12B64 = process.env.PG_SSL_IDENTITY_P12_B64; + + if (!rootCertB64 || !identityP12B64) { + return baseUrl; + } + + const certDir = process.env.PG_SSL_CERT_DIR || DEFAULT_CERT_DIR; + const rootCertPath = path.join(certDir, "ca.crt"); + const identityPath = path.join(certDir, "client-identity.p12"); + + writeFileIfChanged(rootCertPath, Buffer.from(rootCertB64, "base64"), 0o600); + writeFileIfChanged(identityPath, Buffer.from(identityP12B64, "base64"), 0o600); + + const url = new URL(baseUrl); + + url.searchParams.set("sslmode", process.env.PG_SSL_MODE || "verify-full"); + url.searchParams.set("sslrootcert", rootCertPath); + url.searchParams.set("sslidentity", identityPath); + + const identityPassword = process.env.PG_SSL_IDENTITY_PASSWORD; + if (identityPassword) { + url.searchParams.set("sslpassword", identityPassword); + } + + return url.toString(); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index af2a01e..3822134 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,9 +1,25 @@ -import { PrismaClient } from '@prisma/client' +import { PrismaClient } from "@prisma/client"; + +import { buildPrismaDataSourceUrl } from "@/lib/prisma-url"; const globalForPrisma = globalThis as unknown as { - prisma: PrismaClient | undefined -} + prisma: PrismaClient | undefined; +}; -export const prisma = globalForPrisma.prisma ?? new PrismaClient() +const datasourceUrl = buildPrismaDataSourceUrl(); -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient( + datasourceUrl + ? { + datasources: { + db: { + url: datasourceUrl, + }, + }, + } + : undefined, + ); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;