diff --git a/docs/cloud-api-runtime.md b/docs/cloud-api-runtime.md index f5018391..afce68af 100644 --- a/docs/cloud-api-runtime.md +++ b/docs/cloud-api-runtime.md @@ -17,8 +17,9 @@ and verifies: - `GET /health` reports `status: "degraded"` when live DB persistence is not connected. - The DB contract reports required env names, redacted Secret refs, env - injection, connection attempt status, connection result, and `liveConnected` - without exposing DB URL or secret values. + injection, connection attempt status, connection result, `liveConnected`, + `liveDbEvidence`, and redaction/safety fields without exposing DB URL or + secret values. - `gateway.session.register`, `box.resource.register`, `box.capability.report`, `hardware.invoke.shell`, `audit.event.query`, and `evidence.record.query` round-trip through the same runtime store. @@ -45,10 +46,15 @@ keeps all endpoint details redacted and reports only: - `db.connectionResult`: redacted classifier such as `connected`, `refused`, `timeout`, `dns_error`, `invalid_url`, or `unsupported_protocol`. - `db.liveConnected`: the TCP connection completed. +- `db.liveDbEvidence`: true only when the live connection completed; env + presence, fixtures, and disabled probes must keep this false. +- `db.redaction` and `db.safety`: confirm values/endpoints are redacted and + secret material was not read by the health path. - `db.ready`: currently aliases `db.liveConnected`. Env presence alone is not treated as green. If the runtime has env injection -but `db.connectionAttempted` is false or `db.liveConnected` is false, the gate -must stay blocked/degraded with the reported blocker. +but `db.connectionAttempted` is false, `db.liveConnected` is false, or +`db.liveDbEvidence` is false, the gate must stay blocked/degraded with the +reported blocker. Fixture: `fixtures/cloud-api-runtime/minimal-runtime.json`. diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index 21f56a63..4eb4099c 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -53,6 +53,7 @@ export function buildDbHealthContract(env = process.env) { environment: DEV_DB_ENV_CONTRACT.environment, connected, liveConnected: connected, + liveDbEvidence: connected, connectionChecked, connectionAttempted: connection.attempted, connectionResult: connection.result, @@ -73,12 +74,16 @@ export function buildDbHealthContract(env = process.env) { redacted: true })), connection, + redaction: buildRedactionSafety(connection), safety: { devOnly: true, prodAllowed: DEV_DB_ENV_CONTRACT.forbidden.prodAllowed, secretsRead: false, + secretMaterialRead: false, valuesRedacted: true, - liveDbEvidence: false + endpointRedacted: true, + liveDbEvidence: connected, + liveDbConnectedEvidence: connected }, blocker: configReady ? "live DB connection has not been attempted by this health contract" : missingEnvBlocker(missingEnv), evidence: configReady ? "env_presence_only_no_live_db" : "env_contract_blocked" @@ -125,6 +130,7 @@ export function summarizeDbContract(db = buildDbHealthContract()) { connectionChecked: db.connectionChecked, connectionAttempted: db.connectionAttempted ?? db.connectionChecked, connectionResult: db.connectionResult ?? db.connection?.result ?? "unknown", + liveDbEvidence: Boolean(db.liveDbEvidence ?? db.safety?.liveDbEvidence), requiredEnv: db.fields.map((field) => ({ name: field.name, present: field.present, @@ -154,8 +160,8 @@ export function summarizeDbContract(db = buildDbHealthContract()) { }) ), connection: summarizeConnection(db.connection), + redaction: summarizeRedaction(db.redaction), blocker: db.blocker ?? null, - liveDbEvidence: db.safety.liveDbEvidence, fixtureEvidence: false }; } @@ -184,6 +190,16 @@ function buildNotAttemptedConnection(configReady, missingEnv) { }; } +function buildRedactionSafety(connection) { + return { + valuesRedacted: true, + endpointRedacted: connection?.endpointRedacted !== false, + valueRedacted: connection?.valueRedacted !== false, + secretMaterialRead: false, + secretRefsOnly: true + }; +} + async function probeDbConnection(env, options) { const timeoutMs = normalizeTimeoutMs(options.timeoutMs ?? env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS); const parsed = parseDbTarget(env?.HWLAB_CLOUD_DB_URL); @@ -339,6 +355,7 @@ function applyConnectionResult(base, connection) { ...base, connected: liveConnected, liveConnected, + liveDbEvidence: liveConnected, connectionChecked: connection.attempted, connectionAttempted: connection.attempted, connectionResult: connection.result, @@ -346,9 +363,11 @@ function applyConnectionResult(base, connection) { status: liveConnected ? "ready" : "degraded", mode: liveConnected ? "live_connection_ready" : "live_connection_blocked", connection, + redaction: buildRedactionSafety(connection), safety: { ...base.safety, - liveDbEvidence: connection.attempted, + endpointRedacted: connection.endpointRedacted !== false, + liveDbEvidence: liveConnected, liveDbConnectedEvidence: liveConnected }, blocker: liveConnected ? null : blockerForConnection(connection), @@ -412,3 +431,13 @@ function summarizeConnection(connection) { errorCode: connection.errorCode ?? null }; } + +function summarizeRedaction(redaction) { + return { + valuesRedacted: redaction?.valuesRedacted !== false, + endpointRedacted: redaction?.endpointRedacted !== false, + valueRedacted: redaction?.valueRedacted !== false, + secretMaterialRead: redaction?.secretMaterialRead === true, + secretRefsOnly: redaction?.secretRefsOnly !== false + }; +} diff --git a/internal/cloud/json-rpc.test.mjs b/internal/cloud/json-rpc.test.mjs index 22439898..a4ed0c0e 100644 --- a/internal/cloud/json-rpc.test.mjs +++ b/internal/cloud/json-rpc.test.mjs @@ -60,6 +60,13 @@ test("system.health includes the redacted DEV DB env contract", async () => { assert.equal(response.result.status, "degraded"); assert.equal(response.result.db.status, "blocked"); assert.equal(response.result.db.connected, false); + assert.equal(response.result.db.liveConnected, false); + assert.equal(response.result.db.liveDbEvidence, false); + assert.equal(response.result.db.connectionAttempted, false); + assert.equal(response.result.db.connectionResult, "not_attempted_missing_env"); + assert.equal(response.result.db.redaction.valuesRedacted, true); + assert.equal(response.result.db.redaction.secretMaterialRead, false); + assert.equal(response.result.db.safety.liveDbEvidence, false); assert.equal(response.result.runtime.durable, false); assert.ok(response.result.db.missingEnv.includes("HWLAB_CLOUD_DB_URL")); assert.equal(response.result.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 63707e5e..409f22b3 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -28,8 +28,12 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.db.status, "blocked"); assert.equal(healthPayload.db.connected, false); assert.equal(healthPayload.db.liveConnected, false); + assert.equal(healthPayload.db.liveDbEvidence, false); assert.equal(healthPayload.db.connectionAttempted, false); assert.equal(healthPayload.db.connectionResult, "not_attempted_missing_env"); + assert.equal(healthPayload.db.redaction.valuesRedacted, true); + assert.equal(healthPayload.db.redaction.secretMaterialRead, false); + assert.equal(healthPayload.db.safety.liveDbEvidence, false); assert.equal(healthPayload.runtime.durable, false); assert.equal(healthPayload.runtime.status, "degraded"); assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); @@ -88,12 +92,18 @@ test("cloud api health reports DB env presence and live connection classificatio assert.equal(payload.db.status, "ready"); assert.equal(payload.db.connected, true); assert.equal(payload.db.liveConnected, true); + assert.equal(payload.db.liveDbEvidence, true); assert.equal(payload.db.connectionChecked, true); assert.equal(payload.db.connectionAttempted, true); assert.equal(payload.db.connectionResult, "connected"); assert.equal(payload.db.configReady, true); assert.equal(payload.db.ready, true); assert.equal(payload.db.evidence, "live_db_tcp_connection_ready"); + assert.equal(payload.db.safety.liveDbEvidence, true); + assert.equal(payload.db.safety.liveDbConnectedEvidence, true); + assert.equal(payload.db.redaction.valuesRedacted, true); + assert.equal(payload.db.redaction.endpointRedacted, true); + assert.equal(payload.db.redaction.secretMaterialRead, false); assert.deepEqual(payload.db.missingEnv, []); assert.equal(JSON.stringify(payload.db).includes("password"), false); assert.equal(JSON.stringify(payload.db).includes("127.0.0.1"), false); @@ -119,3 +129,52 @@ test("cloud api health reports DB env presence and live connection classificatio }); } }); + +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; + const originalProbeDisabled = process.env.HWLAB_CLOUD_DB_PROBE_DISABLED; + process.env.HWLAB_CLOUD_DB_URL = "postgres://user:password@db.example.invalid:5432/hwlab"; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; + process.env.HWLAB_CLOUD_DB_PROBE_DISABLED = "1"; + + const server = createCloudApiServer(); + 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.db.configReady, true); + assert.equal(payload.db.connected, false); + assert.equal(payload.db.liveConnected, false); + assert.equal(payload.db.liveDbEvidence, false); + assert.equal(payload.db.safety.liveDbEvidence, false); + assert.equal(payload.db.ready, false); + assert.equal(payload.db.connectionAttempted, false); + assert.equal(payload.db.connectionResult, "not_attempted"); + assert.equal(payload.db.evidence, "env_presence_only_probe_disabled"); + assert.equal(JSON.stringify(payload.db).includes("password"), false); + assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false); + } 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; + } + if (originalProbeDisabled === undefined) { + delete process.env.HWLAB_CLOUD_DB_PROBE_DISABLED; + } else { + process.env.HWLAB_CLOUD_DB_PROBE_DISABLED = originalProbeDisabled; + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); diff --git a/scripts/cloud-api-runtime-smoke.mjs b/scripts/cloud-api-runtime-smoke.mjs index c9d47ed2..2c2c3091 100644 --- a/scripts/cloud-api-runtime-smoke.mjs +++ b/scripts/cloud-api-runtime-smoke.mjs @@ -87,6 +87,7 @@ async function run() { assert.equal(health.body.db.status, "blocked"); assert.equal(health.body.db.connected, false); assert.equal(health.body.db.liveConnected, false); + assert.equal(health.body.db.liveDbEvidence, false); assert.equal(health.body.db.connectionAttempted, false); assert.equal(health.body.db.connectionResult, "not_attempted_missing_env"); assert.equal(health.body.db.ready, false); diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index f8f5a65e..346e37b9 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -154,6 +154,7 @@ async function smokeCloudApi() { assert.equal(health.body.status, "degraded"); assert.equal(health.body.db.connected, false); assert.equal(health.body.db.liveConnected, false); + assert.equal(health.body.db.liveDbEvidence, false); assert.equal(health.body.db.connectionAttempted, false); assert.equal(health.body.db.connectionResult, "not_attempted_missing_env"); assert.equal(health.body.db.status, "blocked"); diff --git a/scripts/src/dev-edge-health-smoke-lib.mjs b/scripts/src/dev-edge-health-smoke-lib.mjs index 5c8b1748..cf644b63 100644 --- a/scripts/src/dev-edge-health-smoke-lib.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.mjs @@ -394,9 +394,9 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc connectionAttempted, connectionResult, liveConnected, - valuesRedacted: db.safety?.valuesRedacted === true, + valuesRedacted: db.redaction?.valuesRedacted !== false && db.safety?.valuesRedacted === true, secretMaterialRead: false, - liveDbEvidence: Boolean(db.safety?.liveDbEvidence), + liveDbEvidence: Boolean(db.liveDbEvidence ?? db.safety?.liveDbEvidence), blocker: blockerMessage, evidence: [ {