diff --git a/.github/workflows/ops-digest.yml b/.github/workflows/ops-digest.yml index 40423dc15d..4ab355f42d 100644 --- a/.github/workflows/ops-digest.yml +++ b/.github/workflows/ops-digest.yml @@ -2,7 +2,8 @@ # hit-rate, answer spend, degraded/truncation rates) into a one-screen summary # and keeps it in a rolling GitHub issue, commenting only when something is off. # -# The repo variable and matching deployment/GitHub secret are provisioned. +# The repo variable and matching deployment/GitHub secret must both be +# provisioned. The preflight below fails closed when either name is absent. # Keep workflow_dispatch for operator verification and the daily schedule for # the normal morning digest. name: Ops Digest diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md index 31d9ec96ac..d1ab264804 100644 --- a/docs/operator-backlog.md +++ b/docs/operator-backlog.md @@ -42,13 +42,14 @@ Findings inventory for handover: [audit-handover-2026-07-14.md](audit-handover-2 Each environment gets **separate** service-role + OpenAI keys (per-env blast radius). Placement is a dashboard/CLI action, never committed. -| Secret / config | Status | Where | Notes | -| ------------------------------------------ | ---------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RAG_QUERY_HASH_SECRET` (prod) | 🔎 verify | Railway runtime secret | GitHub repo secret present since 2026-07-10 (CI green); confirm the SAME value is set in Railway runtime. PIA-2 fail-closed guard requires it at boot (min 16 chars) | -| `HEALTH_DEEP_PROBE_SECRET` (prod + GitHub) | ⚠️ partial | Railway runtime + GitHub repo secret | Railway production was set and the authorized deep probe returned healthy on 2026-07-19. GitHub remains pending: set the same value as a repo secret, set `PROD_HEALTH_URL`, then enable the ops-digest schedule. | -| `SUPABASE_SERVICE_ROLE_KEY` (per env) | ⏳ pending | Railway runtime secret | accepts the `sb_secret_…` key | -| `OPENAI_API_KEY` (per env) | ⏳ pending | Railway runtime secret | `RAG_PROVIDER_MODE=auto` | -| OpenAI DPA / ZDR execution | ⏳ pending | OpenAI account + legal | app endpoints are ZDR-eligible; execution is operator + legal — see [openai-cross-border-basis.md](openai-cross-border-basis.md) | +| Secret / config | Status | Where | Notes | +| ------------------------------------------ | ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `RAG_QUERY_HASH_SECRET` (prod) | ✅ present | Railway runtime + GitHub repo secret | Names-only parity verified both stores on 2026-07-25. Secret stores do not expose comparable values; do not rotate solely to prove equality because that would break query-pseudonym continuity. | +| `HEALTH_DEEP_PROBE_SECRET` (prod + GitHub) | ✅ present | Railway runtime + GitHub repo secret | Names-only parity and `PROD_HEALTH_URL` presence verified on 2026-07-25; the last authorized deep probe was healthy on 2026-07-19. Values were not exposed or compared. | +| `OPENAI_SAFETY_IDENTIFIER_SECRET` (prod) | ✅ stored | Railway runtime secret | A distinct 256-bit production value was stored on 2026-07-25 with deployment intentionally skipped. It activates on the next normal `Database` deployment. | +| `SUPABASE_SERVICE_ROLE_KEY` (per env) | ⏳ pending | Railway runtime secret | accepts the `sb_secret_…` key | +| `OPENAI_API_KEY` (per env) | ⏳ pending | Railway runtime secret | `RAG_PROVIDER_MODE=auto` | +| OpenAI DPA / ZDR execution | ⏳ pending | OpenAI account + legal | app endpoints are ZDR-eligible; execution is operator + legal — see [openai-cross-border-basis.md](openai-cross-border-basis.md) | ## Disaster-recovery re-creation (does NOT survive a schema restore) diff --git a/scripts/check-env-parity.mjs b/scripts/check-env-parity.mjs index 0e7869324c..3a56f853ed 100644 --- a/scripts/check-env-parity.mjs +++ b/scripts/check-env-parity.mjs @@ -8,20 +8,26 @@ * runtime vars. A name present in one place but missing in another has broken main * CI before (e.g. RAG_QUERY_HASH_SECRET). This diffs the name sets and reports gaps. * - * Offline by default (parses env.ts + check-ci-env.mjs only). Live sources are + * Offline by default (parses env.ts, .env.example, and check-ci-env.mjs). Live sources are * opt-in and names-only: * --gh run `gh secret list` (names only; values are write-only anyway) - * --railway run `railway variables` (names only) if the CLI is available + * --railway inspect the pinned production project/environment and both services + * --local report the current process + local env files as NAME/PRESENT/MISSING * * Never prints a value. Exit 1 only when a hard parity problem is found (an * expected secret is absent from a queried live source), else 0. */ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const githubRepository = "BigSimmo/Database"; +const railwayTarget = { + project: "5deaad0b-675a-4c13-978e-5ca2b5b877f9", + environment: "6aa16f7b-d3e8-4aa2-9854-ee9ead9fcbd4", +}; // Vars that MUST be supplied as deployment/CI secrets (never committed). Each is // asserted to exist in the canonical name set below, so this list cannot silently @@ -38,13 +44,47 @@ export const EXPECTED_GITHUB_SECRETS = [ export const EXPECTED_RAILWAY_SECRETS = [ "SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY", + "OPENAI_SAFETY_IDENTIFIER_SECRET", "RAG_QUERY_HASH_SECRET", "HEALTH_DEEP_PROBE_SECRET", ]; -/** Zod schema keys from env.ts: lines shaped like ` NAME: z.…`. */ +export const EXPECTED_RAILWAY_APP_VARIABLES = [ + "NEXT_PUBLIC_SUPABASE_URL", + "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", + "SUPABASE_PROJECT_REF", + "SUPABASE_PROJECT_NAME", + ...EXPECTED_RAILWAY_SECRETS, +]; + +export const EXPECTED_RAILWAY_WORKER_VARIABLES = [ + "NEXT_PUBLIC_SUPABASE_URL", + "SUPABASE_PROJECT_REF", + "SUPABASE_PROJECT_NAME", + "SUPABASE_SERVICE_ROLE_KEY", + "OPENAI_API_KEY", +]; + +export const EXPECTED_GITHUB_VARIABLES = ["PROD_HEALTH_URL"]; + +export const LOCAL_PRESENCE_NAMES = [ + ...EXPECTED_RAILWAY_APP_VARIABLES, + "SUPABASE_STAGING_PROJECT_REF", + "SUPABASE_STAGING_PROJECT_NAME", + "RAG_PROVIDER_MODE", + "E2E_USER_EMAIL", + "E2E_USER_PASSWORD", +]; + +// E2E credentials are CI-only and are documented by check-ci-env.mjs itself. +// The committed app/deployment example must cover every other presence key. +export const EXPECTED_ENV_EXAMPLE_NAMES = LOCAL_PRESENCE_NAMES.filter((name) => !name.startsWith("E2E_USER_")); + +const localEnvFiles = [".env", ".env.local", ".env.development.local"]; + +/** Zod schema keys from env.ts, including declarations where `.enum` starts on the next line. */ export function parseEnvSchemaNames(envTsText) { - return [...envTsText.matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*:\s*z\./gm)].map((m) => m[1]); + return [...envTsText.matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*:\s*z\s*\./gm)].map((m) => m[1]); } /** UPPER_SNAKE names referenced in check-ci-env.mjs (quoted literals + process.env.X). */ @@ -55,6 +95,11 @@ export function parseCiEnvNames(ciEnvText) { return [...names]; } +/** Active or commented KEY= declarations from the committed example file. */ +export function parseEnvExampleNames(envExampleText) { + return [...envExampleText.matchAll(/^\s*#?\s*([A-Z][A-Z0-9_]*)\s*=/gm)].map((match) => match[1]); +} + /** Pure diff of live secret names against expectations + the known-name universe. */ export function computeParity({ canonical, liveNames, expectedSecrets }) { const canon = new Set(canonical); @@ -65,6 +110,42 @@ export function computeParity({ canonical, liveNames, expectedSecrets }) { }; } +function isConfigured(value) { + const normalized = String(value ?? "").trim(); + return Boolean(normalized) && !/(?:^your-|replace-with|placeholder|<[^>]+>)/i.test(normalized); +} + +/** Convert values to a names-only report. Raw values are never returned. */ +export function presenceRows(values, names = LOCAL_PRESENCE_NAMES) { + return names.map((name) => ({ name, status: isConfigured(values[name]) ? "PRESENT" : "MISSING" })); +} + +/** Parse an env file directly into a names-only presence report. */ +export function parseEnvFilePresence(text, names = LOCAL_PRESENCE_NAMES) { + const values = {}; + for (const rawLine of text.split(/\r?\n/)) { + if (rawLine.trimStart().startsWith("#")) continue; + const match = rawLine.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match || !names.includes(match[1])) continue; + values[match[1]] = match[2].trim().replace(/^(['"])(.*)\1$/, "$2"); + } + return presenceRows(values, names); +} + +export function railwayVariableArgs(service) { + return [ + "variable", + "list", + "--json", + "--project", + railwayTarget.project, + "--environment", + railwayTarget.environment, + "--service", + service, + ]; +} + /** Extract Railway variable names from the CLI's JSON object without exposing values. */ export function parseRailwayVariableNames(raw) { const parsed = JSON.parse(raw); @@ -75,25 +156,64 @@ export function parseRailwayVariableNames(raw) { } function ghSecretNames() { - const raw = execFileSync("gh", ["secret", "list", "--json", "name"], { encoding: "utf8" }); + const raw = execFileSync("gh", ["secret", "list", "--repo", githubRepository, "--json", "name"], { + encoding: "utf8", + }); return JSON.parse(raw).map((s) => s.name); } -function railwayVarNames() { - const raw = execFileSync("railway", ["variable", "list", "--json"], { encoding: "utf8" }); +function ghVariableNames() { + const raw = execFileSync("gh", ["variable", "list", "--repo", githubRepository, "--json", "name"], { + encoding: "utf8", + }); + return JSON.parse(raw).map((variable) => variable.name); +} + +function railwayVarNames(service) { + // Railway has no names-only CLI format. Capture JSON in memory, immediately + // reduce it to Object.keys(), and never emit or persist the raw response. + const raw = execFileSync("railway", railwayVariableArgs(service), { encoding: "utf8" }); return parseRailwayVariableNames(raw); } +function printLocalPresence() { + console.log("\nLocal process environment (names/status only):"); + for (const row of presenceRows(process.env)) console.log(` ${row.name}\t${row.status}`); + + for (const fileName of localEnvFiles) { + const filePath = path.join(root, fileName); + console.log(`\n${fileName} (names/status only):`); + const rows = existsSync(filePath) + ? parseEnvFilePresence(readFileSync(filePath, "utf8")) + : LOCAL_PRESENCE_NAMES.map((name) => ({ name, status: "MISSING" })); + for (const row of rows) console.log(` ${row.name}\t${row.status}`); + } +} + function main() { const useGh = process.argv.includes("--gh"); const useRailway = process.argv.includes("--railway"); + const useLocal = process.argv.includes("--local"); const envTs = readFileSync(path.join(root, "src/lib/env.ts"), "utf8"); + const envExample = readFileSync(path.join(root, ".env.example"), "utf8"); const ciEnv = readFileSync(path.join(root, "scripts/check-ci-env.mjs"), "utf8"); - const canonical = new Set([...parseEnvSchemaNames(envTs), ...parseCiEnvNames(ciEnv)]); + const envExampleConfigNames = parseEnvExampleNames(envExample); + const canonical = new Set([ + ...parseEnvSchemaNames(envTs), + ...parseCiEnvNames(ciEnv), + ...envExampleConfigNames, + ...EXPECTED_GITHUB_VARIABLES, + ]); const problems = []; + const envExampleNames = new Set(envExampleConfigNames); + const missingFromExample = EXPECTED_ENV_EXAMPLE_NAMES.filter((name) => !envExampleNames.has(name)); + if (missingFromExample.length > 0) { + problems.push(`Required config names missing from .env.example: ${missingFromExample.join(", ")}`); + } + // Self-consistency: every expected secret must be a name the app/CI actually knows. const expectedSecrets = new Set([...EXPECTED_GITHUB_SECRETS, ...EXPECTED_RAILWAY_SECRETS]); const unknownExpected = [...expectedSecrets].filter((name) => !canonical.has(name)); @@ -103,13 +223,31 @@ function main() { ); } - console.log(`Known env names: ${canonical.size} (env.ts schema + check-ci-env).`); + console.log(`Known config names: ${canonical.size} (env.ts schema + CI/ops config + .env.example).`); + console.log( + `Required .env.example names: ${EXPECTED_ENV_EXAMPLE_NAMES.length - missingFromExample.length}/${EXPECTED_ENV_EXAMPLE_NAMES.length}.`, + ); console.log(`Expected GitHub secrets: ${EXPECTED_GITHUB_SECRETS.join(", ")}`); + console.log(`Expected GitHub variables: ${EXPECTED_GITHUB_VARIABLES.join(", ")}`); console.log(`Expected Railway secrets: ${EXPECTED_RAILWAY_SECRETS.join(", ")}`); for (const [flag, enabled, label, getter, sourceExpectedSecrets] of [ ["--gh", useGh, "GitHub secrets", ghSecretNames, EXPECTED_GITHUB_SECRETS], - ["--railway", useRailway, "Railway variables", railwayVarNames, EXPECTED_RAILWAY_SECRETS], + ["--gh", useGh, "GitHub variables", ghVariableNames, EXPECTED_GITHUB_VARIABLES], + [ + "--railway", + useRailway, + "Railway app variables", + () => railwayVarNames("Database"), + EXPECTED_RAILWAY_APP_VARIABLES, + ], + [ + "--railway", + useRailway, + "Railway worker variables", + () => railwayVarNames("worker"), + EXPECTED_RAILWAY_WORKER_VARIABLES, + ], ]) { if (!enabled) { console.log(`(${label}: skipped — pass ${flag} to check; names only, no values)`); @@ -130,16 +268,21 @@ function main() { console.log(`\n${label}: ${liveNames.length} names.`); if (missingSecrets.length > 0) problems.push(`${label}: missing expected secret(s): ${missingSecrets.join(", ")}`); if (unknownLive.length > 0) { - console.log(` ⚠ present but not in env.ts (possible stale/typo): ${unknownLive.join(", ")}`); + console.log( + ` ⚠ outside the checked app/config contract (may be provider- or workflow-managed): ${unknownLive.join(", ")}`, + ); } } + if (useLocal) printLocalPresence(); + else console.log("(Local presence: skipped — pass --local; names/status only)"); + if (problems.length > 0) { console.error("\nEnv parity problems:"); for (const p of problems) console.error(`- ${p}`); process.exit(1); } - console.log("\nEnv parity OK (names only; no values were read)."); + console.log("\nEnv parity OK (names/status only; no values were emitted or persisted)."); } const invokedDirectly = process.argv[1]?.endsWith("check-env-parity.mjs"); diff --git a/tests/repo-hygiene.test.ts b/tests/repo-hygiene.test.ts index 515cc4ee5f..4ec0e13ae1 100644 --- a/tests/repo-hygiene.test.ts +++ b/tests/repo-hygiene.test.ts @@ -1,10 +1,17 @@ import { describe, expect, it } from "vitest"; import { computeParity, + EXPECTED_GITHUB_VARIABLES, EXPECTED_GITHUB_SECRETS, + EXPECTED_RAILWAY_APP_VARIABLES, EXPECTED_RAILWAY_SECRETS, + EXPECTED_RAILWAY_WORKER_VARIABLES, parseCiEnvNames, + parseEnvExampleNames, + parseEnvFilePresence, parseEnvSchemaNames, + presenceRows, + railwayVariableArgs, } from "../scripts/check-env-parity.mjs"; import { hasCompletedCleanupReview, parseLedgerBranches } from "../scripts/sweep-branch-ledger.mjs"; @@ -15,6 +22,9 @@ describe("check-env-parity name parsing", () => { " NEXT_PUBLIC_SUPABASE_URL: z.string().url().optional(),", " SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),", " OPENAI_MAX_OUTPUT_TOKENS: z.coerce.number().default(16000),", + " RAG_PERSIST_RAW_QUERY_TEXT: z", + ' .enum(["true", "false"])', + ' .default("false"),', " notAKey: 3,", "});", ].join("\n"); @@ -22,6 +32,7 @@ describe("check-env-parity name parsing", () => { expect(names).toContain("NEXT_PUBLIC_SUPABASE_URL"); expect(names).toContain("SUPABASE_SERVICE_ROLE_KEY"); expect(names).toContain("OPENAI_MAX_OUTPUT_TOKENS"); + expect(names).toContain("RAG_PERSIST_RAW_QUERY_TEXT"); expect(names).not.toContain("notAKey"); }); @@ -31,6 +42,13 @@ describe("check-env-parity name parsing", () => { expect(names).toEqual(expect.arrayContaining(["E2E_USER_EMAIL", "E2E_USER_PASSWORD", "E2E_AUTH_ENABLED"])); }); + it("extracts active and documented optional names from .env.example-style text", () => { + const names = parseEnvExampleNames( + ["OPENAI_API_KEY=replace-with-key", "#OPENAI_SAFETY_IDENTIFIER_SECRET=", "# explanation"].join("\n"), + ); + expect(names).toEqual(["OPENAI_API_KEY", "OPENAI_SAFETY_IDENTIFIER_SECRET"]); + }); + it("reports missing expected secrets and unknown live names", () => { const parity = computeParity({ canonical: ["OPENAI_API_KEY", "SUPABASE_SERVICE_ROLE_KEY"], @@ -49,12 +67,73 @@ describe("check-env-parity name parsing", () => { expect.arrayContaining([ "SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY", + "OPENAI_SAFETY_IDENTIFIER_SECRET", "RAG_QUERY_HASH_SECRET", "HEALTH_DEEP_PROBE_SECRET", ]), ); expect(EXPECTED_RAILWAY_SECRETS).not.toEqual(expect.arrayContaining(["E2E_USER_EMAIL", "E2E_USER_PASSWORD"])); }); + + it("covers hosted project identity, runtime privacy, and scheduled health config", () => { + expect(EXPECTED_RAILWAY_APP_VARIABLES).toEqual( + expect.arrayContaining([ + "NEXT_PUBLIC_SUPABASE_URL", + "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", + "SUPABASE_PROJECT_REF", + "SUPABASE_PROJECT_NAME", + "OPENAI_SAFETY_IDENTIFIER_SECRET", + ]), + ); + expect(EXPECTED_RAILWAY_WORKER_VARIABLES).toEqual( + expect.arrayContaining([ + "NEXT_PUBLIC_SUPABASE_URL", + "SUPABASE_PROJECT_REF", + "SUPABASE_PROJECT_NAME", + "SUPABASE_SERVICE_ROLE_KEY", + "OPENAI_API_KEY", + ]), + ); + expect(EXPECTED_GITHUB_VARIABLES).toContain("PROD_HEALTH_URL"); + }); + + it("reduces process and env-file values to PRESENT/MISSING without retaining values", () => { + const secretSentinel = "do-not-include-this-value-in-the-report"; + const processReport = presenceRows( + { + OPENAI_API_KEY: secretSentinel, + RAG_QUERY_HASH_SECRET: "replace-with-query-hash-secret", + }, + ["OPENAI_API_KEY", "RAG_QUERY_HASH_SECRET"], + ); + const fileReport = parseEnvFilePresence(`OPENAI_API_KEY=${secretSentinel}\nRAG_QUERY_HASH_SECRET=\n`, [ + "OPENAI_API_KEY", + "RAG_QUERY_HASH_SECRET", + ]); + + expect(processReport).toEqual([ + { name: "OPENAI_API_KEY", status: "PRESENT" }, + { name: "RAG_QUERY_HASH_SECRET", status: "MISSING" }, + ]); + expect(fileReport).toEqual([ + { name: "OPENAI_API_KEY", status: "PRESENT" }, + { name: "RAG_QUERY_HASH_SECRET", status: "MISSING" }, + ]); + expect(JSON.stringify({ processReport, fileReport })).not.toContain(secretSentinel); + }); + + it("pins Railway reads to the production project, environment, and named service", () => { + expect(railwayVariableArgs("Database")).toEqual( + expect.arrayContaining([ + "--project", + "5deaad0b-675a-4c13-978e-5ca2b5b877f9", + "--environment", + "6aa16f7b-d3e8-4aa2-9854-ee9ead9fcbd4", + "--service", + "Database", + ]), + ); + }); }); describe("sweep-branch-ledger parsing", () => {