diff --git a/docs/cloud-api-runtime.md b/docs/cloud-api-runtime.md index 9280e4dc..9a8ba229 100644 --- a/docs/cloud-api-runtime.md +++ b/docs/cloud-api-runtime.md @@ -48,9 +48,12 @@ non-secret env flags: The Postgres adapter uses `HWLAB_CLOUD_DB_URL` from `hwlab-cloud-api-dev-db/database-url`; source records only the Secret reference -and non-secret DNS contract. The adapter reports `runtime.durable: true` only -after the configured adapter proves all durable gates through read-only runtime -checks: +and non-secret DNS contract. `HWLAB_CLOUD_DB_SSL_MODE` is the runtime authority +for DB TLS mode; if it is `disable`, the adapter strips URL `sslmode` query +parameters before opening the Postgres pool so stale Secret URL parameters +cannot override the DEV SSL contract. The adapter reports `runtime.durable: true` +only after the configured adapter proves all durable gates through read-only +runtime checks: - auth/query access to the configured DB path; - required runtime table and column schema; diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index aebaa1b3..b34310fb 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -176,6 +176,11 @@ Postgres adapter may report `runtime.durableRequested=true`, but it still stays non-durable until the schema, migration ledger, and read readiness gates are proven through the configured adapter. +`HWLAB_CLOUD_DB_SSL_MODE` is the non-secret runtime authority for Postgres TLS +mode. When DEV sets it to `disable`, the runtime strips stale URL `sslmode`, +`ssl`, and certificate query parameters before constructing the `pg` pool; a +Secret URL query parameter must not silently override the manifest SSL contract. + `/health` and `/health/live` expose this split in `readiness.durability`. `dbLiveEvidenceObserved=true` with `dbLiveEvidenceIsDurabilityEvidence=false` means the DB TCP/live layer is diff --git a/internal/db/runtime-store.mjs b/internal/db/runtime-store.mjs index 9d08ad67..e087afa2 100644 --- a/internal/db/runtime-store.mjs +++ b/internal/db/runtime-store.mjs @@ -68,6 +68,15 @@ export function createConfiguredCloudRuntimeStore(options = {}) { return createCloudRuntimeStore(options); } +export function buildPostgresPoolConfig({ dbUrl, sslMode = "require", timeoutMs } = {}) { + const normalizedSslMode = normalizePostgresSslMode(sslMode); + return { + connectionString: normalizePostgresConnectionStringForSslMode(dbUrl, normalizedSslMode), + ssl: postgresSslOption(normalizedSslMode), + connectionTimeoutMillis: normalizeRuntimeTimeoutMs(timeoutMs) + }; +} + export class CloudRuntimeStore { constructor({ now = () => new Date().toISOString() } = {}) { this.now = now; @@ -1139,11 +1148,11 @@ export class PostgresCloudRuntimeStore { throw driverError; } - this.pool = new Pool({ - connectionString: this.dbUrl, - ssl: this.sslMode === "require" ? { rejectUnauthorized: false } : false, - connectionTimeoutMillis: normalizeRuntimeTimeoutMs(this.env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS) - }); + this.pool = new Pool(buildPostgresPoolConfig({ + dbUrl: this.dbUrl, + sslMode: this.sslMode, + timeoutMs: this.env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS + })); return this.pool; } @@ -1310,6 +1319,43 @@ function normalizeRuntimeAdapter(value) { return RUNTIME_STORE_KIND; } +function normalizePostgresSslMode(value) { + const normalized = String(value ?? "").trim().toLowerCase(); + if (["disable", "false", "0", "off", "no"].includes(normalized)) { + return "disable"; + } + return "require"; +} + +function postgresSslOption(sslMode) { + return sslMode === "disable" ? false : { rejectUnauthorized: false }; +} + +function normalizePostgresConnectionStringForSslMode(dbUrl, sslMode) { + if (typeof dbUrl !== "string" || dbUrl.trim() === "") { + return dbUrl; + } + + let url; + try { + url = new URL(dbUrl); + } catch { + return dbUrl; + } + + if (!["postgres:", "postgresql:"].includes(url.protocol)) { + return dbUrl; + } + + for (const key of ["ssl", "sslmode", "sslcert", "sslkey", "sslrootcert"]) { + url.searchParams.delete(key); + } + if (sslMode !== "disable") { + url.searchParams.set("sslmode", "require"); + } + return url.toString(); +} + function postgresAdapterContract() { return { required: "Postgres-backed durable runtime adapter using HWLAB_CLOUD_DB_URL", diff --git a/internal/db/runtime-store.test.mjs b/internal/db/runtime-store.test.mjs index 678bd82a..841ac041 100644 --- a/internal/db/runtime-store.test.mjs +++ b/internal/db/runtime-store.test.mjs @@ -11,6 +11,7 @@ import { RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED, RUNTIME_STORE_KIND_POSTGRES, + buildPostgresPoolConfig, createCloudRuntimeStore, createConfiguredCloudRuntimeStore } from "./runtime-store.mjs"; @@ -132,6 +133,63 @@ test("configured postgres runtime classifies SSL negotiation blocker separately assert.equal(JSON.stringify(readiness).includes("The server does not support SSL connections"), false); }); +test("postgres pool config makes HWLAB_CLOUD_DB_SSL_MODE authoritative over URL sslmode", () => { + const disabled = buildPostgresPoolConfig({ + dbUrl: "postgres://hwlab_user:fixture-pass@db.example.test:5432/hwlab?sslmode=require&application_name=hwlab", + sslMode: "disable", + timeoutMs: "2500" + }); + + assert.equal(disabled.ssl, false); + assert.equal(disabled.connectionTimeoutMillis, 2500); + const disabledUrl = new URL(disabled.connectionString); + assert.equal(disabledUrl.searchParams.get("sslmode"), null); + assert.equal(disabledUrl.searchParams.get("ssl"), null); + assert.equal(disabledUrl.searchParams.get("application_name"), "hwlab"); + + const required = buildPostgresPoolConfig({ + dbUrl: "postgres://hwlab_user:fixture-pass@db.example.test:5432/hwlab?sslmode=disable", + sslMode: "require" + }); + + assert.deepEqual(required.ssl, { rejectUnauthorized: false }); + assert.equal(new URL(required.connectionString).searchParams.get("sslmode"), "require"); +}); + +test("configured postgres runtime passes normalized pool config to pg", async () => { + const pools = []; + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true", + HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS: "1800" + }, + dbUrl: "postgres://hwlab_user:fixture-pass@db.example.test:5432/hwlab?sslmode=require", + sslMode: "disable", + pgModuleLoader: async () => ({ + Pool: class FakePool { + constructor(config) { + this.config = config; + pools.push(this); + } + + async query() { + return { rows: [] }; + } + } + }) + }); + + await store.readiness(); + + assert.equal(pools.length, 1); + assert.equal(pools[0].config.ssl, false); + assert.equal(pools[0].config.connectionTimeoutMillis, 1800); + assert.equal(new URL(pools[0].config.connectionString).searchParams.get("sslmode"), null); + assert.equal(store.summary().blocker, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED); + assert.equal(store.summary().durabilityContract.blockedLayer, "schema"); +}); + test("configured postgres runtime classifies pg_hba no-encryption rejection as SSL before auth", async () => { const store = createConfiguredCloudRuntimeStore({ env: { diff --git a/scripts/src/dev-runtime-migration.mjs b/scripts/src/dev-runtime-migration.mjs index 66f07839..fb0eb475 100644 --- a/scripts/src/dev-runtime-migration.mjs +++ b/scripts/src/dev-runtime-migration.mjs @@ -17,7 +17,8 @@ import { RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, - RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED + RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED, + buildPostgresPoolConfig } from "../../internal/db/runtime-store.mjs"; import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs"; import { ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs"; @@ -345,11 +346,11 @@ async function createPgPool(env, options) { driverError.code = "HWLAB_PG_DRIVER_MISSING"; throw driverError; } - return new Pool({ - connectionString: env.HWLAB_CLOUD_DB_URL, - ssl: env.HWLAB_CLOUD_DB_SSL_MODE === "disable" ? false : { rejectUnauthorized: false }, - connectionTimeoutMillis: normalizeTimeoutMs(env.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS) - }); + return new Pool(buildPostgresPoolConfig({ + dbUrl: env.HWLAB_CLOUD_DB_URL, + sslMode: env.HWLAB_CLOUD_DB_SSL_MODE, + timeoutMs: normalizeTimeoutMs(env.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS) + })); } async function rollbackQuietly(client) { diff --git a/scripts/src/dev-runtime-migration.test.mjs b/scripts/src/dev-runtime-migration.test.mjs index c09139c9..fa7d1e7c 100644 --- a/scripts/src/dev-runtime-migration.test.mjs +++ b/scripts/src/dev-runtime-migration.test.mjs @@ -191,6 +191,31 @@ test("live dry-run closes pg pool after readiness verification", async () => { assert.ok(pools[0].calls.some((call) => call.type === "end")); }); +test("live dry-run normalizes pg pool ssl config before readiness verification", async () => { + const backingClient = createFakeQueryClient({ migrationReady: true }); + const pools = []; + const report = await buildDevRuntimeMigrationReport( + parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), + { + env: { + HWLAB_CLOUD_DB_URL: "postgresql://hwlab:fixture-pass@db.example.test:5432/hwlab?sslmode=require", + HWLAB_CLOUD_DB_SSL_MODE: "disable", + HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS: "1600" + }, + pgModuleLoader: async () => createFakePgModule({ backingClient, pools }), + now: () => "2026-05-22T00:00:00.000Z" + } + ); + + assert.equal(report.conclusion.status, "ready"); + assert.equal(pools.length, 1); + assert.equal(pools[0].config.ssl, false); + assert.equal(pools[0].config.connectionTimeoutMillis, 1600); + assert.equal(new URL(pools[0].config.connectionString).searchParams.get("sslmode"), null); + assert.equal(JSON.stringify(report).includes("fixture-pass"), false); + assert.equal(JSON.stringify(report).includes("db.example.test"), false); +}); + test("apply mode uses a dedicated pg client transaction", async () => { const backingClient = createFakeQueryClient({ migrationReady: false }); const pools = [];