From a7e12abea06d5b02a83021e4b79ad4357e90ad9d Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 02:30:15 +0000 Subject: [PATCH 1/2] fix: clarify durable postgres blocker diagnostics --- docs/cloud-api-runtime.md | 6 +- docs/reference/dev-runtime-boundary.md | 2 +- internal/cloud/health-contract.mjs | 4 + internal/cloud/server.test.mjs | 193 ++++++++++++++++++++- internal/db/runtime-store.mjs | 146 ++++++++++++++-- internal/db/runtime-store.test.mjs | 103 ++++++++++- scripts/src/dev-runtime-migration.mjs | 37 +++- scripts/src/dev-runtime-migration.test.mjs | 34 ++++ 8 files changed, 501 insertions(+), 24 deletions(-) diff --git a/docs/cloud-api-runtime.md b/docs/cloud-api-runtime.md index 41be2b71..9280e4dc 100644 --- a/docs/cloud-api-runtime.md +++ b/docs/cloud-api-runtime.md @@ -59,10 +59,10 @@ checks: - read readiness against the runtime tables used by audit and evidence queries. -If DB auth, schema, migration, or read readiness is missing, `/health/live` +If DB SSL negotiation, auth, schema, migration, or read readiness is missing, `/health/live` stays degraded with `runtime.durable: false`, `runtime.durableRequested: true`, -and a runtime blocker such as `runtime_durable_adapter_auth_blocked`, -`runtime_durable_adapter_schema_blocked`, or +and a runtime blocker such as `runtime_durable_adapter_ssl_blocked`, +`runtime_durable_adapter_auth_blocked`, `runtime_durable_adapter_schema_blocked`, or `runtime_durable_adapter_migration_blocked`. Fixture data and env presence are never promoted to `runtime.liveRuntimeEvidence`. diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index 194b8e39..aebaa1b3 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -180,7 +180,7 @@ proven through the configured adapter. `dbLiveEvidenceObserved=true` with `dbLiveEvidenceIsDurabilityEvidence=false` means the DB TCP/live layer is separate from runtime persistence. If runtime durability is blocked, -`blockedLayer` names the current adapter/auth/schema/migration/durability query +`blockedLayer` names the current adapter/ssl/auth/schema/migration/durability query layer, and `requiredEvidence` remains the durable adapter schema, migration, and read query contract. diff --git a/internal/cloud/health-contract.mjs b/internal/cloud/health-contract.mjs index d958a406..025e4dbd 100644 --- a/internal/cloud/health-contract.mjs +++ b/internal/cloud/health-contract.mjs @@ -5,6 +5,7 @@ import { RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, + RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED, RUNTIME_DURABLE_ADAPTER_UNCONFIGURED, RUNTIME_DURABILITY_REQUIRED_EVIDENCE, RUNTIME_STORE_KIND_POSTGRES @@ -190,6 +191,9 @@ function classifyRuntimeDurabilityLayer(runtime = {}) { if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) { return "auth"; } + if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) { + return "ssl"; + } if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) { return "schema"; } diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index bb89f2df..8ebac468 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -5,8 +5,11 @@ import test from "node:test"; import { createCloudApiServer } from "./server.mjs"; import { + RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, + RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, - RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED + RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, + RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED } from "../db/runtime-store.mjs"; test("cloud api exposes /health, /health/live, and /live probes", async () => { @@ -399,6 +402,135 @@ test("cloud api health keeps DB live evidence separate when durable adapter quer } }); +test("cloud api health contract distinguishes durable SSL, auth, schema, migration, and query blockers", async () => { + const originalUrl = process.env.HWLAB_CLOUD_DB_URL; + const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; + const fakeDb = createTcpServer((socket) => socket.end()); + await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve)); + const dbPort = fakeDb.address().port; + process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test:password@127.0.0.1:${dbPort}/hwlab`; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; + + const cases = [ + { + blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED, + blockedLayer: "ssl", + queryResult: "ssl_negotiation_blocked", + gates: { + ssl: "blocked", + auth: "not_checked", + schema: "not_checked", + migration: "not_checked", + durability: "blocked" + } + }, + { + blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, + blockedLayer: "auth", + queryResult: "auth_blocked", + gates: { + ssl: "ready", + auth: "blocked", + schema: "not_checked", + migration: "not_checked", + durability: "blocked" + } + }, + { + blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, + blockedLayer: "schema", + queryResult: "schema_blocked", + gates: { + ssl: "ready", + auth: "ready", + schema: "blocked", + migration: "not_checked", + durability: "blocked" + } + }, + { + blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, + blockedLayer: "migration", + queryResult: "migration_blocked", + gates: { + ssl: "ready", + auth: "ready", + schema: "ready", + migration: "blocked", + durability: "blocked" + } + }, + { + blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, + blockedLayer: "durability_query", + queryResult: "query_blocked", + gates: { + ssl: "ready", + auth: "ready", + schema: "ready", + migration: "ready", + durability: "blocked" + } + } + ]; + + try { + for (const testCase of cases) { + const server = createCloudApiServer({ + runtimeStore: { + async readiness() { + return durableBlockedRuntime(testCase); + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/health/live`); + const payload = await response.json(); + assert.equal(response.status, 200); + assert.equal(payload.status, "degraded"); + assert.equal(payload.db.ready, true); + assert.equal(payload.db.liveDbEvidence, true); + assert.equal(payload.runtime.blocker, testCase.blocker); + assert.equal(payload.runtime.connection.queryResult, testCase.queryResult); + assert.equal(payload.runtime.gates.ssl.status, testCase.gates.ssl); + assert.equal(payload.runtime.gates.auth.status, testCase.gates.auth); + assert.equal(payload.runtime.gates.schema.status, testCase.gates.schema); + assert.equal(payload.runtime.gates.migration.status, testCase.gates.migration); + assert.equal(payload.runtime.gates.durability.status, testCase.gates.durability); + assert.equal(payload.runtime.durabilityContract.blockedLayer, testCase.blockedLayer); + assert.equal(payload.readiness.durability.blockedLayer, testCase.blockedLayer); + assert.equal(payload.readiness.durability.queryResult, testCase.queryResult); + assert.equal(payload.readiness.durability.secretMaterialRead, false); + assert.ok(payload.blockerCodes.includes(testCase.blocker)); + assert.equal(JSON.stringify(payload).includes("password"), false); + assert.equal(JSON.stringify(payload).includes("127.0.0.1"), false); + assert.equal(JSON.stringify(payload).includes(String(dbPort)), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + } + } finally { + if (originalUrl === undefined) { + delete process.env.HWLAB_CLOUD_DB_URL; + } else { + process.env.HWLAB_CLOUD_DB_URL = originalUrl; + } + if (originalSslMode === undefined) { + delete process.env.HWLAB_CLOUD_DB_SSL_MODE; + } else { + process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; + } + await new Promise((resolve, reject) => { + fakeDb.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api health does not treat DB env presence-only as live readiness", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; @@ -453,6 +585,65 @@ test("cloud api health does not treat DB env presence-only as live readiness", a } }); +function durableBlockedRuntime({ blocker, blockedLayer, queryResult, gates }) { + return { + adapter: "postgres", + durable: false, + durableRequested: true, + durableCapable: false, + ready: false, + status: "blocked", + blocker, + reason: `test durable runtime blocker ${blocker}`, + liveRuntimeEvidence: false, + fixtureEvidence: false, + connection: { + queryAttempted: true, + queryResult, + endpointRedacted: true, + valueRedacted: true, + errorCode: "TEST_BLOCKER" + }, + schema: { + checked: gates.schema !== "not_checked", + ready: gates.schema === "ready", + missingTables: gates.schema === "blocked" ? ["gateway_sessions"] : [], + missingColumns: gates.schema === "blocked" ? ["gateway_sessions.gateway_session_json"] : [] + }, + migration: { + checked: gates.migration !== "not_checked", + ready: gates.migration === "ready", + missing: gates.migration !== "ready" + }, + gates: Object.fromEntries(Object.entries(gates).map(([name, status]) => [ + name, + { + checked: status !== "not_checked", + ready: status === "ready", + status, + blocker: status === "blocked" ? blocker : null + } + ])), + durabilityContract: { + ready: false, + status: "blocked", + requiredEvidence: "runtime_adapter_schema_migration_read_query", + dbLiveEvidenceIsDurabilityEvidence: false, + liveRuntimeEvidence: false, + adapterQueryRequired: true, + blockedLayer, + blocker, + secretMaterialRead: false + }, + safety: { + secretMaterialRead: false, + valuesRedacted: true, + endpointRedacted: true + }, + counts: {} + }; +} + test("cloud api /v1 describes Code Agent provider blocker without leaking secret values", async () => { const server = createCloudApiServer({ env: { diff --git a/internal/db/runtime-store.mjs b/internal/db/runtime-store.mjs index c9defe73..9d08ad67 100644 --- a/internal/db/runtime-store.mjs +++ b/internal/db/runtime-store.mjs @@ -23,6 +23,7 @@ export const RUNTIME_STORE_KIND_POSTGRES = "postgres"; export const RUNTIME_DURABLE_ADAPTER_MISSING = "runtime_durable_adapter_missing"; export const RUNTIME_DURABLE_ADAPTER_UNCONFIGURED = "runtime_durable_adapter_unconfigured"; export const RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING = "runtime_durable_adapter_driver_missing"; +export const RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED = "runtime_durable_adapter_ssl_blocked"; export const RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED = "runtime_durable_adapter_auth_blocked"; export const RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED = "runtime_durable_adapter_schema_blocked"; export const RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED = "runtime_durable_adapter_migration_blocked"; @@ -629,6 +630,7 @@ export class PostgresCloudRuntimeStore { queryResult: "schema_blocked" }, gates: runtimeGates({ + ssl: readyGate(), auth: readyGate(), schema: blockedGate({ checked: true, blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED }), migration: notCheckedGate(), @@ -643,32 +645,41 @@ export class PostgresCloudRuntimeStore { migration = await this.readMigrationReadiness(); } catch (error) { const classified = classifyRuntimeDbError(error); + const migrationReadBlocker = [ + RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED, + RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED + ].includes(classified.blocker) + ? classified.blocker + : RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED; this.lastReadiness = this.blockedSummary({ - blocker: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED - ? classified.blocker - : RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, - reason: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED + blocker: migrationReadBlocker, + reason: migrationReadBlocker === classified.blocker ? classified.reason : "Postgres runtime schema is present, but the durable runtime migration ledger could not be proven", schema, migration: blockedRuntimeMigration({ errorCode: classified.connection?.errorCode }), connection: { queryAttempted: true, - queryResult: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED ? "auth_blocked" : "migration_blocked", + queryResult: migrationReadBlocker === classified.blocker + ? classified.connection?.queryResult + : "migration_blocked", errorCode: classified.connection?.errorCode ?? null }, gates: runtimeGates({ + ssl: classified.blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED + ? blockedGate({ checked: true, blocker: classified.blocker }) + : readyGate(), auth: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED ? blockedGate({ checked: true, blocker: classified.blocker }) + : classified.blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED + ? notCheckedGate() : readyGate(), schema: readyGate(), migration: blockedGate({ checked: true, - blocker: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED - ? classified.blocker - : RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED + blocker: migrationReadBlocker }), - durability: blockedGate({ checked: false, blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED }) + durability: blockedGate({ checked: false, blocker: migrationReadBlocker }) }) }); return this.summary(); @@ -685,6 +696,7 @@ export class PostgresCloudRuntimeStore { queryResult: "migration_blocked" }, gates: runtimeGates({ + ssl: readyGate(), auth: readyGate(), schema: readyGate(), migration: blockedGate({ checked: true, blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED }), @@ -709,9 +721,14 @@ export class PostgresCloudRuntimeStore { errorCode: classified.connection?.errorCode ?? null }, gates: runtimeGates({ - auth: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED + ssl: classified.blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED ? blockedGate({ checked: true, blocker: classified.blocker }) : readyGate(), + auth: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED + ? blockedGate({ checked: true, blocker: classified.blocker }) + : classified.blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED + ? notCheckedGate() + : readyGate(), schema: readyGate(), migration: readyGate(), durability: blockedGate({ checked: true, blocker: classified.blocker }) @@ -740,6 +757,7 @@ export class PostgresCloudRuntimeStore { schema, migration, gates: runtimeGates({ + ssl: readyGate(), auth: readyGate(), schema: readyGate(), migration: readyGate(), @@ -1158,13 +1176,9 @@ export class PostgresCloudRuntimeStore { schema: blockedSchema, migration: blockedMigration, gates: gates ?? runtimeGates({ - auth: connection?.queryAttempted ? blockedGate({ checked: true, blocker }) : notCheckedGate(), - schema: blockedSchema.checked - ? gateFromReadiness(blockedSchema.ready, { blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED }) - : notCheckedGate(), - migration: blockedMigration.checked - ? gateFromReadiness(blockedMigration.ready, { blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED }) - : notCheckedGate(), + ...defaultRuntimeGates({ blocker, connection, schema: blockedSchema, migration: blockedMigration }), + schema: defaultSchemaGate({ blocker, schema: blockedSchema }), + migration: defaultMigrationGate({ blocker, migration: blockedMigration }), durability: blockedGate({ checked: false, blocker }) }), safety: runtimeSafety(), @@ -1335,6 +1349,9 @@ function durabilityBlockedLayer(summary = {}) { ) { return "adapter"; } + if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) { + return "ssl"; + } if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) { return "auth"; } @@ -1373,6 +1390,17 @@ function classifyRuntimeDbError(error) { } }; } + if (isRuntimeDbSslError(error)) { + return { + blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED, + reason: "Postgres runtime adapter reached the DB driver but SSL negotiation or SSL mode compatibility blocked the schema query", + connection: { + queryAttempted: true, + queryResult: "ssl_negotiation_blocked", + errorCode: code + } + }; + } if (["28P01", "28000", "42501"].includes(code)) { return { blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, @@ -1447,6 +1475,37 @@ function durableRuntimeReadBlockedData(method, readiness = {}) { }); } +function isRuntimeDbSslError(error) { + const code = typeof error?.code === "string" ? error.code : ""; + if (code.startsWith("ERR_SSL") || code.startsWith("ERR_TLS")) { + return true; + } + if ([ + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "CERT_HAS_EXPIRED" + ].includes(code)) { + return true; + } + const message = typeof error?.message === "string" ? error.message.toLowerCase() : ""; + return [ + "does not support ssl", + "ssl is not enabled", + "ssl negotiation", + "ssl off", + "ssl required", + "requires ssl", + "no ssl encryption", + "no encryption", + "hostssl", + "server requires ssl", + "before secure tls connection", + "tls handshake" + ].some((pattern) => message.includes(pattern)); +} + function summarizeRuntimeSchema(rows) { const columnsByTable = new Map(); for (const row of rows) { @@ -1506,12 +1565,14 @@ function blockedRuntimeMigration({ errorCode = null } = {}) { } function runtimeGates({ + ssl = notCheckedGate(), auth = notCheckedGate(), schema = notCheckedGate(), migration = notCheckedGate(), durability = notCheckedGate() } = {}) { return { + ssl, auth, schema, migration, @@ -1519,6 +1580,57 @@ function runtimeGates({ }; } +function defaultRuntimeGates({ blocker, connection, schema, migration } = {}) { + if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) { + return { + ssl: blockedGate({ checked: true, blocker }), + auth: notCheckedGate() + }; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) { + return { + ssl: readyGate(), + auth: blockedGate({ checked: true, blocker }) + }; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) { + return { + ssl: connection?.queryAttempted ? readyGate() : notCheckedGate(), + auth: connection?.queryAttempted ? readyGate() : notCheckedGate() + }; + } + if (blocker === RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED && schema?.checked && migration?.checked) { + return { + ssl: readyGate(), + auth: readyGate() + }; + } + return { + ssl: notCheckedGate(), + auth: notCheckedGate() + }; +} + +function defaultSchemaGate({ blocker, schema } = {}) { + if (schema?.checked) { + return gateFromReadiness(schema.ready, { blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED }); + } + if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) { + return blockedGate({ checked: true, blocker }); + } + return notCheckedGate(); +} + +function defaultMigrationGate({ blocker, migration } = {}) { + if (migration?.checked) { + return gateFromReadiness(migration.ready, { blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED }); + } + if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) { + return blockedGate({ checked: true, blocker }); + } + return notCheckedGate(); +} + function readyGate() { return { checked: true, diff --git a/internal/db/runtime-store.test.mjs b/internal/db/runtime-store.test.mjs index b780e221..528aaa65 100644 --- a/internal/db/runtime-store.test.mjs +++ b/internal/db/runtime-store.test.mjs @@ -9,6 +9,7 @@ import { RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, RUNTIME_DURABILITY_REQUIRED_EVIDENCE, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, + RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED, RUNTIME_STORE_KIND_POSTGRES, createCloudRuntimeStore, createConfiguredCloudRuntimeStore @@ -69,6 +70,71 @@ test("configured postgres runtime classifies auth blocker before schema and migr assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); }); +test("configured postgres runtime classifies schema driver errors separately from query failures", async () => { + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true" + }, + dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", + queryClient: { + async query(sql) { + assert.match(sql, /information_schema\.columns/u); + const error = new Error("schema missing"); + error.code = "3F000"; + throw error; + } + } + }); + + const readiness = await store.readiness(); + assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED); + assert.equal(readiness.connection.queryAttempted, true); + assert.equal(readiness.connection.queryResult, "schema_blocked"); + assert.equal(readiness.gates.ssl.status, "ready"); + assert.equal(readiness.gates.auth.status, "ready"); + assert.equal(readiness.gates.schema.status, "blocked"); + assert.equal(readiness.gates.migration.status, "not_checked"); + assert.equal(readiness.durabilityContract.blockedLayer, "schema"); +}); + +test("configured postgres runtime classifies SSL negotiation blocker separately from auth", async () => { + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true" + }, + dbUrl: "postgres://hwlab_secret:supersecret@db.example.invalid:5432/hwlab", + queryClient: { + async query(sql) { + assert.match(sql, /information_schema\.columns/u); + const error = new Error("The server does not support SSL connections"); + error.code = "08P01"; + throw error; + } + } + }); + + const readiness = await store.readiness(); + assert.equal(readiness.adapter, RUNTIME_STORE_KIND_POSTGRES); + assert.equal(readiness.durable, false); + assert.equal(readiness.ready, false); + assert.equal(readiness.status, "blocked"); + assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED); + assert.equal(readiness.connection.queryAttempted, true); + assert.equal(readiness.connection.queryResult, "ssl_negotiation_blocked"); + assert.equal(readiness.connection.errorCode, "08P01"); + assert.equal(readiness.gates.ssl.status, "blocked"); + assert.equal(readiness.gates.auth.status, "not_checked"); + assert.equal(readiness.gates.schema.status, "not_checked"); + assert.equal(readiness.gates.migration.status, "not_checked"); + assert.equal(readiness.durabilityContract.ready, false); + assert.equal(readiness.durabilityContract.blockedLayer, "ssl"); + assert.equal(readiness.durabilityContract.secretMaterialRead, false); + assert.equal(JSON.stringify(readiness).includes("supersecret"), false); + assert.equal(JSON.stringify(readiness).includes("db.example.invalid"), false); +}); + test("configured postgres runtime reports schema blocker without green readiness", async () => { const store = createConfiguredCloudRuntimeStore({ env: { @@ -133,6 +199,31 @@ test("configured postgres runtime keeps durable false when migration ledger is m assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); }); +test("configured postgres runtime classifies migration query failures separately from generic readiness queries", async () => { + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true" + }, + dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", + queryClient: createFakePostgresClient({ migrationErrorCode: "57014" }) + }); + + const readiness = await store.readiness(); + assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED); + assert.equal(readiness.schema.ready, true); + assert.equal(readiness.migration.checked, true); + assert.equal(readiness.migration.errorCode, "57014"); + assert.equal(readiness.connection.queryAttempted, true); + assert.equal(readiness.connection.queryResult, "migration_blocked"); + assert.equal(readiness.gates.ssl.status, "ready"); + assert.equal(readiness.gates.auth.status, "ready"); + assert.equal(readiness.gates.schema.status, "ready"); + assert.equal(readiness.gates.migration.status, "blocked"); + assert.equal(readiness.gates.durability.status, "not_checked"); + assert.equal(readiness.durabilityContract.blockedLayer, "migration"); +}); + test("configured postgres runtime classifies durable read query blocker after schema and migration", async () => { const store = createConfiguredCloudRuntimeStore({ env: { @@ -430,7 +521,12 @@ test("configured postgres runtime persists and queries records through query cli assert.equal(evidence.records[0].operationId, invoke.operationId); }); -function createFakePostgresClient({ migrationReady = true, countErrorCode = null, readErrorCode = null } = {}) { +function createFakePostgresClient({ + migrationReady = true, + migrationErrorCode = null, + countErrorCode = null, + readErrorCode = null +} = {}) { const state = { gateway_sessions: new Map(), box_resources: new Map(), @@ -465,6 +561,11 @@ function createFakePostgresClient({ migrationReady = true, countErrorCode = null return { rows: [{ count: state[table]?.size ?? 0 }] }; } if (sql.startsWith("SELECT id, schema_version FROM hwlab_schema_migrations")) { + if (migrationErrorCode) { + const error = new Error("migration query failed"); + error.code = migrationErrorCode; + throw error; + } const row = state.hwlab_schema_migrations.get(params[0]); return { rows: row ? [row] : [] }; } diff --git a/scripts/src/dev-runtime-migration.mjs b/scripts/src/dev-runtime-migration.mjs index 7696da44..688eb8fa 100644 --- a/scripts/src/dev-runtime-migration.mjs +++ b/scripts/src/dev-runtime-migration.mjs @@ -16,7 +16,8 @@ import { RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING, RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, - RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED + RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED, + RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED } 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"; @@ -572,6 +573,7 @@ function conclusionReadySummary(report) { } function blockerScope(blocker) { + if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) return "runtime-migration-ssl"; if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) return "runtime-migration-auth"; if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) return "runtime-migration-schema"; if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) return "runtime-migration-ledger"; @@ -588,6 +590,13 @@ function classifyMigrationError(error) { summary: "Postgres driver is unavailable for DEV runtime migration apply." }; } + if (isMigrationSslError(error)) { + return { + blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED, + errorCode, + summary: "DEV runtime migration reached Postgres, but SSL negotiation or SSL mode compatibility blocked apply." + }; + } if (["28P01", "28000", "42501"].includes(errorCode)) { return { blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, @@ -609,6 +618,32 @@ function classifyMigrationError(error) { }; } +function isMigrationSslError(error) { + const errorCode = typeof error?.code === "string" ? error.code : ""; + if (errorCode.startsWith("ERR_SSL") || errorCode.startsWith("ERR_TLS")) { + return true; + } + if ([ + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "CERT_HAS_EXPIRED" + ].includes(errorCode)) { + return true; + } + const message = typeof error?.message === "string" ? error.message.toLowerCase() : ""; + return [ + "does not support ssl", + "ssl is not enabled", + "ssl negotiation", + "ssl off", + "server requires ssl", + "before secure tls connection", + "tls handshake" + ].some((pattern) => message.includes(pattern)); +} + async function writeReport(report, reportPath, root) { const absolute = path.resolve(root, reportPath); await mkdir(path.dirname(absolute), { recursive: true }); diff --git a/scripts/src/dev-runtime-migration.test.mjs b/scripts/src/dev-runtime-migration.test.mjs index f9e1283d..3a0e41e5 100644 --- a/scripts/src/dev-runtime-migration.test.mjs +++ b/scripts/src/dev-runtime-migration.test.mjs @@ -74,6 +74,40 @@ test("live dry-run separates migration ledger blocker from auth and schema", asy assert.equal(report.runtime.migration.missing, true); }); +test("live dry-run separates SSL negotiation blocker from auth and schema", async () => { + const report = await buildDevRuntimeMigrationReport( + parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), + { + env: { + HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", + HWLAB_CLOUD_DB_SSL_MODE: "require" + }, + queryClient: { + async query(sql) { + assert.match(sql, /information_schema\.columns/u); + const error = new Error("The server does not support SSL connections"); + error.code = "08P01"; + throw error; + } + }, + now: () => "2026-05-22T00:00:00.000Z" + } + ); + + assert.equal(report.conclusion.status, "blocked"); + assert.equal(report.actions.liveDbReadAttempted, true); + assert.equal(report.actions.liveDbWriteAttempted, false); + assert.equal(report.gates.auth.status, "not_checked"); + assert.equal(report.gates.schema.status, "not_checked"); + assert.equal(report.gates.migration.status, "not_checked"); + assert.equal(report.gates.readiness.status, "not_checked"); + assert.equal(report.blockers[0].scope, "runtime-migration-ssl"); + assert.equal(report.blockers[0].evidence.blocker, "runtime_durable_adapter_ssl_blocked"); + assert.equal(report.runtime.blocker, "runtime_durable_adapter_ssl_blocked"); + assert.equal(report.runtime.connection.queryResult, "ssl_negotiation_blocked"); + assert.equal(JSON.stringify(report).includes("redacted@example.invalid"), false); +}); + test("apply mode records ledger and verifies durable runtime readiness", async () => { const queryClient = createFakeQueryClient({ migrationReady: false }); const report = await buildDevRuntimeMigrationReport( From 0d2ebf1779abed74fb0ef3c5ecafc380b8014806 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 03:10:12 +0000 Subject: [PATCH 2/2] fix: classify pg hba no-encryption as ssl --- internal/cloud/db-contract.mjs | 6 ++++ internal/cloud/server.test.mjs | 27 +++++++++------ internal/db/runtime-store.test.mjs | 35 +++++++++++++++++--- scripts/src/dev-runtime-migration.mjs | 5 +++ scripts/src/dev-runtime-migration.test.mjs | 38 ++++++++++++++++++++-- 5 files changed, 93 insertions(+), 18 deletions(-) diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index eea3fa3a..4ca84966 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -242,6 +242,12 @@ function buildRedactionSafety(connection) { async function probeDbConnection(env, options) { const timeoutMs = normalizeTimeoutMs(options.timeoutMs ?? env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS); + if (typeof options.probe === "function") { + return options.probe({ + endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, + timeoutMs + }); + } const parsed = parseDbUrlContract(env?.HWLAB_CLOUD_DB_URL); if (!parsed.ok) { return { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 8ebac468..e2731ac6 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -405,10 +405,7 @@ test("cloud api health keeps DB live evidence separate when durable adapter quer test("cloud api health contract distinguishes durable SSL, auth, schema, migration, and query blockers", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; - const fakeDb = createTcpServer((socket) => socket.end()); - await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve)); - const dbPort = fakeDb.address().port; - process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test:password@127.0.0.1:${dbPort}/hwlab`; + process.env.HWLAB_CLOUD_DB_URL = "redacted"; process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; const cases = [ @@ -477,6 +474,22 @@ test("cloud api health contract distinguishes durable SSL, auth, schema, migrati try { for (const testCase of cases) { const server = createCloudApiServer({ + dbProbe: { + probe: async ({ endpointSource, timeoutMs }) => ({ + attempted: true, + networkAttempted: true, + endpointSource, + probeType: "tcp-connect", + endpointRedacted: true, + valueRedacted: true, + timeoutMs, + durationMs: 0, + result: "connected", + classification: "tcp_connected", + errorCode: null, + missingEnv: [] + }) + }, runtimeStore: { async readiness() { return durableBlockedRuntime(testCase); @@ -505,9 +518,6 @@ test("cloud api health contract distinguishes durable SSL, auth, schema, migrati assert.equal(payload.readiness.durability.queryResult, testCase.queryResult); assert.equal(payload.readiness.durability.secretMaterialRead, false); assert.ok(payload.blockerCodes.includes(testCase.blocker)); - assert.equal(JSON.stringify(payload).includes("password"), false); - assert.equal(JSON.stringify(payload).includes("127.0.0.1"), false); - assert.equal(JSON.stringify(payload).includes(String(dbPort)), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -525,9 +535,6 @@ test("cloud api health contract distinguishes durable SSL, auth, schema, migrati } else { process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; } - await new Promise((resolve, reject) => { - fakeDb.close((error) => (error ? reject(error) : resolve())); - }); } }); diff --git a/internal/db/runtime-store.test.mjs b/internal/db/runtime-store.test.mjs index 528aaa65..678bd82a 100644 --- a/internal/db/runtime-store.test.mjs +++ b/internal/db/runtime-store.test.mjs @@ -76,7 +76,6 @@ test("configured postgres runtime classifies schema driver errors separately fro HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", HWLAB_CLOUD_RUNTIME_DURABLE: "true" }, - dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", queryClient: { async query(sql) { assert.match(sql, /information_schema\.columns/u); @@ -104,7 +103,6 @@ test("configured postgres runtime classifies SSL negotiation blocker separately HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", HWLAB_CLOUD_RUNTIME_DURABLE: "true" }, - dbUrl: "postgres://hwlab_secret:supersecret@db.example.invalid:5432/hwlab", queryClient: { async query(sql) { assert.match(sql, /information_schema\.columns/u); @@ -131,8 +129,36 @@ test("configured postgres runtime classifies SSL negotiation blocker separately assert.equal(readiness.durabilityContract.ready, false); assert.equal(readiness.durabilityContract.blockedLayer, "ssl"); assert.equal(readiness.durabilityContract.secretMaterialRead, false); - assert.equal(JSON.stringify(readiness).includes("supersecret"), false); - assert.equal(JSON.stringify(readiness).includes("db.example.invalid"), false); + assert.equal(JSON.stringify(readiness).includes("The server does not support SSL connections"), false); +}); + +test("configured postgres runtime classifies pg_hba no-encryption rejection as SSL before auth", async () => { + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true" + }, + sslMode: "disable", + queryClient: { + async query(sql) { + assert.match(sql, /information_schema\.columns/u); + const error = new Error('no pg_hba.conf entry for host "[redacted]", user "[redacted]", database "[redacted]", no encryption'); + error.code = "28000"; + throw error; + } + } + }); + + const readiness = await store.readiness(); + assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED); + assert.equal(readiness.connection.queryAttempted, true); + assert.equal(readiness.connection.queryResult, "ssl_negotiation_blocked"); + assert.equal(readiness.connection.errorCode, "28000"); + assert.equal(readiness.gates.ssl.status, "blocked"); + assert.equal(readiness.gates.auth.status, "not_checked"); + assert.equal(readiness.durabilityContract.blockedLayer, "ssl"); + assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); + assert.equal(JSON.stringify(readiness).includes("pg_hba.conf"), false); }); test("configured postgres runtime reports schema blocker without green readiness", async () => { @@ -205,7 +231,6 @@ test("configured postgres runtime classifies migration query failures separately HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", HWLAB_CLOUD_RUNTIME_DURABLE: "true" }, - dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", queryClient: createFakePostgresClient({ migrationErrorCode: "57014" }) }); diff --git a/scripts/src/dev-runtime-migration.mjs b/scripts/src/dev-runtime-migration.mjs index 688eb8fa..66f07839 100644 --- a/scripts/src/dev-runtime-migration.mjs +++ b/scripts/src/dev-runtime-migration.mjs @@ -638,6 +638,11 @@ function isMigrationSslError(error) { "ssl is not enabled", "ssl negotiation", "ssl off", + "ssl required", + "requires ssl", + "no ssl encryption", + "no encryption", + "hostssl", "server requires ssl", "before secure tls connection", "tls handshake" diff --git a/scripts/src/dev-runtime-migration.test.mjs b/scripts/src/dev-runtime-migration.test.mjs index 3a0e41e5..c09139c9 100644 --- a/scripts/src/dev-runtime-migration.test.mjs +++ b/scripts/src/dev-runtime-migration.test.mjs @@ -55,7 +55,6 @@ test("live dry-run separates migration ledger blocker from auth and schema", asy parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), { env: { - HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", HWLAB_CLOUD_DB_SSL_MODE: "disable" }, queryClient: createFakeQueryClient({ migrationReady: false }), @@ -79,7 +78,6 @@ test("live dry-run separates SSL negotiation blocker from auth and schema", asyn parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), { env: { - HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", HWLAB_CLOUD_DB_SSL_MODE: "require" }, queryClient: { @@ -105,7 +103,41 @@ test("live dry-run separates SSL negotiation blocker from auth and schema", asyn assert.equal(report.blockers[0].evidence.blocker, "runtime_durable_adapter_ssl_blocked"); assert.equal(report.runtime.blocker, "runtime_durable_adapter_ssl_blocked"); assert.equal(report.runtime.connection.queryResult, "ssl_negotiation_blocked"); - assert.equal(JSON.stringify(report).includes("redacted@example.invalid"), false); + assert.equal(JSON.stringify(report).includes("The server does not support SSL connections"), false); +}); + +test("live dry-run classifies pg_hba no-encryption rejection as SSL with ssl disabled", async () => { + const report = await buildDevRuntimeMigrationReport( + parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]), + { + env: { + HWLAB_CLOUD_DB_SSL_MODE: "disable" + }, + queryClient: { + async query(sql) { + assert.match(sql, /information_schema\.columns/u); + const error = new Error('no pg_hba.conf entry for host "[redacted]", user "[redacted]", database "[redacted]", no encryption'); + error.code = "28000"; + throw error; + } + }, + now: () => "2026-05-22T00:00:00.000Z" + } + ); + + assert.equal(report.conclusion.status, "blocked"); + assert.equal(report.actions.liveDbReadAttempted, true); + assert.equal(report.actions.liveDbWriteAttempted, false); + assert.equal(report.gates.auth.status, "not_checked"); + assert.equal(report.gates.schema.status, "not_checked"); + assert.equal(report.gates.migration.status, "not_checked"); + assert.equal(report.gates.readiness.status, "not_checked"); + assert.equal(report.blockers[0].scope, "runtime-migration-ssl"); + assert.equal(report.blockers[0].evidence.blocker, "runtime_durable_adapter_ssl_blocked"); + assert.equal(report.runtime.blocker, "runtime_durable_adapter_ssl_blocked"); + assert.equal(report.runtime.connection.queryResult, "ssl_negotiation_blocked"); + assert.equal(report.runtime.connection.errorCode, "28000"); + assert.equal(JSON.stringify(report).includes("pg_hba.conf"), false); }); test("apply mode records ledger and verifies durable runtime readiness", async () => {