From 9941db15497c1f10ef8d36d5d339cb9d66db5aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:24:21 +0200 Subject: [PATCH] refactor(insights): drop the readable cached-text column Every write has gone to insights_cached_text_encrypted since 0411, and the boot-time free-text backfill sealed the rows written before it. Migration 0430 drops insights_cached_text and the backfill index that served it. A host that skipped the intervening releases loses only a cache of model output; the next warm writes a fresh, sealed payload. The cipher module now reads and writes the sealed column only, the backfill job loses its insights step, and consent withdrawal, the wipe plan, the backup plan and the session projection follow the schema. Backups never carried the column, and restore ignores unknown account settings; a round-trip test now proves an old backup naming the field still restores cleanly. The access guard pins exact counts for where the sealed name may appear. Fixtures, the demo seed and the demo migration script seed the sealed column instead of plaintext. --- e2e/setup/ai-optional-fixture.ts | 10 +- e2e/setup/global-setup.ts | 2 +- .../migration.sql | 15 ++ prisma/schema.prisma | 9 +- scripts/migrate-demo-edge01.sh | 4 +- scripts/seed-demo.ts | 4 +- .../insights-cached-text-access-guard.test.ts | 64 +++---- .../session-user-projection-guard.test.ts | 2 +- .../__tests__/route-module-gate.test.ts | 1 - src/app/api/daily/digest/route.ts | 2 +- src/app/api/dashboard/snapshot/route.ts | 2 +- .../health-record/__tests__/route.test.ts | 2 +- .../insights/generate/__tests__/route.test.ts | 159 +++++++++++------- src/app/api/insights/generate/route.ts | 2 +- .../insights/use-insights-advisor.ts | 4 +- src/lib/auth/session-user.ts | 4 +- src/lib/consent/withdrawal.ts | 2 +- src/lib/daily/digest.ts | 2 +- src/lib/daily/load-digest.ts | 2 +- .../snapshot-read-briefing-capability.test.ts | 12 +- .../snapshot-read-briefing-today.test.ts | 16 +- src/lib/dashboard/snapshot.ts | 4 +- src/lib/data-wipe/wipe-plan.ts | 1 - .../restore-backup-round-trip.test.ts | 37 +++- src/lib/export/backup-plan.ts | 1 - .../__tests__/cached-text-cipher.test.ts | 38 +---- ...ensive-briefing-grounding-disposal.test.ts | 9 +- .../comprehensive-briefing-reroll.test.ts | 11 +- .../comprehensive-budget-refusal.test.ts | 2 +- .../comprehensive-generate-force.test.ts | 49 ++++-- .../comprehensive-json-retry.test.ts | 2 +- .../comprehensive-outbound-repair.test.ts | 2 +- .../comprehensive-plan-progress.test.ts | 2 +- .../comprehensive-privacy-flip.test.ts | 23 ++- .../comprehensive-timeout-honored.test.ts | 4 +- .../comprehensive-warm-timeout.test.ts | 2 +- src/lib/insights/briefing-failure-marker.ts | 2 +- src/lib/insights/cached-text-cipher.ts | 75 +++------ src/lib/insights/comprehensive-generate.ts | 2 +- .../free-text-encryption-backfill.test.ts | 99 ----------- src/lib/jobs/free-text-encryption-backfill.ts | 63 +------ .../profile/__tests__/health-facts.test.ts | 4 - tests/integration/ai-optional-actions.test.ts | 9 +- .../ai-optional-mixed-routes.test.ts | 11 +- .../integration/briefing-cache-locale.test.ts | 11 +- .../consent-withdrawal-purge.test.ts | 9 +- .../free-text-encryption-backfill.test.ts | 79 +++------ .../insights-provider-chain.test.ts | 2 +- tests/integration/sharing-front-door.test.ts | 5 +- 49 files changed, 381 insertions(+), 497 deletions(-) create mode 100644 prisma/migrations/0430_drop_insights_cached_text/migration.sql 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(), },