From e5c2a6a0123ca0ade0da2c300fc7c1c56d15f9ad Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Mon, 16 Mar 2026 07:39:31 -0500 Subject: [PATCH] Adding SQL Connector approach to enable SQL Auth --- .env.example | 8 ++ apphosting.yaml | 29 ++++ package-lock.json | 334 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/lib/db.ts | 68 +++++++++- 5 files changed, 436 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 0da2ffe..652d642 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +DB_CONNECTION_MODE=direct DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres # `openssl rand -hex 32` @@ -9,3 +10,10 @@ NEXT_PUBLIC_NEXTAUTH_URL=https://localhost:3001 OAUTH_REDIRECT_URI=https://localhost:3001/callback OAUTH_CLIENT_ID=local-client OAUTH_CLIENT_SECRET=**** + +# Cloud SQL Auth Proxy (Cloud Connector) — these are relevant if DB_CONNECTION_MODE=connector +# CLOUD_SQL_CONNECTION_NAME=PROJECT:REGION:INSTANCE +# DB_USER=your_db_user +# DB_PASSWORD=**** +# DB_NAME=your_db_name +# CLOUD_SQL_IP_TYPE=PRIVATE \ No newline at end of file diff --git a/apphosting.yaml b/apphosting.yaml index 5260942..7b4fbb5 100644 --- a/apphosting.yaml +++ b/apphosting.yaml @@ -59,3 +59,32 @@ env: availability: - BUILD - RUNTIME + + # Database connection mode: "connector" (Cloud SQL Connector) or "direct" (DATABASE_URL). + - variable: DB_CONNECTION_MODE + value: direct + availability: + - RUNTIME + + # Cloud SQL Connector settings (only used when DB_CONNECTION_MODE=connector). + - variable: CLOUD_SQL_CONNECTION_NAME + value: f3data:us-central1:f3data + availability: + - RUNTIME + - variable: DB_USER + value: app_codex + availability: + - RUNTIME + - variable: DB_PASSWORD + secret: client-database-password + availability: + - RUNTIME + - variable: DB_NAME + value: f3_prod + availability: + - RUNTIME + + - variable: CLOUD_SQL_IP_TYPE + value: PUBLIC + availability: + - RUNTIME diff --git a/package-lock.json b/package-lock.json index 1174207..86dba15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@genkit-ai/googleai": "^1.8.0", "@genkit-ai/next": "^1.8.0", + "@google-cloud/cloud-sql-connector": "^1.9.1", "@hookform/resolvers": "^4.1.3", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", @@ -2074,6 +2075,157 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/@google-cloud/cloud-sql-connector": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@google-cloud/cloud-sql-connector/-/cloud-sql-connector-1.9.1.tgz", + "integrity": "sha512-K7pkjQCq3u6r6KTeAbEdSDCXKmL5Ve8TNPAoek6ndkFmt44kvAZh0sTwRBipkGM0B5UmWljFROqCWGP4IHXBpg==", + "license": "Apache-2.0", + "dependencies": { + "@googleapis/sqladmin": "^35.2.0", + "gaxios": "^7.1.3", + "google-auth-library": "^10.5.0", + "p-throttle": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@google-cloud/cloud-sql-connector/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@google-cloud/firestore": { "version": "7.11.5", "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.5.tgz", @@ -2171,6 +2323,18 @@ "node": ">=18.0.0" } }, + "node_modules/@googleapis/sqladmin": { + "version": "35.2.0", + "resolved": "https://registry.npmjs.org/@googleapis/sqladmin/-/sqladmin-35.2.0.tgz", + "integrity": "sha512-ajR9EGLs1pCkKfsXxfbVRnQ7ZPyktKNAuahHoU06CVKguWwQo3b9aFmq06PYnGk1oXc0+tlW+XEamNa/HF4pbQ==", + "license": "Apache-2.0", + "dependencies": { + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.9.15", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", @@ -13723,6 +13887,158 @@ "node": ">=14" } }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/googleapis-common/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis-common/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/googleapis-common/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/googleapis-common/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -16457,6 +16773,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-throttle": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-7.0.0.tgz", + "integrity": "sha512-aio0v+S0QVkH1O+9x4dHtD4dgCExACcL+3EtNaGqC01GBudS9ijMuUsmN8OVScyV4OOp0jqdLShZFuSlbL/AsA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -19943,6 +20271,12 @@ "integrity": "sha512-EWkjYEN0L6KOfEoOH6Wj4ghQqU7eBZMJqRHQnxQAq+dSEzRPClkWjf8557HkWQXF6BrAUoLSAyy9i3RVTliaNg==", "license": "http://geraintluff.github.io/tv4/LICENSE.txt" }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", diff --git a/package.json b/package.json index 238bdcb..498e559 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@genkit-ai/googleai": "^1.8.0", "@genkit-ai/next": "^1.8.0", + "@google-cloud/cloud-sql-connector": "^1.9.1", "@hookform/resolvers": "^4.1.3", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", diff --git a/src/lib/db.ts b/src/lib/db.ts index 0000f63..2cecdf7 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,13 +1,63 @@ // src/lib/db.ts import { Pool, type PoolClient } from "pg"; +import { Connector } from "@google-cloud/cloud-sql-connector"; +import { IpAddressTypes } from "@google-cloud/cloud-sql-connector"; let pool: Pool | null = null; +let connector: InstanceType | null = null; -function initializePool(): Pool { +/** + * Creates a pool using the Cloud SQL Node.js Connector. + * Requires: CLOUD_SQL_CONNECTION_NAME, DB_USER, DB_PASSWORD, DB_NAME + */ +async function createCloudSqlPool(): Promise { + const instanceConnectionName = process.env.CLOUD_SQL_CONNECTION_NAME; + const dbUser = process.env.DB_USER; + const dbPassword = process.env.DB_PASSWORD; + const dbName = process.env.DB_NAME; + + if (!instanceConnectionName || !dbUser || !dbName) { + throw new Error( + "Cloud SQL Connector requires CLOUD_SQL_CONNECTION_NAME, DB_USER, and DB_NAME.", + ); + } + + const validTypes = Object.values(IpAddressTypes) as string[]; + const ipAddressType = validTypes.includes(process.env.CLOUD_SQL_IP_TYPE ?? "") + ? (process.env.CLOUD_SQL_IP_TYPE as IpAddressTypes) + : IpAddressTypes.PUBLIC; + + connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName, + ipType: ipAddressType, + }); + + const newPool = new Pool({ + ...clientOpts, + user: dbUser, + password: dbPassword, + database: dbName, + max: 10, + }); + + newPool.on("error", (err) => { + console.error("Unexpected error on idle PostgreSQL client:", err); + }); + + console.log( + `✅ PostgreSQL pool initialized via Cloud SQL Connector (${instanceConnectionName}).`, + ); + return newPool; +} + +/** + * Creates a pool using a direct TCP connection via DATABASE_URL. + */ +function createDirectPool(): Pool { const connectionString = process.env.DATABASE_URL; if (!connectionString) { - console.error("❌ CRITICAL: DATABASE_URL is not set in the environment."); throw new Error("DATABASE_URL is missing. Cannot connect to the database."); } @@ -23,16 +73,26 @@ function initializePool(): Pool { console.error("Unexpected error on idle PostgreSQL client:", err); }); + console.log("✅ PostgreSQL pool initialized via DATABASE_URL (TCP)."); return newPool; } /** * Acquires a PostgreSQL client from the connection pool. + * + * Connection mode is controlled by DB_CONNECTION_MODE: + * "connector" → Cloud SQL Node.js Connector (authenticated, no public IP needed) + * "direct" → DATABASE_URL TCP connection (default) */ export async function getClient(): Promise { - // Lazily initialize the pool only when a client is needed if (!pool) { - pool = initializePool(); + const mode = process.env.DB_CONNECTION_MODE ?? "direct"; + + if (mode === "connector") { + pool = await createCloudSqlPool(); + } else { + pool = createDirectPool(); + } } try {