diff --git a/e2e/setup/ai-optional-fixture.ts b/e2e/setup/ai-optional-fixture.ts index 4a417d3e1..3b930fc38 100644 --- a/e2e/setup/ai-optional-fixture.ts +++ b/e2e/setup/ai-optional-fixture.ts @@ -54,7 +54,7 @@ export async function resetAiOptionalAccount(): Promise { `UPDATE users SET ai_provider = NULL, ai_base_url = NULL, - insights_cached_text = NULL, + insights_cached_text_encrypted = NULL, insights_cached_at = NULL, insights_cached_locale = NULL, disable_coach = false @@ -80,16 +80,20 @@ export async function seedStoredBriefing(marker: string): Promise { // Generated now: only text written today is served as today's read. briefingGeneratedAt: new Date().toISOString(), }; + // Sealed the way the app writes it; there is no readable column to seed. + const { sealInsightsCachedText } = + await import("@/lib/insights/cached-text-cipher"); + const sealed = Buffer.from(sealInsightsCachedText(JSON.stringify(insights))); await withAccount(async (pool, userId) => { await pool.query( `UPDATE users SET ai_provider = 'LOCAL', ai_base_url = 'http://127.0.0.1:9/v1', - insights_cached_text = $2, + insights_cached_text_encrypted = $2, insights_cached_at = now(), insights_cached_locale = 'en' WHERE id = $1`, - [userId, JSON.stringify(insights)], + [userId, sealed], ); await pool.query( `INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at) diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 3d4b74cf9..faf10ceb1 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -802,7 +802,7 @@ export default async function globalSetup(config: FullConfig): Promise { codex_connected_at = NULL, codex_connection_status = 'disconnected', insights_cached_at = NULL, - insights_cached_text = NULL, + insights_cached_text_encrypted = NULL, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, module_preferences_json = EXCLUDED.module_preferences_json, diff --git a/prisma/migrations/0430_drop_insights_cached_text/migration.sql b/prisma/migrations/0430_drop_insights_cached_text/migration.sql new file mode 100644 index 000000000..39724eb38 --- /dev/null +++ b/prisma/migrations/0430_drop_insights_cached_text/migration.sql @@ -0,0 +1,15 @@ +-- Drop the readable copy of the cached AI insights payload. +-- +-- Since 0411 every write goes to `insights_cached_text_encrypted` and leaves +-- `insights_cached_text` NULL, and the boot-time free-text encryption backfill +-- sealed the rows written before. Nothing reads the readable column any more. +-- +-- A host that skipped the releases between 0411 and this one may still hold a +-- readable payload the backfill never reached. It is a cache of model output, +-- not a record: the next nightly or on-demand warm writes a fresh, sealed one. +-- Dropping it is the safe direction; keeping it would leave health text in the +-- clear on exactly the hosts that upgraded the longest way round. + +DROP INDEX IF EXISTS "users_insights_cached_text_backfill_idx"; + +ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_cached_text"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d79d97b1..61e9e8270 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,13 +136,6 @@ model User { useCentralCodex Boolean @default(false) @map("use_central_codex") insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" insightsCachedAt DateTime? @map("insights_cached_at") - /// @internal: legacy plaintext cache of the AI insights payload, read only - /// as a fallback for rows the free-text encryption backfill has not reached - /// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted` - /// and leaves this NULL; the backfill seals and nulls it on the old rows. - /// DROP this column in a later release, once the backfill reports zero - /// remaining rows on every instance. - insightsCachedText String? @map("insights_cached_text") /// v1.43.7 — the cached AI insights payload (briefing, per-domain readings, /// recommendations; JSON), AES-256-GCM binary layout under /// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through @@ -6701,7 +6694,7 @@ model HostMetric { // (no prompt-tuning flow yet — that ratchet is v1.4.17). // // Privacy: server-side fills providerType + promptVersion from the -// user's most recent cached payload (User.insightsCachedText) so the +// user's most recent cached payload (User.insightsCachedTextEncrypted) so the // client can't tamper with the attribution. // // `(userId, recommendationId, recommendationText)` is the dedup key. diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index 228e94714..d623229f0 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -327,7 +327,7 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat height_cm, date_of_birth, gender, timezone, codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at, codex_connected_at, codex_connection_status, - insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted, + insights_privacy_mode, insights_cached_at, insights_cached_text_encrypted, insights_exclude_metrics, global_excluded_injection_sites, telegram_bot_token, telegram_chat_id, telegram_enabled, @@ -360,7 +360,7 @@ readonly USERS_SELECT="id, username, email, password_hash, NULL::timestamptz AS codex_token_expires_at, NULL::timestamptz AS codex_connected_at, 'disconnected' AS codex_connection_status, - insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted, + insights_privacy_mode, insights_cached_at, insights_cached_text_encrypted, insights_exclude_metrics, global_excluded_injection_sites, NULL::text AS telegram_bot_token, diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index aad6f50f2..2479d4cf3 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -3555,15 +3555,13 @@ async function seed() { await client.query( `UPDATE users SET insights_cached_text_encrypted = $2, - insights_cached_text = NULL, insights_cached_at = NOW(), insights_snapshot_hash = $3, insights_briefing_reroll_date = $4 WHERE id = $1`, [ userId, - // Sealed like every write since v1.43.7; the app never reads the - // readable column while a ciphertext is there. + // Sealed like every write since v1.43.7. Buffer.from( sealInsightsCachedText(JSON.stringify(comprehensiveInsight)), ), diff --git a/src/__tests__/insights-cached-text-access-guard.test.ts b/src/__tests__/insights-cached-text-access-guard.test.ts index f5cff3900..f3bd38ee4 100644 --- a/src/__tests__/insights-cached-text-access-guard.test.ts +++ b/src/__tests__/insights-cached-text-access-guard.test.ts @@ -1,18 +1,16 @@ /** * The cached AI insights payload is read and written in one place. * - * Since v1.43.7 the payload is sealed in `User.insightsCachedTextEncrypted` - * and the readable `insightsCachedText` survives only as a fallback for rows - * the backfill has not reached. Two columns that must move together are a - * standing invitation to move one: a clear written as - * `{ insightsCachedText: null }` — the shape every clear had until this - * release — would now leave the whole sealed briefing behind, after a consent - * withdrawal of all places. So the names are spelled in - * `src/lib/insights/cached-text-cipher.ts`, and every other file goes through - * its select, write, clear and open helpers. + * The payload is sealed in `User.insightsCachedTextEncrypted`; the readable + * `insightsCachedText` column it replaced was dropped in v1.44.0 (migration + * 0430). Every reader and writer goes through + * `src/lib/insights/cached-text-cipher.ts` (its select, write, clear and open + * helpers), so a clear cannot forget the column and a read cannot skip the + * decryption. The column name is spelled there and in the tables that have + * to name every column of the row. * - * The allowlist below is the set of files with a reason to spell the legacy - * name anyway, each stated. A file that joins it has to say why here. + * The readable name survives only as the snapshot builder's input field, which + * carries the OPENED text, never the column. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -23,42 +21,46 @@ import { stripComments, walkSourceFiles } from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); -const ALLOWED: Readonly> = { - "lib/insights/cached-text-cipher.ts": "the one module that owns the columns", - "lib/jobs/free-text-encryption-backfill.ts": - "the backfill that seals the legacy column", +const SEALED_ALLOWED: Readonly> = { + "lib/insights/cached-text-cipher.ts": "the one module that owns the column", + "lib/crypto/encrypted-columns.ts": + "the registry of every ciphertext-bearing column", "lib/export/backup-plan.ts": "the backup classification names every User column", - "lib/data-wipe/wipe-plan.ts": - "the wipe reset names every User column it resets, and resets both", + "lib/data-wipe/wipe-plan.ts": "the wipe reset names every User column", +}; + +const OPENED_TEXT_ALLOWED: Readonly> = { "lib/dashboard/snapshot.ts": - "the snapshot builder's input field carries the OPENED text, not the column", + "the snapshot builder's input field carries the opened text", "lib/dashboard/snapshot-read.ts": "fills that input field from openInsightsCachedText", }; -/** The legacy name, not its sealed sibling, outside comments. */ -const LEGACY_NAME = /\binsightsCachedText\b(?!Encrypted)/; +const SEALED_NAME = /\binsightsCachedTextEncrypted\b/; +/** The dropped readable name, not its sealed sibling. */ +const READABLE_NAME = /\binsightsCachedText\b(?!Encrypted)/; -function filesSpellingTheLegacyName(): string[] { +function sourceFilesMatching(pattern: RegExp): string[] { return walkSourceFiles(SRC, { floor: 2000 }) .filter((f) => !f.startsWith("generated/")) .filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f)) .filter((f) => - LEGACY_NAME.test(stripComments(readFileSync(join(SRC, f), "utf8"))), - ); + pattern.test(stripComments(readFileSync(join(SRC, f), "utf8"))), + ) + .sort(); } describe("insights cache column access", () => { - const found = filesSpellingTheLegacyName(); - - it("is confined to the cipher module and the stated exceptions", () => { - const strays = found.filter((f) => !(f in ALLOWED)); - expect(strays).toEqual([]); + it("spells the sealed column only in the cipher module and the stated tables", () => { + const found = sourceFilesMatching(SEALED_NAME); + expect(found.length).toBe(4); + expect(found).toEqual(Object.keys(SEALED_ALLOWED).sort()); }); - it("still finds every stated exception, so the matcher is live", () => { - expect(found.length).toBeGreaterThan(5); - expect([...found].sort()).toEqual(Object.keys(ALLOWED).sort()); + it("uses the readable name only for the opened text the snapshot builder takes", () => { + const found = sourceFilesMatching(READABLE_NAME); + expect(found.length).toBe(2); + expect(found).toEqual(Object.keys(OPENED_TEXT_ALLOWED).sort()); }); }); diff --git a/src/__tests__/session-user-projection-guard.test.ts b/src/__tests__/session-user-projection-guard.test.ts index 36a64c618..ad62e0f7d 100644 --- a/src/__tests__/session-user-projection-guard.test.ts +++ b/src/__tests__/session-user-projection-guard.test.ts @@ -127,7 +127,7 @@ describe("SESSION_USER_SELECT leaves out bytes and secrets", () => { it("selects no credential, secret or large cached text", () => { const secretShaped = - /Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^insightsCachedText$|^environmentAqHistoryJson$/; + /Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^environmentAqHistoryJson$/; const secrets = columns .map((c) => c.name) .filter((name) => secretShaped.test(name)); diff --git a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts index 94bb26256..647741d4b 100644 --- a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts @@ -226,7 +226,6 @@ describe("about-me module gate", () => { where: { id: "u1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, diff --git a/src/app/api/daily/digest/route.ts b/src/app/api/daily/digest/route.ts index db05d9ea8..f9b6dbfa3 100644 --- a/src/app/api/daily/digest/route.ts +++ b/src/app/api/daily/digest/route.ts @@ -4,7 +4,7 @@ * The read seam for the unified daily-value system (P3). Returns the * `DailyDigest` DTO the Today surface, the daily push, and a future iOS widget * all consume — assembled by `loadDailyDigest` from ALREADY-CACHED data (the - * nightly briefing lifted read-only from `User.insightsCachedText`, the + * nightly briefing lifted read-only from `User.insightsCachedTextEncrypted`, the * dashboard-snapshot health score / meds-today / sleep freshness) plus two * light deterministic reads (broken integrations, overdue Vorsorge). No * provider call is reachable from this path, and nothing warms on mount. diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts index 8ad23d819..3079fb2e8 100644 --- a/src/app/api/dashboard/snapshot/route.ts +++ b/src/app/api/dashboard/snapshot/route.ts @@ -26,7 +26,7 @@ * so a regression is attributable without re-instrumenting. * * No LLM is reachable from the builder — the daily briefing is lifted - * read-only from `User.insightsCachedText`. The nightly + * read-only from `User.insightsCachedTextEncrypted`. The nightly * `insight-pregenerate` cron keeps that cache warm. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 3b61abb58..0a768cb86 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -206,7 +206,7 @@ beforeEach(() => { fullName: null, insurerName: null, insuranceNumberEncrypted: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, } as never); }); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 8972bd996..bab35438f 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -44,7 +44,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(async () => ({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, locale: "en", })), updateMany: vi.fn(async () => ({ count: 1 })), @@ -175,7 +175,10 @@ import { import { annotate } from "@/lib/logging/context"; import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt"; import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + openInsightsCachedText, + sealInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -472,7 +475,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { const user = { insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, locale: "en", unitPreference: "imperial", glucoseUnit: "mmol/L", @@ -527,7 +530,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: ["sleep"], locale: "en", dateOfBirth: null, @@ -564,7 +567,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], locale: "en", } as never); @@ -596,11 +599,13 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - changed: "still fresh", - dailyBriefing: { paragraph: "ok", keyFindings: [] }, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + changed: "still fresh", + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }), + ), locale: "en", } as never); @@ -636,11 +641,13 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - changed: "still fresh", - dailyBriefing: { paragraph: "ok", keyFindings: [] }, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + changed: "still fresh", + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }), + ), locale: "en", } as never); @@ -673,10 +680,12 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ - changed: "fresh but no briefing", - dailyBriefing: null, - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + changed: "fresh but no briefing", + dailyBriefing: null, + }), + ), insightsExcludeMetrics: [], locale: "en", } as never); @@ -686,10 +695,12 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "today", keyFindings: [] }, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "today", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }), + ), locale: "en", } as never); @@ -770,7 +781,7 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", () // wide shape; the retry asks for the aggregated shape. insightsPrivacyMode: "raw", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], locale: "en", } as never); @@ -817,7 +828,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(cached), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify(cached), + ), locale: "en", } as never); @@ -842,7 +855,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const stale = new Date(Date.now() - 48 * 60 * 60 * 1000); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: stale, - insightsCachedText: JSON.stringify({ dailyBriefing: null }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: null }), + ), locale: "en", } as never); @@ -867,7 +882,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const cached = { dailyBriefing: { paragraph: "Deutsch", keyFindings: [] } }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(cached), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify(cached), + ), insightsCachedLocale: "de", locale: "en", } as never); @@ -896,7 +913,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(cached), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify(cached), + ), insightsCachedLocale: "en", locale: "en", } as never); @@ -918,10 +937,12 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(Date.now() - 60_000), - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] }, - briefingGeneratedAt: yesterday.toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] }, + briefingGeneratedAt: yesterday.toISOString(), + }), + ), insightsCachedLocale: "en", locale: "en", timezone: "UTC", @@ -944,29 +965,31 @@ describe("GET /api/insights/generate — read-only advisor read", () => { it("serves today's signals with deltas at display precision", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - dailyBriefing: { - paragraph: "ok", - keyFindings: [], - signalsOfDay: [ - { - sourceMetric: "sleep", - tone: "info", - headline: "Shorter night", - nudge: "An earlier night.", - delta: null, - }, - { - sourceMetric: "pulse", - tone: "watch", - headline: "Pulse up", - nudge: "Rest.", - delta: "+33.72 bpm vs your 30-day average", - }, - ], - }, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { + paragraph: "ok", + keyFindings: [], + signalsOfDay: [ + { + sourceMetric: "sleep", + tone: "info", + headline: "Shorter night", + nudge: "An earlier night.", + delta: null, + }, + { + sourceMetric: "pulse", + tone: "watch", + headline: "Pulse up", + nudge: "Rest.", + delta: "+33.72 bpm vs your 30-day average", + }, + ], + }, + briefingGeneratedAt: new Date().toISOString(), + }), + ), insightsCachedLocale: "en", locale: "en", timezone: "UTC", @@ -998,7 +1021,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => { vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, locale: "en", } as never); @@ -1027,7 +1050,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const cached = { dailyBriefing: { paragraph: "old", keyFindings: [] } }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: stale, - insightsCachedText: JSON.stringify(cached), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify(cached), + ), locale: "en", } as never); @@ -1054,7 +1079,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const cachedAt = new Date(Date.now() - 60 * 60 * 1000); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: cachedAt, - insightsCachedText: JSON.stringify({ dailyBriefing: null }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: null }), + ), locale: "en", } as never); // Marker newer than the last successful generation (written right @@ -1086,9 +1113,11 @@ describe("GET /api/insights/generate — read-only advisor read", () => { const cachedAt = new Date(Date.now() - 60 * 60 * 1000); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: cachedAt, - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "held", keyFindings: [] }, - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "held", keyFindings: [] }, + }), + ), locale: "en", } as never); vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ @@ -1109,9 +1138,11 @@ describe("GET /api/insights/generate — read-only advisor read", () => { it("reports hasProvider: true on a normal cached read", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "ok", keyFindings: [] }, - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + }), + ), locale: "en", } as never); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index f2e3c3ff9..9827cab62 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -153,7 +153,7 @@ export function resolveInsightsRateLimit(): number { * to mount against the POST handler, which generates inline on a cache miss * and blocks the page-load path on the full provider chain (up to the * client's 8 s abort). This read-only GET serves the cached payload from - * `User.insightsCachedText` immediately — NEVER calling the provider — and, + * `User.insightsCachedTextEncrypted` immediately — NEVER calling the provider — and, * when the cache is stale / missing AND a provider is configured, enqueues * an out-of-band warm (the same `insight-pregenerate` queue the nightly * cron and the "prepare assessments" button use). The next read reflects diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index e19700589..9633a652e 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -26,7 +26,7 @@ import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marke * * Why a POST under a `useQuery` rather than a dedicated GET endpoint: * the route already cache-returns the most recent generation (24h TTL on - * `User.insightsCachedAt` / `User.insightsCachedText`) without burning a + * `User.insightsCachedAt` / `User.insightsCachedTextEncrypted`) without burning a * rate-limit token, so a single POST-without-`force` is functionally a * GET-or-generate. Adding a separate GET would duplicate the cache-read * branch and split the audit-log surface; the reconcile report deferred @@ -540,7 +540,7 @@ export function useInsightsAdvisorQuery( // re-fetches. queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); // v1.18.10 / v1.32.19 — the dashboard hero surfaces the briefing - // headline from the SNAPSHOT (server-lifted `User.insightsCachedText`), + // headline from the SNAPSHOT (server-lifted `User.insightsCachedTextEncrypted`), // and the Today hero's lead line is the SAME briefing lifted into the // digest — neither is this advisor cache. A regenerate rewrites that // server cache, so both the snapshot and the digest must refetch. diff --git a/src/lib/auth/session-user.ts b/src/lib/auth/session-user.ts index d54cfeccd..b665542c7 100644 --- a/src/lib/auth/session-user.ts +++ b/src/lib/auth/session-user.ts @@ -17,8 +17,8 @@ import type { Prisma } from "@/generated/prisma/client"; * flags, the small preference documents). Anything else is read by the one * route that needs it, with its own `select`, at the moment it needs it: * - * - bytes and large text: `avatarBytes`, `insightsCachedText`, - * `insightsCachedTextEncrypted`, `homeLocationEncrypted`, + * - bytes and large text: `avatarBytes`, `insightsCachedTextEncrypted`, + * `homeLocationEncrypted`, * `environmentAqHistoryJson`, `dawarichPrefsEncrypted`; * - credentials and secrets: `passwordHash`, `totpSecretEncrypted`, * `totpLastStep`, `telegramBotToken`, every `*Encrypted` integration and diff --git a/src/lib/consent/withdrawal.ts b/src/lib/consent/withdrawal.ts index 9ec245d6c..2c35c261a 100644 --- a/src/lib/consent/withdrawal.ts +++ b/src/lib/consent/withdrawal.ts @@ -66,7 +66,7 @@ export async function purgeRegenerableAiText( where: { id: userId, OR: [ - ...INSIGHTS_CACHE_PRESENT_WHERE.OR, + INSIGHTS_CACHE_PRESENT_WHERE, { insightsCachedAt: { not: null } }, { insightsSnapshotHash: { not: null } }, ], diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 5d837ba28..e6995062f 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -3,7 +3,7 @@ * * A pure, deterministic composer that assembles the day's read from data that * ALREADY EXISTS — the nightly `insight-pregenerate` output lifted read-only - * from `User.insightsCachedText`, the dashboard-snapshot ingredients (health + * from `User.insightsCachedTextEncrypted`, the dashboard-snapshot ingredients (health * score, meds-today, sleep last-seen), plus two light deterministic reads * (integration status, due Vorsorge reminders). It computes no analytics and * NEVER triggers an AI/provider call — the warm-on-mount ban extends here. The diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index a79699426..1b15616a9 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -6,7 +6,7 @@ * briefing lift) rides `readDashboardSnapshotCached` — the SAME SWR cell the * dashboard already serves, so the digest never re-runs the snapshot builder * within its TTL and never reaches an AI provider (the briefing is lifted - * read-only from `User.insightsCachedText`). Two light deterministic reads add + * read-only from `User.insightsCachedTextEncrypted`). Two light deterministic reads add * the rail's non-snapshot inputs: broken integrations and overdue Vorsorge * reminders. Module gating is inherited from the snapshot (already * module-gated at the build layer) plus the resolved module map the item diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts index ebe57a1d8..329dd66d9 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts @@ -16,6 +16,10 @@ import { AI_AVAILABLE, aiUnavailable, } from "@/__tests__/helpers/ai-capability-fixtures"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; + +// The cached payload is sealed at rest, so seeding it needs a key. +vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); const briefingBody = { tiles: {}, @@ -35,9 +39,11 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: async () => ({ - insightsCachedText: JSON.stringify({ - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + briefingGeneratedAt: new Date().toISOString(), + }), + ), }), }, }, diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts index 7cf124e4a..3efaf82c4 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts @@ -10,6 +10,10 @@ import { AI_AVAILABLE, aiUnavailable, } from "@/__tests__/helpers/ai-capability-fixtures"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; + +// The cached payload is sealed at rest, so seeding it needs a key. +vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); const BRIEFING = { paragraph: "Your pulse is well above its usual level today.", @@ -49,7 +53,7 @@ const buildDashboardSnapshot = vi.fn(async () => current); // The cached briefing text is read off its own row, not the session user. const cacheRow = vi.hoisted(() => ({ - current: null as { insightsCachedText: string | null } | null, + current: null as { insightsCachedTextEncrypted: Uint8Array | null } | null, })); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: async () => cacheRow.current } }, @@ -80,10 +84,12 @@ const NOW = new Date("2026-10-05T19:00:00.000Z"); function user(generatedAt: string | null): SessionUser { cacheRow.current = { - insightsCachedText: JSON.stringify({ - dailyBriefing: BRIEFING, - ...(generatedAt ? { briefingGeneratedAt: generatedAt } : {}), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: BRIEFING, + ...(generatedAt ? { briefingGeneratedAt: generatedAt } : {}), + }), + ), }; return { id: "user-briefing-today", diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 4293375c7..29541fa39 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -27,7 +27,7 @@ * inside the same fast window. * * No LLM is reachable from this builder. The briefing is lifted - * read-only from `User.insightsCachedText`; a cache miss / stale row + * read-only from `User.insightsCachedTextEncrypted`; a cache miss / stale row * yields `briefingState: "preparing"` (with the last good briefing * delivered under `briefingStale: true` when one exists) and the * `insight-pregenerate` cron refills it — or `"no-provider"` when no @@ -943,7 +943,7 @@ export function applyBriefingCapability( } /** - * Read-only briefing lift. Parses `User.insightsCachedText`, validates + * Read-only briefing lift. Parses `User.insightsCachedTextEncrypted`, validates * the `dailyBriefing` block, and reports a four-state. NEVER calls the * provider chain — `hasProvider` is a credential-PRESENCE thunk (two * narrow reads, no decrypt, no network) evaluated only on the diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index f02b0d0e7..93347dfcc 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -400,7 +400,6 @@ export const USER_RESET = { // Derived AI output cached on the row insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, insightsSnapshotHash: null, diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index fc61a2cbe..b647ccbf4 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -536,6 +536,38 @@ describe("backup round trip — export, wire schema, restore", () => { expect(res.status).toBe(200); }); + // Files written before v1.44.0 can name the readable insights cache the + // release dropped (migration 0430), at the top level or among the account + // settings. The column is gone, so the restore must neither fail on the name + // nor try to write it. + it("restores a file that still names the dropped readable insights cache", async () => { + const { payload } = await buildFullBackupPayload( + sourceClient() as never, + OWNER, + { purpose: "disaster-recovery" }, + ); + const legacy: Record = { + ...payload, + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + accountSettings: { + ...((payload.accountSettings as Record | null) ?? {}), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + }, + }; + + const { res, written } = await roundTrip({}, legacy); + + expect(res.status).toBe(200); + for (const w of written) { + expect( + Object.keys(w.data), + `${w.model}.${w.op} must not write the dropped column`, + ).not.toContain("insightsCachedText"); + } + // The rest of the file still lands. + expect(written.some((w) => w.model === "userHealthProfile")).toBe(true); + }); + it("writes the durable self-context back with its ciphertext intact", async () => { const { written } = await roundTrip(); @@ -785,7 +817,7 @@ describe("backup round trip — export, wire schema, restore", () => { (w) => w.model === "user" && w.op === "update" && - "insightsCachedText" in w.data, + "insightsCachedTextEncrypted" in w.data, ); expect( userUpdate, @@ -794,7 +826,6 @@ describe("backup round trip — export, wire schema, restore", () => { // The language tag is cleared with the text: an empty slot carries // no language, and the next write tags it afresh. expect(userUpdate!.data).toEqual({ - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedAt: null, insightsCachedLocale: null, @@ -831,7 +862,7 @@ describe("backup round trip — export, wire schema, restore", () => { (w) => w.model === "user" && w.op === "update" && - "insightsCachedText" in w.data, + "insightsCachedTextEncrypted" in w.data, ); expect( userUpdate, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index b3c9ddef3..1d517711b 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -948,7 +948,6 @@ export const USER_COLUMN_BACKUP_CLASS = { // ── Operational ─────────────────────────────────────────────────────────── insightsCachedAt: "OPERATIONAL", - insightsCachedText: "OPERATIONAL", insightsCachedTextEncrypted: "OPERATIONAL", insightsCachedLocale: "OPERATIONAL", insightsSnapshotHash: "OPERATIONAL", diff --git a/src/lib/insights/__tests__/cached-text-cipher.test.ts b/src/lib/insights/__tests__/cached-text-cipher.test.ts index 8fcd71142..4aa64f6c0 100644 --- a/src/lib/insights/__tests__/cached-text-cipher.test.ts +++ b/src/lib/insights/__tests__/cached-text-cipher.test.ts @@ -23,50 +23,27 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()); describe("cached insights payload at rest", () => { - it("writes ciphertext and nulls the readable column in the same write", () => { + it("writes ciphertext only", () => { const data = insightsCacheWrite(PAYLOAD); - expect(data.insightsCachedText).toBeNull(); + expect(Object.keys(data)).toEqual(["insightsCachedTextEncrypted"]); expect( Buffer.from(data.insightsCachedTextEncrypted).toString("utf8"), ).not.toContain("Sleep held"); expect(openInsightsCachedText(data)).toBe(PAYLOAD); }); - it("falls back to the readable column for a row the backfill has not reached", () => { + it("reads a sealed value", () => { expect( openInsightsCachedText({ - insightsCachedText: PAYLOAD, - insightsCachedTextEncrypted: null, - }), - ).toBe(PAYLOAD); - }); - - // This used to prefer the readable column, the one sealed column read - // that way. Every sealed column now reads the ciphertext first. - it("reads a sealed value before a readable one beside it", () => { - expect( - openInsightsCachedText({ - insightsCachedText: "readable", insightsCachedTextEncrypted: sealInsightsCachedText("sealed"), }), ).toBe("sealed"); }); - it("never serves the readable value in place of a sealed one that does not open", () => { - const foreign = encryptBytes(Buffer.from(PAYLOAD, "utf8"), "other/v1"); - expect( - openInsightsCachedText({ - insightsCachedText: "readable", - insightsCachedTextEncrypted: new Uint8Array(foreign), - }), - ).toBeNull(); - }); - it("does not open a value sealed for another purpose", () => { const foreign = encryptBytes(Buffer.from(PAYLOAD, "utf8"), "other/v1"); expect( openInsightsCachedText({ - insightsCachedText: null, insightsCachedTextEncrypted: new Uint8Array(foreign), }), ).toBeNull(); @@ -75,6 +52,9 @@ describe("cached insights payload at rest", () => { it("reads an empty row as no cache", () => { expect(openInsightsCachedText(null)).toBeNull(); expect(openInsightsCachedText({ ...INSIGHTS_CACHE_CLEARED })).toBeNull(); + expect( + openInsightsCachedText({ insightsCachedTextEncrypted: new Uint8Array() }), + ).toBeNull(); }); it("is fail-closed on write: no key, no payload", () => { @@ -82,13 +62,11 @@ describe("cached insights payload at rest", () => { expect(() => insightsCacheWrite(PAYLOAD)).toThrow(); }); - it("clears and selects both columns", () => { + it("clears and selects the sealed column", () => { expect(INSIGHTS_CACHE_CLEARED).toEqual({ - insightsCachedText: null, insightsCachedTextEncrypted: null, }); - expect(Object.keys(INSIGHTS_CACHE_SELECT).sort()).toEqual([ - "insightsCachedText", + expect(Object.keys(INSIGHTS_CACHE_SELECT)).toEqual([ "insightsCachedTextEncrypted", ]); }); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 898355e11..17c106731 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -92,7 +92,10 @@ vi.mock("@/lib/cache/invalidate", () => ({ })); import { generateComprehensiveInsight } from "../comprehensive-generate"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + openInsightsCachedText, + sealInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -148,7 +151,7 @@ function cachedUser(over: Record = {}) { return { insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], // Never equal to the freshly computed hash, so a full generation runs. insightsSnapshotHash: null, @@ -189,7 +192,7 @@ describe("briefing grounding — disposal", () => { findUnique.mockResolvedValue( cachedUser({ insightsCachedAt: new Date("2026-07-15T03:00:00.000Z"), - insightsCachedText: PREVIOUS_PAYLOAD, + insightsCachedTextEncrypted: sealInsightsCachedText(PREVIOUS_PAYLOAD), }), ); // Both passes carry the same fabricated figure: a content failure. diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 417cc94f1..00ce1b374 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -98,7 +98,10 @@ import { hashInsightSnapshot } from "../snapshot-hash"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { featuresInReaderUnits } from "../features-units"; import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + openInsightsCachedText, + sealInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -143,7 +146,7 @@ describe("daily-briefing re-roll on unchanged data", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: CACHED, + insightsCachedTextEncrypted: sealInsightsCachedText(CACHED), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, insightsBriefingRerollDate: null, // never re-rolled today @@ -198,7 +201,7 @@ describe("daily-briefing re-roll on unchanged data", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: CACHED, + insightsCachedTextEncrypted: sealInsightsCachedText(CACHED), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, insightsBriefingRerollDate: todayKey, // already re-rolled today @@ -214,7 +217,7 @@ describe("daily-briefing re-roll on unchanged data", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: CACHED, + insightsCachedTextEncrypted: sealInsightsCachedText(CACHED), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 1ab2e37d8..e2b135eb3 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -150,7 +150,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 5519de2cd..3878245df 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -96,7 +96,10 @@ import { hashInsightSnapshot } from "../snapshot-hash"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { featuresInReaderUnits } from "../features-units"; import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; -import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher"; +import { + openInsightsCachedText, + sealInsightsCachedText, +} from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -143,7 +146,7 @@ describe("generateComprehensiveInsight — the briefing capability at the wire", const STALE_USER = { insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, }; @@ -181,7 +184,7 @@ describe("generateComprehensiveInsight — the briefing capability at the wire", findUnique.mockResolvedValue({ ...STALE_USER, insightsCachedAt: new Date(Date.now() - 60 * 60 * 1000), - insightsCachedText: "{}", + insightsCachedTextEncrypted: sealInsightsCachedText("{}"), }); aiCapabilityForRecord.mockResolvedValue({ available: false, @@ -201,7 +204,7 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 21 * 60 * 60 * 1000), - insightsCachedText: "{}", + insightsCachedTextEncrypted: sealInsightsCachedText("{}"), insightsExcludeMetrics: [], insightsSnapshotHash: null, }); @@ -220,7 +223,7 @@ describe("generateComprehensiveInsight — cache short-circuit", () => { insightsPrivacyMode: "aggregated", // 21h old — inside the 24h TTL, so the un-forced path would cache. insightsCachedAt: new Date(Date.now() - 21 * 60 * 60 * 1000), - insightsCachedText: "{}", + insightsCachedTextEncrypted: sealInsightsCachedText("{}"), insightsExcludeMetrics: [], insightsSnapshotHash: null, }); @@ -251,7 +254,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, }); @@ -280,7 +285,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], // Stored fingerprint from an older data state. insightsSnapshotHash: "0".repeat(64), @@ -329,7 +336,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, unitPreference: "imperial", @@ -383,7 +392,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 1 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "english" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "english" } }), + ), insightsCachedLocale: "en", insightsExcludeMetrics: [], insightsSnapshotHash: null, @@ -403,7 +414,9 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: null, }); @@ -436,10 +449,12 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ - dailyBriefing: null, - recommendations: [], - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: null, + recommendations: [], + }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, }); @@ -475,7 +490,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, }); @@ -522,7 +537,9 @@ describe("generateComprehensiveInsight — usage level", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, aiUsageLevel, diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index dca1bcfe2..3a6f0e78f 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -110,7 +110,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts b/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts index 4d6025b50..97adb76f7 100644 --- a/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts +++ b/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts @@ -116,7 +116,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts b/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts index 98c1501e7..eaf83631b 100644 --- a/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts +++ b/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts @@ -114,7 +114,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 12bca2b0b..87fbb55b1 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -97,6 +97,7 @@ vi.mock("@/lib/logging/context", async (importOriginal) => { import { generateComprehensiveInsight } from "../comprehensive-generate"; import { hashInsightSnapshot } from "../snapshot-hash"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; // The cached payload is sealed at rest, so its writes need a key. vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64)); @@ -151,7 +152,9 @@ describe("generateComprehensiveInsight — consent withdrawn in flight", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: "0".repeat(64), }); @@ -191,7 +194,9 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: "0".repeat(64), // changed → full generation }); @@ -236,7 +241,9 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: ["sleep"], insightsSnapshotHash: "0".repeat(64), gender: "FEMALE", @@ -280,7 +287,9 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: ["sleep"], insightsSnapshotHash: "0".repeat(64), gender: "FEMALE", @@ -322,7 +331,9 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ dailyBriefing: { p: "old" } }), + ), insightsExcludeMetrics: [], insightsSnapshotHash: "0".repeat(64), }); @@ -361,7 +372,7 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), - insightsCachedText: CACHED_WITH_BRIEFING, + insightsCachedTextEncrypted: sealInsightsCachedText(CACHED_WITH_BRIEFING), insightsExcludeMetrics: [], insightsSnapshotHash: FEATURES_HASH, // unchanged → reroll path insightsBriefingRerollDate: null, diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index fb77da31c..39307a17c 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -108,7 +108,7 @@ function mockUser(overrides: Record = {}) { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, @@ -165,7 +165,7 @@ describe("comprehensive timeout honouring", () => { const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); expect(outcome.status).toBe("failed"); - // No cache row written → the prior `insightsCachedText` (last good) survives. + // No cache row written → the prior `insightsCachedTextEncrypted` (last good) survives. expect(userUpdate).not.toHaveBeenCalled(); // A dated failure marker is recorded so the read path can surface a hint. expect(recordBriefingFailure).toHaveBeenCalledWith( diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index b1f089d00..ef7cd05ad 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -129,7 +129,7 @@ beforeEach(() => { findUnique.mockResolvedValue({ insightsPrivacyMode: "aggregated", insightsCachedAt: null, - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsExcludeMetrics: [], insightsSnapshotHash: null, insightsBriefingRerollDate: null, diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts index 025a3abe4..b3a62c25d 100644 --- a/src/lib/insights/briefing-failure-marker.ts +++ b/src/lib/insights/briefing-failure-marker.ts @@ -4,7 +4,7 @@ import { prisma } from "@/lib/db"; * Dated failure marker for the daily-briefing generation. * * The briefing keeps its last good payload on a failed refresh: the generator - * writes NO `User.insightsCachedText` row when a provider times out or errors, + * writes NO `User.insightsCachedTextEncrypted` row when a provider times out or errors, * so the previous briefing stays intact and the surface never blanks. What was * missing is a signal the READ path can use to tell the difference between "no * refresh was due" and "the last refresh attempt failed" — without that, a diff --git a/src/lib/insights/cached-text-cipher.ts b/src/lib/insights/cached-text-cipher.ts index c74cbfcb0..90d154f18 100644 --- a/src/lib/insights/cached-text-cipher.ts +++ b/src/lib/insights/cached-text-cipher.ts @@ -1,35 +1,30 @@ /** * The cached AI insights payload, sealed at rest. * - * `User.insightsCachedText` held the model's last full reading of the account - * in the clear: the daily briefing, the per-domain status text and the - * recommendations, every one of them a sentence about the person's health. - * It was the last readable column of that kind on the user row. Since v1.43.7 - * the payload lives in `insightsCachedTextEncrypted` (AES-256-GCM, binary - * layout, purpose-bound by {@link INSIGHTS_CACHED_TEXT_AAD}), and the free-text - * encryption backfill seals the rows written before. + * The model's last full reading of the account (the daily briefing, the + * per-domain status text and the recommendations, every one of them a + * sentence about the person's health) lives in `insightsCachedTextEncrypted` + * (AES-256-GCM, binary layout, purpose-bound by + * {@link INSIGHTS_CACHED_TEXT_AAD}). The readable column it replaced in + * v1.43.7 was dropped in v1.44.0 (migration 0430). * - * Every reader and writer goes through this module, which is what keeps the - * two columns in step. A clear that nulls one column and not the other would - * leave the briefing behind after a consent withdrawal, so the clearing value - * is exported here and nowhere else spells the column names + * Every reader and writer goes through this module, so the column name is + * spelled here and in the classification tables only * (`insights-cached-text-access-guard.test.ts`). */ import { Buffer } from "node:buffer"; import { decryptBytes, encryptBytes } from "@/lib/crypto"; import { INSIGHTS_CACHED_TEXT_AAD } from "@/lib/crypto/encrypted-columns"; -import { readSealedOrLegacy, warnAndDrop } from "@/lib/crypto/sealed-or-legacy"; +import { warnAndDrop } from "@/lib/crypto/sealed-or-legacy"; /** The columns a reader selects to open the cache. */ export const INSIGHTS_CACHE_SELECT = { - insightsCachedText: true, insightsCachedTextEncrypted: true, } as const; /** The row shape {@link openInsightsCachedText} reads. */ export interface InsightsCacheColumns { - insightsCachedText?: string | null; insightsCachedTextEncrypted?: Uint8Array | null; } @@ -44,47 +39,28 @@ export function sealInsightsCachedText(text: string): Uint8Array { return out; } -/** - * The `data` fields that store a payload: the ciphertext, and the legacy - * readable column nulled in the same write so a row never holds both a new - * sealed payload and an older readable one. - */ +/** The `data` fields that store a payload. */ export function insightsCacheWrite(text: string): { insightsCachedTextEncrypted: Uint8Array; - insightsCachedText: null; } { - return { - insightsCachedTextEncrypted: sealInsightsCachedText(text), - insightsCachedText: null, - }; + return { insightsCachedTextEncrypted: sealInsightsCachedText(text) }; } -/** The `data` fields that empty the cache. Both columns, always. */ +/** The `data` fields that empty the cache. */ export const INSIGHTS_CACHE_CLEARED = { - insightsCachedText: null, insightsCachedTextEncrypted: null, } as const; -/** A `where` that finds rows holding a cached payload in either column. */ +/** A `where` that finds rows holding a cached payload. */ export const INSIGHTS_CACHE_PRESENT_WHERE = { - OR: [ - { insightsCachedText: { not: null } }, - { insightsCachedTextEncrypted: { not: null } }, - ], + insightsCachedTextEncrypted: { not: null }, } as const; +const dropUndecryptable = warnAndDrop("insights cache did not open"); + /** * The cached payload as text, or null when there is none. * - * The ciphertext is read first, as every sealed column is read - * (`readSealedOrLegacy`); the readable column only when the row holds no - * ciphertext. A row holding both can come from a writer of the previous - * release still running after the upgrade; its readable value may be the - * newer one, and the free-text backfill seals it over the old ciphertext on - * its next pass. Until then the reader serves the sealed one: an older - * briefing for a while, never a readable value preferred over a protected - * one. - * * A sealed value that does not open reads as an empty cache with a warning on * the event. It is a cache: the next warm overwrites it under the active key, * whereas failing the read would take the dashboard down with it. @@ -92,14 +68,13 @@ export const INSIGHTS_CACHE_PRESENT_WHERE = { export function openInsightsCachedText( row: InsightsCacheColumns | null | undefined, ): string | null { - if (!row) return null; - return readSealedOrLegacy({ - sealed: row.insightsCachedTextEncrypted, - legacy: row.insightsCachedText, - open: (sealed) => - decryptBytes(Buffer.from(sealed), INSIGHTS_CACHED_TEXT_AAD).toString( - "utf8", - ), - onUndecryptable: warnAndDrop("insights cache did not open"), - }); + const sealed = row?.insightsCachedTextEncrypted; + if (!sealed || sealed.byteLength === 0) return null; + try { + return decryptBytes(Buffer.from(sealed), INSIGHTS_CACHED_TEXT_AAD).toString( + "utf8", + ); + } catch (error) { + return dropUndecryptable(error); + } } diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 3b1036474..67a612a62 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -1279,7 +1279,7 @@ export async function generateComprehensiveInsight( }); // v1.25 — record a dated failure marker so the read path can surface a // discreet "couldn't refresh" hint alongside the preserved last-good - // briefing. No cache row is written here, so `insightsCachedText` (the + // briefing. No cache row is written here, so `insightsCachedTextEncrypted` (the // last good payload) stays intact and the surface never blanks. void recordBriefingFailure({ userId, diff --git a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts index 06057a5c8..5bc6abd92 100644 --- a/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts +++ b/src/lib/jobs/__tests__/free-text-encryption-backfill.test.ts @@ -5,10 +5,6 @@ import { decryptRouteGeometry, encryptRouteGeometry, } from "@/lib/workouts/route-geometry-cipher"; -import { - openInsightsCachedText, - sealInsightsCachedText, -} from "@/lib/insights/cached-text-cipher"; // ── in-memory store backing a minimal prisma mock ────────────────────────── interface ConversationRow { @@ -33,14 +29,6 @@ interface PractitionerRow { locationEncrypted: Uint8Array | null; updatedAt?: Date; } -interface UserRow { - id: string; - /** The row is its own owner; mirrors the other delegates' owner field. */ - userId: string; - insightsCachedText: string | null; - insightsCachedTextEncrypted: Uint8Array | null; - updatedAt?: Date; -} interface RouteRow { id: string; /** The owning workout's user; the real table reaches it through the join. */ @@ -54,7 +42,6 @@ const store = vi.hoisted(() => ({ entries: [] as EntryRow[], practitioners: [] as PractitionerRow[], routes: [] as RouteRow[], - users: [] as UserRow[], })); vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); @@ -131,33 +118,6 @@ vi.mock("@/lib/db", () => { customMetricEntry: delegate(() => store.entries, ["note"]), practitioner: delegate(() => store.practitioners, ["phone", "location"]), workoutRoute: delegate(() => store.routes, ["geometry"]), - user: (() => { - const base = delegate(() => store.users, ["insightsCachedText"]); - return { - ...base, - // The insights cache move also guards on the readable value it read, - // so a warm that sealed a newer payload meanwhile is not overwritten. - updateMany: async (args: { - where: { id: string; updatedAt?: Date; insightsCachedText?: string }; - data: Partial; - }) => { - const row = store.users.find((x) => x.id === args.where.id); - if ( - row && - args.where.insightsCachedText !== undefined && - row.insightsCachedText !== args.where.insightsCachedText - ) { - return { count: 0 }; - } - // Prisma stamps `@updatedAt` on every write that does not set it. - const data = - "updatedAt" in args.data - ? args.data - : { ...args.data, updatedAt: new Date() }; - return base.updateMany({ ...args, data }); - }, - }; - })(), }; return { prisma: { @@ -178,9 +138,6 @@ import { } from "@/lib/jobs/free-text-encryption-backfill"; const KEY = "a".repeat(64); -const CACHE = JSON.stringify({ - dailyBriefing: { paragraph: "Your pressure eased after the new tablets." }, -}); const TRACK = { type: "LineString", coordinates: [ @@ -268,21 +225,6 @@ beforeEach(() => { }, { id: "r3", userId: "u2", geometry: TRACK, geometryEncrypted: null }, ]; - store.users = [ - { - id: "u1", - userId: "u1", - insightsCachedText: CACHE, - insightsCachedTextEncrypted: null, - updatedAt: new Date("2026-03-01T07:00:00.000Z"), - }, - { - id: "u2", - userId: "u2", - insightsCachedText: "other user", - insightsCachedTextEncrypted: null, - }, - ]; }); afterEach(() => { @@ -300,7 +242,6 @@ describe("runFreeTextEncryptionBackfillForUser", () => { routeGeometriesMigrated: 1, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 1, }); const c1 = store.conversations.find((r) => r.id === "c1")!; @@ -404,52 +345,12 @@ describe("runFreeTextEncryptionBackfillForUser", () => { routeGeometriesMigrated: 0, appointmentAddressesCleared: 0, contactAuditRowsScrubbed: 0, - insightsCachesMigrated: 0, }); expect(store.conversations.find((r) => r.id === "c1")!.titleEncrypted).toBe( sealed, ); }); - it("seals the cached insights payload, keeps updatedAt, and leaves another account's alone", async () => { - await runFreeTextEncryptionBackfillForUser("u1"); - const u1 = store.users.find((r) => r.id === "u1")!; - expect(u1.insightsCachedText).toBeNull(); - expect(u1.insightsCachedTextEncrypted).not.toBeNull(); - // Sealed, not merely encoded: the bytes do not carry the prose. - expect( - Buffer.from(u1.insightsCachedTextEncrypted!).toString("utf8"), - ).not.toContain("pressure"); - expect(openInsightsCachedText(u1)).toBe(CACHE); - // Profile forms use updatedAt as their concurrency token; a storage move - // must not invalidate an open form. - expect(u1.updatedAt).toEqual(new Date("2026-03-01T07:00:00.000Z")); - expect(store.users.find((r) => r.id === "u2")).toMatchObject({ - insightsCachedText: "other user", - insightsCachedTextEncrypted: null, - }); - }); - - it("does not overwrite a payload a warm sealed while the move was reading", async () => { - const newer = sealInsightsCachedText("newer"); - const u1 = store.users.find((r) => r.id === "u1")!; - // The warm lands between the backfill's read and its write: the read - // returns the old readable value, the row then holds the newer sealed one. - const { prisma } = await import("@/lib/db"); - const snapshot = { ...u1 }; - const spy = vi.spyOn(prisma.user, "findUnique").mockImplementationOnce((( - _args: unknown, - ) => { - u1.insightsCachedText = null; - u1.insightsCachedTextEncrypted = newer; - return Promise.resolve(snapshot); - }) as never); - await runFreeTextEncryptionBackfillForUser("u1"); - spy.mockRestore(); - expect(u1.insightsCachedTextEncrypted).toBe(newer); - expect(openInsightsCachedText(u1)).toBe("newer"); - }); - it("is fail-closed: without a key every readable row stays intact", async () => { vi.stubEnv("ENCRYPTION_KEY", ""); vi.stubEnv("ENCRYPTION_KEYS", ""); diff --git a/src/lib/jobs/free-text-encryption-backfill.ts b/src/lib/jobs/free-text-encryption-backfill.ts index 024861185..f7e7317d3 100644 --- a/src/lib/jobs/free-text-encryption-backfill.ts +++ b/src/lib/jobs/free-text-encryption-backfill.ts @@ -22,9 +22,9 @@ * column holds a JSON null rather than SQL NULL has nothing to seal; it is set * to SQL NULL so discovery stops finding it. * - * v1.43.7 adds the cached AI insights payload on the user row: - * `User.insightsCachedText` -> `insightsCachedTextEncrypted` (the model's - * briefing, per-domain readings and recommendations about the account). + * v1.43.7 sealed the cached AI insights payload on the user row the same + * way; v1.44.0 dropped its readable column (migration 0430), so that part of + * the pass is gone too. * * v1.42 (#615) added the environment module's coarse locations (home, dated * location periods, stored days). v1.43 dropped their readable columns @@ -71,7 +71,6 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; -import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; export const FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE = "free-text-encryption-backfill"; @@ -99,8 +98,6 @@ export interface FreeTextEncryptionBackfillSummary { appointmentAddressesCleared: number; /** v1.39.4 — edit audit rows whose old phone number / address was removed. */ contactAuditRowsScrubbed: number; - /** v1.43.7 — cached AI insights payloads sealed (0 or 1 per account). */ - insightsCachesMigrated: number; } /** The audit action whose older rows carried contact values. */ @@ -298,42 +295,6 @@ async function migrateRouteGeometry(id: string): Promise { }); } -/** - * Seal one account's cached AI insights payload into - * `insightsCachedTextEncrypted` and null the readable column, atomically and - * idempotently. The update is guarded on the readable value it read: a warm - * that lands meanwhile writes the ciphertext and nulls the readable column, so - * the guard misses and the newer payload is left as it is. - */ -async function migrateInsightsCache(id: string): Promise { - return prisma.$transaction(async (tx) => { - const fresh = await tx.user.findUnique({ - where: { id }, - select: { insightsCachedText: true, updatedAt: true }, - }); - if (!fresh || fresh.insightsCachedText === null) return false; - // FAIL-CLOSED: a key error throws here and rolls the tx back. A cache - // move is storage, not activity, so `updatedAt` is carried: the profile - // forms use it as their optimistic-concurrency token. Matching on it too - // means an edit that lands meanwhile wins; the row is retried next pass. - const { count } = await tx.user.updateMany({ - where: { - id, - updatedAt: fresh.updatedAt, - insightsCachedText: fresh.insightsCachedText, - }, - data: { - insightsCachedTextEncrypted: sealInsightsCachedText( - fresh.insightsCachedText, - ), - insightsCachedText: null, - updatedAt: fresh.updatedAt, - }, - }); - return count === 1; - }); -} - /** * Walk one kind of row page by page, migrating each. `page` returns the ids * still holding a readable value; each migration clears it, so a migrated row @@ -428,18 +389,6 @@ export async function runFreeTextEncryptionBackfillForUser( once, ); - // One row at most: the cache is a single slot on the user row. - const insightsCachesMigrated = await drain( - () => - prisma.user.findMany({ - where: { id: userId, insightsCachedText: { not: null } }, - select: { id: true }, - take: 1, - }), - migrateInsightsCache, - once, - ); - // An appointment reminder's address is resolved from the practitioner when // it fires; the readable copy earlier releases wrote here goes. const appointmentAddressesCleared = ( @@ -463,7 +412,6 @@ export async function runFreeTextEncryptionBackfillForUser( route_geometries_migrated: routeGeometriesMigrated, appointment_addresses_cleared: appointmentAddressesCleared, contact_audit_rows_scrubbed: contactAuditRowsScrubbed, - insights_caches_migrated: insightsCachesMigrated, }, }, }); @@ -475,7 +423,6 @@ export async function runFreeTextEncryptionBackfillForUser( routeGeometriesMigrated, appointmentAddressesCleared, contactAuditRowsScrubbed, - insightsCachesMigrated, }; } @@ -530,10 +477,6 @@ export async function enqueueBootTimeFreeTextEncryptionBackfill( WHERE action = ${CONTACT_AUDIT_ACTION} AND user_id IS NOT NULL AND (details LIKE '%"location":%' OR details LIKE '%"phone":%')`, - // The 0411 partial index matches this predicate. - prisma.$queryRaw<{ user_id: string }[]>` - SELECT id AS user_id FROM users - WHERE insights_cached_text IS NOT NULL`, ]); const userIds = new Set(); diff --git a/src/lib/profile/__tests__/health-facts.test.ts b/src/lib/profile/__tests__/health-facts.test.ts index 8fb2c0a0b..2e3baf688 100644 --- a/src/lib/profile/__tests__/health-facts.test.ts +++ b/src/lib/profile/__tests__/health-facts.test.ts @@ -93,7 +93,6 @@ describe("encrypted health profile fact revisions", () => { where: { id: "user-1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, @@ -133,7 +132,6 @@ describe("encrypted health profile fact revisions", () => { where: { id: "user-1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, @@ -199,7 +197,6 @@ describe("encrypted health profile fact revisions", () => { where: { id: "user-1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, @@ -247,7 +244,6 @@ describe("encrypted health profile fact revisions", () => { where: { id: "user-1" }, data: { insightsCachedAt: null, - insightsCachedText: null, insightsCachedTextEncrypted: null, insightsCachedLocale: null, }, diff --git a/tests/integration/ai-optional-actions.test.ts b/tests/integration/ai-optional-actions.test.ts index 5a29d565a..255b4467a 100644 --- a/tests/integration/ai-optional-actions.test.ts +++ b/tests/integration/ai-optional-actions.test.ts @@ -24,6 +24,7 @@ import { type StateName, } from "./ai-optional-fixtures"; import { getPrismaClient } from "./setup"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -172,9 +173,11 @@ describe("insights/generate refuses by the briefing capability", () => { data: { // The read resolves (never calls) the chain, so the key must decrypt. aiAnthropicKeyEncrypted: encrypt("sk-ant-integration"), - insightsCachedText: JSON.stringify({ - dailyBriefing: { paragraph: "Held text.", keyFindings: [] }, - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: { paragraph: "Held text.", keyFindings: [] }, + }), + ), insightsCachedAt: new Date(), insightsCachedLocale: "en", }, diff --git a/tests/integration/ai-optional-mixed-routes.test.ts b/tests/integration/ai-optional-mixed-routes.test.ts index 4529cae12..2475a1ab5 100644 --- a/tests/integration/ai-optional-mixed-routes.test.ts +++ b/tests/integration/ai-optional-mixed-routes.test.ts @@ -23,6 +23,7 @@ import { type StateName, } from "./ai-optional-fixtures"; import { getPrismaClient } from "./setup"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -94,10 +95,12 @@ async function seed(recordId: string): Promise { where: { id: recordId }, data: { // Generated now: only text written today is served as today's read. - insightsCachedText: JSON.stringify({ - ...BRIEFING, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + ...BRIEFING, + briefingGeneratedAt: new Date().toISOString(), + }), + ), insightsCachedAt: new Date(), insightsCachedLocale: "en", }, diff --git a/tests/integration/briefing-cache-locale.test.ts b/tests/integration/briefing-cache-locale.test.ts index 0469710b9..8589571c3 100644 --- a/tests/integration/briefing-cache-locale.test.ts +++ b/tests/integration/briefing-cache-locale.test.ts @@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -67,10 +68,12 @@ async function seedUserWithCachedBriefing(cachedLocale: string | null) { locale: "de", insightsCachedAt: new Date(), // Generated now: only text written today is served as today's read. - insightsCachedText: JSON.stringify({ - dailyBriefing: BRIEFING, - briefingGeneratedAt: new Date().toISOString(), - }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ + dailyBriefing: BRIEFING, + briefingGeneratedAt: new Date().toISOString(), + }), + ), insightsCachedLocale: cachedLocale, // The briefing is shown only while its AI capability is available, so // the account has its own provider (presence only) and a receipt. diff --git a/tests/integration/consent-withdrawal-purge.test.ts b/tests/integration/consent-withdrawal-purge.test.ts index e80e75790..e18ea479c 100644 --- a/tests/integration/consent-withdrawal-purge.test.ts +++ b/tests/integration/consent-withdrawal-purge.test.ts @@ -20,6 +20,7 @@ import { purgeRegenerableAiText, withdrawConsent, } from "@/lib/consent/withdrawal"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; const BYTES = Buffer.from("ciphertext-stand-in", "utf8"); @@ -33,7 +34,9 @@ async function seed(username: string, kinds: string[]) { data: { username, email: `${username}@example.test`, - insightsCachedText: JSON.stringify({ briefing: "model prose" }), + insightsCachedTextEncrypted: sealInsightsCachedText( + JSON.stringify({ briefing: "model prose" }), + ), insightsCachedAt: new Date(), insightsSnapshotHash: "hash", }, @@ -153,7 +156,7 @@ async function snapshot(userId: string) { prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { - insightsCachedText: true, + insightsCachedTextEncrypted: true, insightsCachedAt: true, insightsSnapshotHash: true, }, @@ -207,7 +210,7 @@ describe("withdrawing the analysis consent", () => { const after = await snapshot(user.id); expect(after.user).toEqual({ - insightsCachedText: null, + insightsCachedTextEncrypted: null, insightsCachedAt: null, insightsSnapshotHash: null, }); diff --git a/tests/integration/free-text-encryption-backfill.test.ts b/tests/integration/free-text-encryption-backfill.test.ts index d4743a457..beb3ef479 100644 --- a/tests/integration/free-text-encryption-backfill.test.ts +++ b/tests/integration/free-text-encryption-backfill.test.ts @@ -30,6 +30,7 @@ import { runFreeTextEncryptionBackfillForUser, } from "@/lib/jobs/free-text-encryption-backfill"; import { + INSIGHTS_CACHE_SELECT, insightsCacheWrite, openInsightsCachedText, } from "@/lib/insights/cached-text-cipher"; @@ -498,73 +499,31 @@ describe("free-text encryption backfill (real Postgres)", () => { expect(second.sent).toEqual([]); }); - it("seals the cached insights payload, keeps updatedAt, and converges", async () => { + it("has no readable insights cache left after migration 0430", async () => { const prisma = getPrismaClient(); - await Promise.all([seedUser("ic-a"), seedUser("ic-b"), seedUser("ic-c")]); - const payload = JSON.stringify({ - dailyBriefing: { paragraph: "Pressure eased this week." }, - }); - // ic-a: written by the previous release, in the clear. + const columns = await prisma.$queryRaw<{ column_name: string }[]>` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'users' AND column_name LIKE 'insights_cached_text%' + ORDER BY column_name`; + expect(columns.map((c) => c.column_name)).toEqual([ + "insights_cached_text_encrypted", + ]); + const indexes = await prisma.$queryRaw<{ indexname: string }[]>` + SELECT indexname FROM pg_indexes + WHERE indexname = 'users_insights_cached_text_backfill_idx'`; + expect(indexes).toEqual([]); + + // The sealed column still round-trips through the one module. + await seedUser("ic-a"); await prisma.user.update({ where: { id: "ic-a" }, - data: { insightsCachedText: payload }, - }); - // ic-b: written the current way. - await prisma.user.update({ - where: { id: "ic-b" }, data: insightsCacheWrite("current"), }); - const before = await prisma.user.findUniqueOrThrow({ + const row = await prisma.user.findUniqueOrThrow({ where: { id: "ic-a" }, - select: { updatedAt: true }, + select: INSIGHTS_CACHE_SELECT, }); - - const { boss, sent } = makeCapturingBoss(); - setGlobalBoss(boss as never); - const discovered = await enqueueBootTimeFreeTextEncryptionBackfill(); - expect(discovered.error).toBeNull(); - expect(sent.map((s) => s.userId)).toEqual(["ic-a"]); - - const summary = await runFreeTextEncryptionBackfillForUser("ic-a"); - expect(summary.insightsCachesMigrated).toBe(1); - - const rowA = await prisma.user.findUniqueOrThrow({ - where: { id: "ic-a" }, - select: { - insightsCachedText: true, - insightsCachedTextEncrypted: true, - updatedAt: true, - }, - }); - expect(rowA.insightsCachedText).toBeNull(); - expect(rowA.insightsCachedTextEncrypted).not.toBeNull(); - expect(openInsightsCachedText(rowA)).toBe(payload); - expect(rowA.updatedAt).toEqual(before.updatedAt); - - const rowB = await prisma.user.findUniqueOrThrow({ - where: { id: "ic-b" }, - select: { insightsCachedText: true, insightsCachedTextEncrypted: true }, - }); - expect(openInsightsCachedText(rowB)).toBe("current"); - - const again = await runFreeTextEncryptionBackfillForUser("ic-a"); - expect(again.insightsCachesMigrated).toBe(0); - const second = makeCapturingBoss(); - setGlobalBoss(second.boss as never); - await enqueueBootTimeFreeTextEncryptionBackfill(); - expect(second.sent).toEqual([]); - }); - - it("has the partial index migration 0411 adds", async () => { - const indexes = await getPrismaClient().$queryRaw< - { indexname: string; indexdef: string }[] - >` - SELECT indexname, indexdef FROM pg_indexes - WHERE indexname = 'users_insights_cached_text_backfill_idx'`; - expect(indexes).toHaveLength(1); - expect(indexes[0].indexdef).toContain( - "WHERE (insights_cached_text IS NOT NULL)", - ); + expect(openInsightsCachedText(row)).toBe("current"); }); it("has the partial indexes migration 0361 adds", async () => { diff --git a/tests/integration/insights-provider-chain.test.ts b/tests/integration/insights-provider-chain.test.ts index aff86654d..017d27094 100644 --- a/tests/integration/insights-provider-chain.test.ts +++ b/tests/integration/insights-provider-chain.test.ts @@ -288,7 +288,7 @@ describe("POST /api/insights/generate — chain fallback", () => { const prisma = getPrismaClient(); await prisma.user.update({ where: { username: "chain-user" }, - data: { insightsCachedAt: null, insightsCachedText: null }, + data: { insightsCachedAt: null, insightsCachedTextEncrypted: null }, }); // Second call: cache should reorder so Anthropic is the first diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 04ebedfe0..16f1a0d28 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -33,6 +33,7 @@ import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; +import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -292,7 +293,9 @@ frontDoorRead("GET /api/daily/digest", { await getPrismaClient().user.update({ where: { id: userId }, data: { - insightsCachedText: cachedBriefing(marker), + insightsCachedTextEncrypted: sealInsightsCachedText( + cachedBriefing(marker), + ), insightsCachedAt: new Date(), ...ownProvider(), },