From b7801be9be67f8d6b70bf9a3a657832a9204ef8a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:33:39 +0800 Subject: [PATCH 1/3] privacy: enforce query-hash secret in prod + purge rag_query_misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two low-risk hardening fixes from the privacy impact assessment (PIA-2, PIA-4). PIA-2 — RAG_QUERY_HASH_SECRET is now required at production startup. Without it, logged clinical-query hashes silently degrade from keyed HMAC-SHA256 to unsalted, dictionary-reversible SHA-256, defeating the redaction. requireQueryHashSecret() (src/lib/env.ts) is called from instrumentation.register(), which already fails closed on demo/no-auth/misconfig; unit-tested in tests/env-query-hash-secret.test.ts. PIA-4 — add a nightly pg_cron purge for rag_query_misses (90 days), which stored the same hash-redacted query telemetry as rag_queries/rag_retrieval_logs but had no retention bound. New migration only; schema.sql + drift-manifest are regenerated from live via `npm run drift:manifest` (Docker) as the usual follow-up. Not included: the fail-closed retrieval_owner_matches change (tenancy review §6 item 1) — it sits on the retrieval hot path with ~20 RPC sites and needs the live golden-eval to verify, which can't run without the Supabase key. Held as a separate, verified change. Verified: typecheck, eslint, prettier, and the relevant vitest suites (env, privacy, owner-scope, supabase-schema, drift-detection, check-runtime, api-route-coverage) pass. Live cron scheduling is verifiable only against the live project. Co-Authored-By: Claude Fable 5 --- src/instrumentation.ts | 6 +- src/lib/env.ts | 14 +++++ ...60708120000_rag_query_misses_retention.sql | 55 +++++++++++++++++++ tests/env-query-hash-secret.test.ts | 29 ++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260708120000_rag_query_misses_retention.sql create mode 100644 tests/env-query-hash-secret.test.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 58c82ad0c..1b22a6ccd 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -16,7 +16,7 @@ export async function register() { throw new Error("Refusing to start: local no-auth mode is enabled in a production build."); } - const { isDemoMode, requireOpenAIEnv, requireServerEnv } = await import("@/lib/env"); + const { isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireServerEnv } = await import("@/lib/env"); // A clinical production server must run against real, configured backends — never // in demo mode, which bypasses auth and serves canned content. @@ -30,4 +30,8 @@ export async function register() { // is missing or points at the wrong project, instead of failing per-request. requireServerEnv(); requireOpenAIEnv(); + + // A keyed HMAC secret must be present so clinical-query hashes written to the log + // tables are not reversible (PIA-2). Fail closed rather than degrade to weak SHA-256. + requireQueryHashSecret(); } diff --git a/src/lib/env.ts b/src/lib/env.ts index 19fcfcab4..d802d6657 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -185,6 +185,20 @@ export function requireOpenAIEnv() { } } +// Clinical query text is redacted to a keyed HMAC pseudonym before it is logged +// (see query-privacy.ts). Without RAG_QUERY_HASH_SECRET the hash silently degrades +// to an unsalted, dictionary-reversible SHA-256, which defeats the redaction: a +// reader of the log tables can hash candidate patient/drug strings offline and match +// rows. Production must fail closed rather than log real clinical queries under the +// weak digest. See docs/privacy-impact-assessment.md (PIA-2). +export function requireQueryHashSecret() { + if (!env.RAG_QUERY_HASH_SECRET) { + throw new Error( + "Missing RAG_QUERY_HASH_SECRET. It is required in production so logged clinical-query hashes are keyed HMAC-SHA256 pseudonyms, not offline-reversible SHA-256. Set a random secret (min 16 chars). See docs/privacy-impact-assessment.md (PIA-2).", + ); + } +} + export function isDemoMode() { // Explicit opt-in is honored in every environment (e.g. a deliberate demo deploy). if (env.NEXT_PUBLIC_DEMO_MODE === "true") { diff --git a/supabase/migrations/20260708120000_rag_query_misses_retention.sql b/supabase/migrations/20260708120000_rag_query_misses_retention.sql new file mode 100644 index 000000000..723020bcf --- /dev/null +++ b/supabase/migrations/20260708120000_rag_query_misses_retention.sql @@ -0,0 +1,55 @@ +-- PIA-4 (docs/privacy-impact-assessment.md): rag_query_misses stores the same +-- hash-redacted query + candidate aliases as rag_queries, but unlike rag_queries +-- (30d) and rag_retrieval_logs (90d) it had NO retention bound, so rows +-- accumulated indefinitely. Add a matching nightly purge (90 days — wider than +-- rag_queries to leave headroom for the miss-review workflow, still bounding +-- long-lived query telemetry). Query text is already hash-redacted at write time +-- via queryTextForStorage, so this bounds redacted telemetry, not raw PHI. +set search_path = public, pg_catalog, pg_temp; + +create or replace function public.purge_expired_rag_query_misses(p_retention_days integer default 90) +returns integer +language plpgsql +security definer +set search_path = public, pg_catalog, pg_temp +as $$ +declare + v_deleted integer; +begin + if p_retention_days < 1 then + raise exception 'retention days must be positive'; + end if; + delete from public.rag_query_misses where created_at < now() - make_interval(days => p_retention_days); + get diagnostics v_deleted = row_count; + return v_deleted; +end; +$$; + +revoke all on function public.purge_expired_rag_query_misses(integer) from public, anon, authenticated; +grant execute on function public.purge_expired_rag_query_misses(integer) to service_role; + +comment on table public.rag_query_misses is + 'Unresolved/low-confidence query telemetry (hash-redacted query + candidate aliases). + Rows older than 90 days are purged nightly by the pg_cron job + "purge-rag-query-misses" (see purge_expired_rag_query_misses). Adjust the window + by changing the interval in that job definition.'; + +-- Register the nightly purge when pg_cron is available. Preview/branch databases +-- may not ship pg_cron; install the function there but skip scheduling. +do $cron$ +begin + if to_regnamespace('cron') is null then + return; + end if; + + perform cron.unschedule(j.jobid) + from cron.job j + where j.jobname = 'purge-rag-query-misses'; + + perform cron.schedule( + 'purge-rag-query-misses', + '45 3 * * *', + $job$select public.purge_expired_rag_query_misses(90);$job$ + ); +end +$cron$; diff --git a/tests/env-query-hash-secret.test.ts b/tests/env-query-hash-secret.test.ts new file mode 100644 index 000000000..7acec12be --- /dev/null +++ b/tests/env-query-hash-secret.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// requireQueryHashSecret() reads the frozen `env` value parsed at import time, so +// each case re-imports the module with a stubbed environment. Production must fail +// closed when RAG_QUERY_HASH_SECRET is absent, so clinical-query hashes written to +// the log tables are keyed HMAC pseudonyms and not offline-reversible SHA-256 (PIA-2). + +async function loadEnv(secret: string | undefined) { + vi.resetModules(); + vi.stubEnv("RAG_QUERY_HASH_SECRET", secret); + return import("../src/lib/env"); +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("requireQueryHashSecret", () => { + it("throws when RAG_QUERY_HASH_SECRET is absent", async () => { + const { requireQueryHashSecret } = await loadEnv(undefined); + expect(() => requireQueryHashSecret()).toThrow(/RAG_QUERY_HASH_SECRET/); + }); + + it("does not throw when RAG_QUERY_HASH_SECRET is set", async () => { + const { requireQueryHashSecret } = await loadEnv("test-secret-at-least-16-chars"); + expect(() => requireQueryHashSecret()).not.toThrow(); + }); +}); From 0bb79f2206e1f756063ff182b89c5d3374f41dac Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:20:01 +0800 Subject: [PATCH 2/3] docs(env): mark RAG_QUERY_HASH_SECRET as required in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup guard added in this branch (requireQueryHashSecret) makes the secret mandatory in production, so update .env.example from "strongly recommended" to "required", with a generation command. Placeholder only — no real value committed. Co-Authored-By: Claude Fable 5 --- .env.example | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index e56b6a601..cdf6c9c01 100644 --- a/.env.example +++ b/.env.example @@ -84,9 +84,13 @@ RAG_AWAIT_QUERY_LOGS=false #RAG_PERSIST_RAW_QUERY_TEXT=false # Server-side key for the redacted query-hash placeholder (min 16 chars). # When set, stored query hashes are HMAC-SHA256 keyed pseudonyms — not -# offline-reversible and not correlatable outside this deployment. Strongly -# recommended wherever real clinical queries are logged. Changing or setting -# the key changes future hashes, so historical dedup/joins reset from then on. +# offline-reversible and not correlatable outside this deployment. REQUIRED in +# production: instrumentation.register() calls requireQueryHashSecret() and the +# server refuses to start without it, so clinical-query hashes are never logged +# under the weak, reversible SHA-256 fallback (see docs/privacy-impact-assessment.md, +# PIA-2). Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# Changing or setting the key changes future hashes, so historical dedup/joins +# reset from then on. Do NOT commit the real value. #RAG_QUERY_HASH_SECRET= # Private buckets created by supabase/schema.sql. From 8797023ab99bd8c84e1442a6ef9643717642f367 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:25:57 +0800 Subject: [PATCH 3/3] test: update instrumentation boot-guard for required query-hash secret The new requireQueryHashSecret() gate made the existing "starts a fully configured production server" case throw. Supply RAG_QUERY_HASH_SECRET in that case and add a case asserting register() rejects when it is absent (ordered after the OpenAI check, matching instrumentation.ts). Full vitest suite green (1292 passed). Co-Authored-By: Claude Fable 5 --- tests/instrumentation.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index fb47b1d3b..69b22ebee 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -17,6 +17,7 @@ const ENV_KEYS = [ "SUPABASE_PROJECT_REF", "SUPABASE_PROJECT_NAME", "OPENAI_API_KEY", + "RAG_QUERY_HASH_SECRET", "NEXT_PUBLIC_LOCAL_NO_AUTH", "LOCAL_NO_AUTH", ] as const; @@ -62,12 +63,23 @@ describe("instrumentation boot guard", () => { await expect(register()).rejects.toThrow(/OPENAI_API_KEY/); }); + it("refuses to start a production server without a query-hash secret", async () => { + const register = await loadRegister({ + ...PRODUCTION_NODE, + NEXT_PUBLIC_SUPABASE_URL: MATCHING_URL, + SUPABASE_SERVICE_ROLE_KEY: "service-role-key", + OPENAI_API_KEY: "openai-key", + }); + await expect(register()).rejects.toThrow(/RAG_QUERY_HASH_SECRET/); + }); + it("starts a fully configured production server", async () => { const register = await loadRegister({ ...PRODUCTION_NODE, NEXT_PUBLIC_SUPABASE_URL: MATCHING_URL, SUPABASE_SERVICE_ROLE_KEY: "service-role-key", OPENAI_API_KEY: "openai-key", + RAG_QUERY_HASH_SECRET: "test-secret-at-least-16-chars", }); await expect(register()).resolves.toBeUndefined(); });