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 1/8] 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(), }, From 07f9615dbaed93c234e66f0aed224ce2c574435f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:36:06 +0200 Subject: [PATCH 2/8] feat(ops): name 1536 MB as the supported memory floor The getting-started requirements now say a host needs 3 GB of RAM, 4 GB recommended, and drop the advice to lower APP_MEMORY_LIMIT to 1g on a 2 GB host. The compose and .env example comments say the same. At boot the app reads its cgroup memory limit (v2, then v1) and logs one line when it is below MEMORY_FLOOR_MB. The support report adds the container limit and a note below the floor, and Admin > System status shows "below the supported floor" next to the existing low-heap flag. The app keeps running either way. --- .env.production.example | 8 +- docker-compose.yml | 7 +- docs/self-hosting/getting-started.md | 18 ++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + .../api/admin/status/__tests__/route.test.ts | 43 +++++++++- src/app/api/admin/status/route.ts | 18 +++- src/components/admin/_shared.tsx | 15 +++- .../admin/system-status-section.tsx | 19 ++++- src/instrumentation.ts | 9 ++ .../support/__tests__/memory-floor.test.ts | 69 ++++++++++++++++ .../support-report-redaction.test.ts | 25 ++++++ src/lib/support/heap-limit.ts | 82 +++++++++++++++++++ src/lib/support/report.ts | 18 ++++ 20 files changed, 313 insertions(+), 26 deletions(-) create mode 100644 src/lib/support/__tests__/memory-floor.test.ts diff --git a/.env.production.example b/.env.production.example index 0428201a7..ecf31e293 100644 --- a/.env.production.example +++ b/.env.production.example @@ -533,9 +533,11 @@ API_TOKEN_HMAC_KEY="" # TRUST_CF_CONNECTING_IP="1" # Memory cap of the app container (read by docker-compose.yml, not by the app). -# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB, -# which a long-used account fills in normal operation. Lower it on a host with -# 2 GB or less, raise it on a busy instance. +# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the +# supported floor: below it a long-used account fills the heap in normal +# operation, and the boot log, the support report and Admin > System status +# say the app runs below the supported floor. The host needs 3 GB of RAM, +# 4 GB recommended. Raise it on a busy instance; do not lower it. # APP_MEMORY_LIMIT="1536m" # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index fb778bee4..af4954609 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -503,9 +503,10 @@ services: # workload can grow large enough to starve Postgres on a shared host. # Node sizes its heap from this limit: 1g gives a 524 MB heap, which a # long-used account fills in normal operation, so garbage collection - # stalls requests for seconds. 1536m gives 792 MB. The support report and - # Admin > System status flag a heap below 700 MB. On a host with 2 GB or less, lower it with - # APP_MEMORY_LIMIT in .env; on a busy instance, raise it. + # stalls requests for seconds. 1536m gives 792 MB and is the supported + # floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the + # support report and Admin > System status say so. On a busy instance, + # raise it with APP_MEMORY_LIMIT in .env; do not lower it. mem_limit: "${APP_MEMORY_LIMIT:-1536m}" healthcheck: test: diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index e9ee7aa1f..0541c2333 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -11,14 +11,16 @@ already installed, longer if a TLS-fronted public hostname is in scope pulls a multi-arch image from GitHub Container Registry (linux/amd64 - linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged. -- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with - measurement history; the Apple Health import worker can briefly - hold a 1.5 GB upload in `/tmp` while it parses. The app container is - capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its - heap at about 792 MB). On a 2 GB host that leaves too little for - Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env` - (a heap of about 524 MB); a long-used account then runs closer to - that heap, and the support report and Admin > System status say so. +- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data + volume grows with measurement history; the Apple Health import worker + can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app + container is capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node + then sizes its heap at about 792 MB), and Postgres and the system need + the rest. 1536 MB is the supported floor for the app container: do not + lower it to fit a smaller host. Below it, a long-used account or a large + import can fill the heap and restart the app; the app still starts, but + the boot log, the support report and Admin > System status say it runs + below the supported floor. On a busy instance, raise it. - **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever integration endpoints you plan to enable (Withings, OpenAI, etc.). The opt-in environmental-context module additionally reaches diff --git a/messages/de.json b/messages/de.json index b8afe311c..ba1ca12ef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heap-Grenze", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, zu knapp. APP_MEMORY_LIMIT=1536m in .env setzen", + "memoryBelowFloor": "Container {mb} MB, unter der unterstützten Untergrenze von {floor} MB. APP_MEMORY_LIMIT={floor}m in .env setzen", "offlineGeoOff": "Aus", "offlineGeoChosen": "Online ({host})", "apiGlobal": "API-Endpunkte und API-Tokens erlauben.", diff --git a/messages/en.json b/messages/en.json index 8ec8c0e61..3a6dfe846 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heap limit", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, low. Set APP_MEMORY_LIMIT=1536m in .env", + "memoryBelowFloor": "Container {mb} MB, below the supported floor of {floor} MB. Set APP_MEMORY_LIMIT={floor}m in .env", "offlineGeoOff": "Off", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Allow API endpoints and API tokens.", diff --git a/messages/es.json b/messages/es.json index 80928dc2d..0196a1f7b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Límite de heap", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, escaso: define APP_MEMORY_LIMIT=1536m en .env", + "memoryBelowFloor": "Contenedor {mb} MB, por debajo del mínimo admitido de {floor} MB: define APP_MEMORY_LIMIT={floor}m en .env", "offlineGeoOff": "Desactivado", "offlineGeoChosen": "En línea ({host})", "apiGlobal": "Permitir endpoints de API y tokens de API.", diff --git a/messages/fr.json b/messages/fr.json index 4f7058a57..d7109278c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limite du tas", "heapLimitValue": "{mb} Mo", "heapLimitLow": "{mb} Mo, trop juste : définissez APP_MEMORY_LIMIT=1536m dans .env", + "memoryBelowFloor": "Conteneur {mb} Mo, sous le minimum pris en charge de {floor} Mo : définissez APP_MEMORY_LIMIT={floor}m dans .env", "offlineGeoOff": "Désactivé", "offlineGeoChosen": "En ligne ({host})", "apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.", diff --git a/messages/it.json b/messages/it.json index a1dc08274..91db78f4b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limite heap", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, poco: imposta APP_MEMORY_LIMIT=1536m in .env", + "memoryBelowFloor": "Container {mb} MB, sotto il minimo supportato di {floor} MB: imposta APP_MEMORY_LIMIT={floor}m in .env", "offlineGeoOff": "Disattivato", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Consentire endpoint API e token API.", diff --git a/messages/ko.json b/messages/ko.json index 8712f3f1a..0708547d5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7575,6 +7575,7 @@ "heapLimitLabel": "힙 한도", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, 부족합니다. .env에 APP_MEMORY_LIMIT=1536m을 설정하세요", + "memoryBelowFloor": "컨테이너 {mb} MB, 지원 최소치 {floor} MB 미만입니다. .env에 APP_MEMORY_LIMIT={floor}m을 설정하세요", "offlineGeoOff": "꺼짐", "offlineGeoChosen": "온라인 ({host})", "apiGlobal": "API 엔드포인트와 API 토큰을 허용해요.", diff --git a/messages/nl.json b/messages/nl.json index dc0c5e5f0..c5e9d9a13 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heaplimiet", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, laag. Stel APP_MEMORY_LIMIT=1536m in via .env", + "memoryBelowFloor": "Container {mb} MB, onder het ondersteunde minimum van {floor} MB. Stel APP_MEMORY_LIMIT={floor}m in via .env", "offlineGeoOff": "Uit", "offlineGeoChosen": "Online ({host})", "apiGlobal": "API-endpoints en API-tokens toestaan.", diff --git a/messages/pl.json b/messages/pl.json index 625abe2a4..6d55a5d6d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limit sterty", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, za mało: ustaw APP_MEMORY_LIMIT=1536m w .env", + "memoryBelowFloor": "Kontener {mb} MB, poniżej obsługiwanego minimum {floor} MB: ustaw APP_MEMORY_LIMIT={floor}m w .env", "offlineGeoOff": "Wyłączone", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Zezwól na punkty końcowe API i tokeny API.", diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index 3bf2f8165..46a6b2a86 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -44,12 +44,19 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })), })); -const heapMock = vi.hoisted(() => ({ mb: 792 })); +const heapMock = vi.hoisted(() => ({ + mb: 792, + container: 1536 as number | null, +})); vi.mock("@/lib/support/heap-limit", async () => { const actual = await vi.importActual< typeof import("@/lib/support/heap-limit") >("@/lib/support/heap-limit"); - return { ...actual, heapLimitMb: () => heapMock.mb }; + return { + ...actual, + heapLimitMb: () => heapMock.mb, + containerMemoryLimitMb: () => heapMock.container, + }; }); const geoMock = vi.hoisted(() => ({ @@ -200,12 +207,40 @@ describe("GET /api/admin/status (integration health summary)", () => { const low = (await (await GET()).json()) as { data: { memory: { heapLimitMb: number; low: boolean } }; }; - expect(low.data.memory).toEqual({ heapLimitMb: 524, low: true }); + expect(low.data.memory).toMatchObject({ heapLimitMb: 524, low: true }); heapMock.mb = 792; const enough = (await (await GET()).json()) as { data: { memory: { heapLimitMb: number; low: boolean } }; }; - expect(enough.data.memory).toEqual({ heapLimitMb: 792, low: false }); + expect(enough.data.memory).toMatchObject({ heapLimitMb: 792, low: false }); + }); + + it("flags a container memory limit below the supported floor", async () => { + type Memory = { + containerLimitMb: number | null; + floorMb: number; + belowFloor: boolean; + }; + heapMock.container = 1024; + const below = (await (await GET()).json()) as { data: { memory: Memory } }; + expect(below.data.memory).toMatchObject({ + containerLimitMb: 1024, + floorMb: 1536, + belowFloor: true, + }); + heapMock.container = 1536; + const atFloor = (await (await GET()).json()) as { + data: { memory: Memory }; + }; + expect(atFloor.data.memory.belowFloor).toBe(false); + // No limit, or none readable: nothing to flag. + heapMock.container = null; + const none = (await (await GET()).json()) as { data: { memory: Memory } }; + expect(none.data.memory).toMatchObject({ + containerLimitMb: null, + belowFloor: false, + }); + heapMock.container = 1536; }); it("reports named failing queues through failingJobs only", async () => { diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index a8d9968d8..0cfd3a0c4 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -9,7 +9,13 @@ import { readFailingQueues, } from "@/lib/jobs/job-failures"; import { readFileSync } from "node:fs"; -import { heapLimitMb, isHeapLimitLow } from "@/lib/support/heap-limit"; +import { + containerMemoryLimitMb, + heapLimitMb, + isBelowMemoryFloor, + isHeapLimitLow, + MEMORY_FLOOR_MB, +} from "@/lib/support/heap-limit"; import { geoLookupMode, geoProviderChosen, @@ -77,6 +83,8 @@ export const GET = apiHandler(async () => { // The process's heap ceiling, and whether it is the 1 GB-container size // that a long-used account can fill (`heap-limit.ts`). const heapLimit = heapLimitMb(); + // And the container's own limit against the supported floor. + const containerLimit = containerMemoryLimitMb(); return apiSuccess({ version: process.env.npm_package_version || "0.1.0", @@ -85,7 +93,13 @@ export const GET = apiHandler(async () => { buildTime: BUILD_TIME, startTime: START_TIME, database: "connected", - memory: { heapLimitMb: heapLimit, low: isHeapLimitLow(heapLimit) }, + memory: { + heapLimitMb: heapLimit, + low: isHeapLimitLow(heapLimit), + containerLimitMb: containerLimit, + floorMb: MEMORY_FLOOR_MB, + belowFloor: isBelowMemoryFloor(containerLimit), + }, worker: { running: workerStatus.running, startedAt: workerStatus.startedAt, diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 69f00d639..09bd986b8 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -61,8 +61,19 @@ export interface SystemStatus { buildTime: string; startTime: string; database: string; - /** The process's heap ceiling; `low` when it is below what a long-used account needs. */ - memory: { heapLimitMb: number; low: boolean }; + /** + * The process's heap ceiling; `low` when it is below what a long-used + * account needs. `containerLimitMb` is the container's memory limit + * (`null` when there is none or it cannot be read), `belowFloor` when it is + * under the supported floor of `floorMb`. + */ + memory: { + heapLimitMb: number; + low: boolean; + containerLimitMb: number | null; + floorMb: number; + belowFloor: boolean; + }; worker: WorkerStatus; /** * Queues whose jobs exhausted their retries recently. `null` means the diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 8f520e46c..c23d4c0d9 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -164,11 +164,22 @@ export function SystemStatusSection() { icon={MemoryStick} label={t("admin.heapLimitLabel")} value={ - status.memory.low - ? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb }) - : t("admin.heapLimitValue", { mb: status.memory.heapLimitMb }) + status.memory.belowFloor && status.memory.containerLimitMb + ? t("admin.memoryBelowFloor", { + mb: status.memory.containerLimitMb, + floor: status.memory.floorMb, + }) + : status.memory.low + ? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb }) + : t("admin.heapLimitValue", { + mb: status.memory.heapLimitMb, + }) + } + tone={ + status.memory.belowFloor || status.memory.low + ? "warning" + : undefined } - tone={status.memory.low ? "warning" : undefined} /> { + it("is the compose default", () => { + expect(MEMORY_FLOOR_MB).toBe(1536); + }); + + it("parses cgroup limit files", () => { + expect(parseCgroupMemoryLimit(`${1536 * MiB}\n`)).toBe(1536); + expect(parseCgroupMemoryLimit(`${1024 * MiB}`)).toBe(1024); + expect(parseCgroupMemoryLimit("max\n")).toBeNull(); + // cgroup v1 reports "no limit" as a huge page-aligned sentinel. + expect(parseCgroupMemoryLimit("9223372036854771712")).toBeNull(); + expect(parseCgroupMemoryLimit("")).toBeNull(); + expect(parseCgroupMemoryLimit("garbage")).toBeNull(); + expect(parseCgroupMemoryLimit("0")).toBeNull(); + }); + + it("reads cgroup v2 first, then v1, and gives null without either", () => { + const files: Record = { + "/sys/fs/cgroup/memory/memory.limit_in_bytes": `${1024 * MiB}`, + }; + const read = (path: string) => { + if (path in files) return files[path]; + throw new Error("ENOENT"); + }; + expect(containerMemoryLimitMb(read)).toBe(1024); + files["/sys/fs/cgroup/memory.max"] = `${2048 * MiB}`; + expect(containerMemoryLimitMb(read)).toBe(2048); + files["/sys/fs/cgroup/memory.max"] = "max"; + expect(containerMemoryLimitMb(read)).toBeNull(); + expect( + containerMemoryLimitMb(() => { + throw new Error("ENOENT"); + }), + ).toBeNull(); + }); + + it("flags only a known limit under the floor", () => { + expect(isBelowMemoryFloor(1024)).toBe(true); + expect(isBelowMemoryFloor(1535)).toBe(true); + expect(isBelowMemoryFloor(1536)).toBe(false); + expect(isBelowMemoryFloor(4096)).toBe(false); + expect(isBelowMemoryFloor(null)).toBe(false); + }); + + it("logs exactly one line at boot below the floor, none otherwise", () => { + const warn = vi.fn(); + expect(logMemoryFloorAtBoot(1024, warn)).toBe(1024); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch( + /^\[boot\] The container memory limit of 1024 MB is below the supported floor of 1536 MB\./, + ); + expect(warn.mock.calls[0][0]).not.toContain("\n"); + warn.mockClear(); + logMemoryFloorAtBoot(1536, warn); + logMemoryFloorAtBoot(null, warn); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/support/__tests__/support-report-redaction.test.ts b/src/lib/support/__tests__/support-report-redaction.test.ts index 72bc7f1ea..f42c18c92 100644 --- a/src/lib/support/__tests__/support-report-redaction.test.ts +++ b/src/lib/support/__tests__/support-report-redaction.test.ts @@ -57,6 +57,7 @@ function seededFacts(): SupportReportFacts { node: "v22.11.0", processType: "web,worker", heapLimitMb: 792, + containerMemoryLimitMb: 1536, uptimeSeconds: 7260, }, database: { @@ -219,6 +220,30 @@ describe("support report redaction", () => { expect(enough).not.toContain("Heap limit note"); }); + it("says when the container runs below the supported memory floor", () => { + const facts = seededFacts(); + const below = redactSupportText( + renderSupportReport({ + ...facts, + build: { ...facts.build, containerMemoryLimitMb: 1024 }, + }), + CONTEXT, + ); + expect(below).toContain("- Container memory limit: 1024 MB"); + expect(below).toContain( + "- Memory note: Below the supported floor. The container memory limit of 1024 MB is below the supported floor of 1536 MB.", + ); + const atFloor = renderSupportReport(facts); + expect(atFloor).toContain("- Container memory limit: 1536 MB"); + expect(atFloor).not.toContain("Memory note"); + const unknown = renderSupportReport({ + ...facts, + build: { ...facts.build, containerMemoryLimitMb: null }, + }); + expect(unknown).toContain("- Container memory limit: none or unreadable"); + expect(unknown).not.toContain("Memory note"); + }); + it("the seeds really are in the unredacted text", () => { const raw = renderSupportReport(seededFacts()); for (const value of FORBIDDEN) expect(raw).toContain(value); diff --git a/src/lib/support/heap-limit.ts b/src/lib/support/heap-limit.ts index 80f0dcfc6..dcf6a512b 100644 --- a/src/lib/support/heap-limit.ts +++ b/src/lib/support/heap-limit.ts @@ -9,6 +9,7 @@ * system status say so in one sentence, because the number alone ("Heap * limit: 524 MB") reads as a fact rather than as the cause (#1239). */ +import { readFileSync } from "node:fs"; import { getHeapStatistics } from "node:v8"; /** Below this, the heap is flagged. Between the 1g (524) and 1536m (792) heaps. */ @@ -38,3 +39,84 @@ export function lowHeapLimitAdvice(limitMb: number): string { "docker compose up -d." ); } + +/** + * The smallest container memory limit HealthLog supports: the compose + * default. Below it the heap is too small for a long-used account and a + * large import, so the boot log, the support report and Admin > System + * status say the host runs below the supported floor. The app keeps + * running; the floor is advice, not a gate. + */ +export const MEMORY_FLOOR_MB = 1536; + +/** + * The cgroup files that hold a container's memory limit: cgroup v2 first, + * then v1. A v1 host without a limit reports a huge sentinel value rather + * than "max", so anything at or above 1 TiB counts as unlimited. + */ +const CGROUP_LIMIT_FILES = [ + "/sys/fs/cgroup/memory.max", + "/sys/fs/cgroup/memory/memory.limit_in_bytes", +] as const; +const UNLIMITED_BYTES = 1024 ** 4; + +/** Parse one cgroup limit file's content; `null` for no limit or garbage. */ +export function parseCgroupMemoryLimit(raw: string): number | null { + const text = raw.trim(); + if (text === "" || text === "max") return null; + if (!/^\d+$/.test(text)) return null; + const bytes = Number(text); + if (!Number.isFinite(bytes) || bytes <= 0 || bytes >= UNLIMITED_BYTES) { + return null; + } + return megabytes(bytes); +} + +/** + * This process's container memory limit in megabytes, or `null` when there + * is none or it cannot be read (no cgroup, not Linux, unlimited). + */ +export function containerMemoryLimitMb( + read: (path: string) => string = (path) => readFileSync(path, "utf8"), +): number | null { + for (const file of CGROUP_LIMIT_FILES) { + let raw: string; + try { + raw = read(file); + } catch { + continue; + } + return parseCgroupMemoryLimit(raw); + } + return null; +} + +/** Whether a known container limit is below the supported floor. */ +export function isBelowMemoryFloor(limitMb: number | null): boolean { + return limitMb !== null && limitMb < MEMORY_FLOOR_MB; +} + +/** The sentence the boot log and the support report use for it. */ +export function memoryFloorAdvice(limitMb: number): string { + return ( + `The container memory limit of ${limitMb} MB is below the supported ` + + `floor of ${MEMORY_FLOOR_MB} MB. HealthLog keeps running, but a ` + + "long-used account or a large import can fill the heap and restart " + + `the app. Set APP_MEMORY_LIMIT=${MEMORY_FLOOR_MB}m or more in .env and ` + + "run docker compose up -d; the host needs 3 GB of RAM, 4 GB recommended." + ); +} + +/** + * Log one line at boot when the container runs below the floor. Never + * throws. Returns the limit it found, for tests. + */ +export function logMemoryFloorAtBoot( + limitMb: number | null = containerMemoryLimitMb(), + warn: (line: string) => void = console.warn, +): number | null { + if (isBelowMemoryFloor(limitMb)) { + warn(`[boot] ${memoryFloorAdvice(limitMb as number)}`); + } + return limitMb; +} diff --git a/src/lib/support/report.ts b/src/lib/support/report.ts index 1160b0327..d186ee20b 100644 --- a/src/lib/support/report.ts +++ b/src/lib/support/report.ts @@ -22,10 +22,13 @@ import { prisma } from "@/lib/db"; import { failureCondition } from "@/lib/jobs/failure-condition"; import { + containerMemoryLimitMb, heapLimitMb, + isBelowMemoryFloor, isHeapLimitLow, lowHeapLimitAdvice, megabytes, + memoryFloorAdvice, } from "@/lib/support/heap-limit"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; @@ -85,6 +88,8 @@ export interface SupportReportFacts { node: string; processType: string; heapLimitMb: number; + /** The container's memory limit; `null` when there is none or it cannot be read. */ + containerMemoryLimitMb: number | null; uptimeSeconds: number; }; database: { @@ -617,6 +622,7 @@ export async function collectSupportReportFacts( node: process.version, processType: getProcessType(), heapLimitMb: heapLimitMb(), + containerMemoryLimitMb: containerMemoryLimitMb(), uptimeSeconds: Math.round(process.uptime()), }, database, @@ -730,6 +736,18 @@ export function renderSupportReport(facts: SupportReportFacts): string { row("Built at", facts.build.builtAt); row("Node", facts.build.node); row("Process type", facts.build.processType); + row( + "Container memory limit", + facts.build.containerMemoryLimitMb === null + ? "none or unreadable" + : `${facts.build.containerMemoryLimitMb} MB`, + ); + if (isBelowMemoryFloor(facts.build.containerMemoryLimitMb)) { + row( + "Memory note", + `Below the supported floor. ${memoryFloorAdvice(facts.build.containerMemoryLimitMb as number)}`, + ); + } row("Heap limit", `${facts.build.heapLimitMb} MB`); if (isHeapLimitLow(facts.build.heapLimitMb)) { row("Heap limit note", lowHeapLimitAdvice(facts.build.heapLimitMb)); From d44af0646782b107f2ef02f55f0bb6373ab34346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:37:22 +0200 Subject: [PATCH 3/8] docs: publish the UI standards in docs/contributing The binding UI rulebook (card anatomy, spacing scale, text colour, tokens, primitive mapping, enforcement) lived outside the repository, so a contributor could follow a code comment to a file they could not open. It now lives at docs/contributing/ui-standards.md, every comment that cited it points there, and the docs index and ui-guidelines.md say which document wins when they disagree. --- docs/README.md | 6 +- docs/contributing/ui-standards.md | 184 ++++++++++++++++++ docs/ui-guidelines.md | 9 +- e2e/settings-overscroll.spec.ts | 2 +- ...v137-sharing-managed-profiles-a11y.spec.ts | 2 +- eslint-plugins/healthlog/spacing-scale.js | 4 +- .../sharing-recoverable-errors.test.tsx | 2 +- src/app/achievements/page.tsx | 2 +- src/app/globals.css | 2 +- src/app/insights/workouts/[id]/page.tsx | 2 +- .../charts/nutrient-daily-bar-chart.tsx | 2 +- src/components/daily/today-hero.tsx | 2 +- src/components/day/day-sections.tsx | 2 +- .../insights/__tests__/hero-strip.test.tsx | 2 +- .../sleep-stage-stacked-bar.test.tsx | 2 +- .../chat-bubble-unverified-notice.test.tsx | 2 +- .../insights/coach-panel/chat-bubble.tsx | 4 +- src/components/insights/ecg-detail.tsx | 2 +- src/components/insights/hero-strip.tsx | 2 +- .../insights/insight-section-card.tsx | 2 +- .../insights/intraday-pulse-chart.tsx | 2 +- src/components/insights/labs-changes-card.tsx | 2 +- .../insights/medication-efficacy-summary.tsx | 2 +- .../insights/nutrients/hydration-card.tsx | 2 +- src/components/layout/auth-shell.tsx | 2 +- src/components/layout/nav-model.ts | 2 +- .../layout/shared-record-banner.tsx | 2 +- .../medications/medication-order-editor.tsx | 2 +- .../module-list/module-order-editor.tsx | 2 +- .../mood/manage/tag-groups-card.tsx | 2 +- .../mood/manage/tag-manager-card.tsx | 2 +- src/components/onboarding/step-heading.tsx | 2 +- .../managed-profile-affordances.test.tsx | 6 +- .../settings/access/grant-action-error.tsx | 2 +- .../settings/access/grant-invite-card.tsx | 2 +- src/components/settings/access/grant-row.tsx | 2 +- .../settings/access/managed-profile-card.tsx | 8 +- .../access/managed-profile-create-form.tsx | 2 +- .../access/managed-profile-edit-form.tsx | 2 +- .../access/managed-profile-guardians.tsx | 2 +- .../access/managed-profile-handover.tsx | 2 +- .../settings/dashboard-layout-section.tsx | 2 +- .../report-selection/report-scope-picker.tsx | 2 +- .../settings/share-link-create-form.tsx | 2 +- src/components/ui/query-error-row.tsx | 2 +- tests/fixtures/v137/e2e-journeys.ts | 2 +- 46 files changed, 244 insertions(+), 55 deletions(-) create mode 100644 docs/contributing/ui-standards.md diff --git a/docs/README.md b/docs/README.md index 34465adf4..aabff21cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,7 +56,11 @@ notes, and machine-readable specs that ship alongside the source: — the iOS submission gate. - [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT OAuth protocol reverse-engineering notes. -- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style. +- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the + binding UI standards: card anatomy, spacing, text colour, tokens and + which primitive to use where. Read before touching any surface. +- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where + it disagrees with the UI standards, the standards win. If you're a self-hoster looking to spin up an instance, start at or jump to the [Quick Start in the project diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md new file mode 100644 index 000000000..f2c982788 --- /dev/null +++ b/docs/contributing/ui-standards.md @@ -0,0 +1,184 @@ +# UI standards + +The binding reference for building or touching any surface of the web app. The rules are derived from the primitives' own contracts (`src/components/ui/card.tsx`, `tile-header.tsx`, `page-header.tsx`, `settings/_card-header.tsx`, `layout/auth-shell.tsx`) and from a review of every surface against them. When a rule here conflicts with an existing file, the file is drift: fix it when you touch it, don't copy it. Where an older code comment or [`docs/ui-guidelines.md`](../ui-guidelines.md) says otherwise, this document wins. A deviation needs a written reason in the pull request. + +--- + +## 1. Card anatomy + +**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap** — never from slot padding. + +- FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: ``. +- Two densities, nothing else: + - `default` — Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`. + - `compact` — `` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card). + - No `md:py-5` half-steps. +- Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input). +- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile) — never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`). +- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes ``, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`) — the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it. +- **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card. + +### Header patterns — exactly three, pick by surface + +| Surface | Primitive | Contract | +| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Page title | `PageHeader` (`ui/page-header.tsx`) | H1 `text-2xl font-bold tracking-tight`, description `text-muted-foreground text-sm`, `space-y-1.5`, **no icon beside the H1**, optional backLink + actions slot | +| Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below | +| Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot | + +**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent — primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node — the primitive owns size and colour. + +The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them — do not invent a fourth header language. + +## 2. Spacing scale + +> **Scroll height is shell-owned (over-scroll class, #154 lineage).** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
` — nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. + +- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication-card rhythm uses them docblock-sanctioned across ~78 sites and the in-between steps earn their keep in dense card interiors — reality wins over a sweep that would only churn settled surfaces. `5` (20 px) and `py-5`, `p-5` are banned in new code, swept on touch. `7` (28 px) is banned with them — it entered as the Settings/Admin `pl-7` body gutter and is gone (§13). +- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. +- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`. +- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding. +- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`. +- **Anchors:** `scroll-mt-28`, single token (31-to-1 in the tree already). + +## 3. Text colour rules + +The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers: + +- **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted. +- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader) — keep it; do not "fix" it to foreground. +- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `

` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run — it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it. +- **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot. +- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1 — below AA; they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor). +- **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node. +- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast — never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented. +- **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme. + +## 4. Colour token rules + +- **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune. +- **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values. +- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot — review for it explicitly until the rule covers it. +- **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`). +- Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`. + +## 5. Typography scale + +| Tier | Classes | Where | +| ------------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------- | +| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader` — weight is **bold**, always | +| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` | +| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` | +| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters — never muted prose | +| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings | +| Body | `text-sm` (foreground) | default content | +| Meta | `text-xs text-muted-foreground` | timestamps, units, hints — the floor | +| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only | + +No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling). + +## 6. Component mapping — when to use which primitive + +| Situation | Use | Never | +| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Page title + description + actions | `PageHeader` — `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) | +| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"` — one real `

`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `

` | +| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections | +| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` | +| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div | +| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box | +| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs | +| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it | +| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) | +| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` | +| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) | +| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset` — keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error | +| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}` — same decision as the other two | either of the above | +| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` | +| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) | +| Honest no-data state | `EmptyState` | reusing error copy — a failed query must never fall through to "no data yet" | +| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) | +| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) | +| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`) — tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `