Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
}
14 changes: 14 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
@@ -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$;
29 changes: 29 additions & 0 deletions tests/env-query-hash-secret.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
12 changes: 12 additions & 0 deletions tests/instrumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
Expand Down
Loading