diff --git a/public/sw.js b/public/sw.js index e94b6d5f3..fb3bad369 100644 --- a/public/sw.js +++ b/public/sw.js @@ -61,13 +61,17 @@ const MAX_DATA_ENTRIES = 60; // (mirrors the idempotency cache's `hlk_`/`hlr_`/`sk-` refusal) so a future // endpoint that slipped onto the list can still never persist a secret. // Matched by exact path or `/` / `?` prefix. +// v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`) +// are deliberately NOT cached: `/api/insights` prefix-matched the Coach chat +// endpoint into the disk cache, and neither belongs on a self-hosted health +// PWA's durable storage. Only the dashboard snapshot, the widget layout, the +// measurement reads (incl. the batched daily series), the medication reads, +// and the version probe are eligible. const API_READ_ALLOWLIST = [ "/api/dashboard/snapshot", "/api/dashboard/widgets", "/api/measurements", "/api/medications", - "/api/insights", - "/api/analytics", "/api/version", ]; diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index d95f9a2bc..8d4a27ba6 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -8,6 +8,7 @@ import { MaintainershipBanner } from "@/components/i18n/maintainership-banner"; import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; import { useAuth } from "@/hooks/use-auth"; +import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { BottomNav } from "./bottom-nav"; @@ -91,6 +92,13 @@ export function AuthShell({ useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { + // Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe + // every client-side cache that can hold the previous account's health + // data before bouncing to login, so it never leaks to the next account + // on a shared device. Logout does the same from `useLogout`; this covers + // the expiry path that logout never runs through. Best-effort; the + // redirect proceeds regardless. + void clearOfflineCachesForSessionEnd(); router.replace("/auth/login"); } }, [isLoading, isAuthenticated, isPublicPage, router]); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index d73adbca9..ba079e0c7 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -10,7 +10,7 @@ import { useTranslations } from "@/lib/i18n/context"; import type { TimeFormatPreference } from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; import type { ModuleKey } from "@/lib/modules/registry"; -import { clearPersistedQueryCache } from "@/lib/pwa/query-persister"; +import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; export interface AuthUser { id: string; @@ -195,28 +195,14 @@ export function useLogout() { onSuccess: () => { queryClient.setQueryData(queryKeys.authMe(), null); queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - // Defense-in-depth at the session boundary: wipe the service-worker - // page cache so no cached navigation HTML survives a logout on a - // shared device. Scoped to `healthlog-pages-*` only — the static - // cache (hashed chunks, icons) carries no PII and dropping it would - // force a needless re-download on the next login. Best-effort; never - // blocks the redirect. - if (typeof caches !== "undefined") { - void caches.keys().then((keys) => { - for (const key of keys) { - // v1.18.6 — also drop the offline read-data cache; it holds the - // last-synced health JSON and must never survive a logout. - if ( - key.startsWith("healthlog-pages-") || - key.startsWith("healthlog-data-") - ) { - void caches.delete(key); - } - } - }); - } - // Wipe the persisted IndexedDB query cache for the same reason. - void clearPersistedQueryCache(); + // Defense-in-depth at the session boundary: wipe every client-side cache + // that can hold this account's health data — the IndexedDB query + // snapshot, the SW offline read-data cache (`healthlog-data-*`), and the + // SW page cache (`healthlog-pages-*`, cached navigation HTML) — so + // nothing survives a logout on a shared device. The static cache (hashed + // chunks, icons) carries no PII and is left intact to avoid a needless + // re-download. Best-effort; never blocks the redirect. + void clearOfflineCachesForSessionEnd(); router.push("/auth/login"); }, // v1.16.4 — a network-failed logout used to do nothing at all (the diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index 161c6c14a..9ddf27bdb 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -8,11 +8,33 @@ import { startPersistingQueryCache, } from "@/lib/pwa/query-persister"; -describe("isPersistableKey — what survives to disk", () => { - it("persists health-data query families", () => { +describe("isPersistableKey — strict dashboard-offline allowlist", () => { + it("persists ONLY the dashboard snapshot + measurement daily-series families", () => { + // Dashboard snapshot — the unified first-paint read. expect(isPersistableKey(["dashboard", "snapshot"])).toBe(true); - expect(isPersistableKey(["measurements", "list"])).toBe(true); - expect(isPersistableKey(["medications"])).toBe(true); + // Per-chart daily series + the batched dashboard series. + expect(isPersistableKey(["chart-data", "WEIGHT"])).toBe(true); + expect(isPersistableKey(["chart-data", "series-batch", "WEIGHT"])).toBe( + true, + ); + // The resolved widget layout the snapshot seeds — matched as an exact + // tuple under the overloaded `["user", …]` head. + expect(isPersistableKey(["user", "dashboardWidgets"])).toBe(true); + }); + + it("never persists clinical / narrative health families", () => { + // The denylist regression: each of these used to be dehydrated to disk + // in plaintext. They are health/clinical/narrative and must stay + // in-memory only. + expect(isPersistableKey(["coach", "conversations"])).toBe(false); + expect(isPersistableKey(["insights"])).toBe(false); + expect(isPersistableKey(["illness"])).toBe(false); + expect(isPersistableKey(["labs"])).toBe(false); + expect(isPersistableKey(["mood-entries"])).toBe(false); + expect(isPersistableKey(["medications"])).toBe(false); + expect(isPersistableKey(["cycle"])).toBe(false); + expect(isPersistableKey(["measurements", "list"])).toBe(false); + expect(isPersistableKey(["analytics"])).toBe(false); }); it("never persists auth / session / admin / token families", () => { @@ -22,6 +44,13 @@ describe("isPersistableKey — what survives to disk", () => { expect(isPersistableKey(["tokens"])).toBe(false); expect(isPersistableKey(["apiTokens"])).toBe(false); }); + + it("never persists the rest of the overloaded user head", () => { + expect(isPersistableKey(["user", "profile"])).toBe(false); + expect(isPersistableKey(["user", "ai-provider"])).toBe(false); + expect(isPersistableKey(["user", "insightsLayout"])).toBe(false); + expect(isPersistableKey(["user", "thresholds"])).toBe(false); + }); }); describe("persister degrades gracefully without IndexedDB", () => { @@ -120,9 +149,10 @@ describe("persister round-trips through a fake IndexedDB", () => { const source = new QueryClient(); source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 }); const stop = startPersistingQueryCache(source, "v1.18.6"); - // Force a flush by advancing past the 1s debounce. + // Force a flush by advancing past the 1s debounce. The trigger write uses + // an allowlisted family so the flush captures real persistable data. vi.useFakeTimers(); - source.setQueryData(["measurements", "list"], [{ id: "a" }]); + source.setQueryData(["chart-data", "WEIGHT"], [{ d: "2026-06-18", v: 81 }]); vi.advanceTimersByTime(1100); vi.useRealTimers(); await new Promise((r) => setTimeout(r, 0)); @@ -198,26 +228,98 @@ describe("persister round-trips through a fake IndexedDB", () => { }, }; - // Persist a STALE (empty) measurements list. + // Persist a STALE (empty) chart series under an allowlisted family. const source = new QueryClient(); - source.setQueryData(["measurements", "list"], []); + source.setQueryData(["chart-data", "WEIGHT"], []); const stop = startPersistingQueryCache(source, "v1.18.6"); vi.useFakeTimers(); - source.setQueryData(["measurements", "list"], []); + source.setQueryData(["chart-data", "WEIGHT"], []); vi.advanceTimersByTime(1100); vi.useRealTimers(); await new Promise((r) => setTimeout(r, 0)); stop(); - // The live client already fetched the FRESH (post-mutation) row. + // The live client already fetched the FRESH (post-mutation) series. const live = new QueryClient(); - live.setQueryData(["measurements", "list"], [{ id: "fresh-row" }]); + live.setQueryData(["chart-data", "WEIGHT"], [{ id: "fresh-row" }]); await restorePersistedQueryCache(live, "v1.18.6"); // The fresh row survived — the empty persisted snapshot did not win. - expect(live.getQueryData(["measurements", "list"])).toEqual([ + expect(live.getQueryData(["chart-data", "WEIGHT"])).toEqual([ { id: "fresh-row" }, ]); }); + + it("discards a snapshot stamped with a different account id", async () => { + const store = new Map(); + const makeReq = (run: () => void) => { + const req: Record = {}; + queueMicrotask(() => { + run(); + (req.onsuccess as (() => void) | undefined)?.(); + }); + return req; + }; + const fakeDb = { + close() {}, + transaction() { + return { + objectStore() { + return { + put(value: unknown, key: string) { + store.set(key, value); + return makeReq(() => {}); + }, + get(key: string) { + const req = makeReq(() => {}); + (req as { result?: unknown }).result = store.get(key); + return req; + }, + delete(key: string) { + store.delete(key); + return makeReq(() => {}); + }, + }; + }, + set oncomplete(fn: () => void) { + queueMicrotask(fn); + }, + set onerror(_fn: () => void) {}, + }; + }, + }; + (globalThis as { indexedDB?: unknown }).indexedDB = { + open() { + const req: Record = { result: fakeDb }; + queueMicrotask(() => (req.onsuccess as () => void)?.()); + return req; + }, + }; + + // Account "user-a" persists a dashboard snapshot. The persister stamps + // the payload with the id read from the live `["auth","me"]` cache entry. + const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); + source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 }); + const stop = startPersistingQueryCache(source, "v1.18.6"); + vi.useFakeTimers(); + source.setQueryData(["chart-data", "WEIGHT"], [{ v: 81 }]); + vi.advanceTimersByTime(1100); + vi.useRealTimers(); + await new Promise((r) => setTimeout(r, 0)); + stop(); + + // A DIFFERENT account ("user-b") restores on the same browser profile: + // the stamped owner mismatches the live account, so the snapshot is + // discarded rather than hydrating user-a's dashboard into user-b's cache. + const other = new QueryClient(); + other.setQueryData(["auth", "me"], { id: "user-b" }); + await restorePersistedQueryCache(other, "v1.18.6"); + expect(other.getQueryData(["dashboard", "snapshot"])).toBeUndefined(); + + // ...and the foreign snapshot is wiped from disk, so a later same-account + // restore can't resurrect it either. + expect(store.get("react-query")).toBeUndefined(); + }); }); diff --git a/src/lib/pwa/__tests__/sw-cache-policy.test.ts b/src/lib/pwa/__tests__/sw-cache-policy.test.ts index 0e6a68734..8d359a968 100644 --- a/src/lib/pwa/__tests__/sw-cache-policy.test.ts +++ b/src/lib/pwa/__tests__/sw-cache-policy.test.ts @@ -22,11 +22,21 @@ describe("isAllowlistedApiRead — offline read boundary", () => { isAllowlistedApiRead("/api/measurements/series-batch?types=WEIGHT"), ).toBe(true); expect(isAllowlistedApiRead("/api/medications")).toBe(true); - expect(isAllowlistedApiRead("/api/insights")).toBe(true); - expect(isAllowlistedApiRead("/api/analytics")).toBe(true); expect(isAllowlistedApiRead("/api/version")).toBe(true); }); + // v1.18.6 — AI/clinical narrative surfaces are never disk-cached. `/api/insights` + // previously prefix-matched the Coach chat list (`/api/insights/chat`, which + // returns decrypted conversation content) into the data cache; `/api/analytics` + // carries clinical aggregates. Both are off the allowlist. + it("never caches the AI / clinical narrative surfaces", () => { + expect(isAllowlistedApiRead("/api/insights")).toBe(false); + expect(isAllowlistedApiRead("/api/insights/chat")).toBe(false); + expect(isAllowlistedApiRead("/api/insights/generate")).toBe(false); + expect(isAllowlistedApiRead("/api/analytics")).toBe(false); + expect(isAllowlistedApiRead("/api/analytics/range")).toBe(false); + }); + it("never caches auth / session / token / login surfaces", () => { expect(isAllowlistedApiRead("/api/auth/me")).toBe(false); expect(isAllowlistedApiRead("/api/auth/logout")).toBe(false); @@ -38,7 +48,7 @@ describe("isAllowlistedApiRead — offline read boundary", () => { it("refuses an auth/token segment nested under an allowlisted prefix", () => { expect(isAllowlistedApiRead("/api/medications/tokens")).toBe(false); - expect(isAllowlistedApiRead("/api/insights/auth")).toBe(false); + expect(isAllowlistedApiRead("/api/measurements/auth")).toBe(false); }); it("does not cache endpoints outside the allowlist", () => { diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index cc85009ab..e512f54b7 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -8,10 +8,19 @@ * * Safety: * - Only successful (`status: "success"`) query results are persisted. - * - A small denylist keeps obviously sensitive query families out of disk - * (auth/session/admin/tokens), defence-in-depth alongside the SW boundary. - * - `clearPersistedQueryCache()` is called on logout so a shared device never - * leaks one account's cached health data to the next. + * - A strict ALLOWLIST keeps everything except the low-sensitivity + * dashboard-offline families off disk. This is the privacy floor: a + * denylist would persist every family it didn't name (cycle, mood, illness, + * labs, coach, insights, medications, …) in plaintext. Only the dashboard + * snapshot, its resolved widget layout, and the measurement daily-series — + * the data the dashboard needs to paint last-known values offline — are + * eligible. Clinical / narrative families are never written to disk. + * - `clearPersistedQueryCache()` is called on every session END (logout AND + * the 401 / session-expiry redirect), so a shared device never leaks one + * account's cached data to the next once the session is gone. + * - The payload is stamped with the current user id; a restore whose stored + * marker doesn't match the live account discards + clears the cache rather + * than hydrating a foreign account's data. * - Respects the centralised queryKey factory: it reads existing query keys, * it never invents new ones. */ @@ -29,20 +38,31 @@ const KEY = "react-query"; const VERSION_KEY = "react-query-version"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day -/** Query-key families never written to disk (defence in depth). */ -const PERSIST_DENYLIST = [ - "auth", - "session", - "sessions", - "admin", - "tokens", - "apiTokens", -]; +/** + * Strict allowlist of the only query families written to disk — the + * low-sensitivity reads the dashboard needs to paint last-known values when + * opened offline. Everything else (coach, insights, illness, labs, mood, + * medications, cycle, auth, admin, tokens, …) stays in memory only. + * + * - `["dashboard", …]` — the unified first-paint snapshot + * (`queryKeys.dashboardSnapshot()` → `["dashboard","snapshot"]`). + * - `["chart-data", …]` — the per-chart daily series + the batched dashboard + * series (`queryKeys.chartData(…)` / `queryKeys.chartSeriesBatch(…)`), the + * aggregated values the dashboard charts render. + * - `["user","dashboardWidgets"]` — the resolved widget layout the snapshot + * seeds, matched as an exact tuple so the rest of the overloaded `["user", …]` + * head (profile, AI provider, insights layout, thresholds) stays off disk. + */ +const PERSIST_ALLOWLIST_HEADS = ["dashboard", "chart-data"] as const; export function isPersistableKey(queryKey: readonly unknown[]): boolean { const head = queryKey[0]; - if (typeof head !== "string") return true; - return !PERSIST_DENYLIST.includes(head); + if (typeof head !== "string") return false; + if ((PERSIST_ALLOWLIST_HEADS as readonly string[]).includes(head)) { + return true; + } + // Only the dashboard widget layout under the overloaded `["user", …]` head. + return head === "user" && queryKey[1] === "dashboardWidgets"; } function openDb(): Promise { @@ -82,9 +102,23 @@ async function idbGet(): Promise { interface PersistedPayload { buildVersion: string; savedAt: number; + /** + * Per-account binding. The id of the account whose reads produced this + * snapshot, read from the live `["auth","me"]` cache entry at flush time + * (`null` when unknown). A restore whose live account differs discards the + * snapshot rather than hydrating a foreign account's dashboard data — a + * belt-and-suspenders guard alongside the session-end wipe. + */ + userId: string | null; state: DehydratedState; } +/** Read the current account id from the live `["auth","me"]` cache entry. */ +function currentUserId(queryClient: QueryClient): string | null { + const me = queryClient.getQueryData<{ id?: string } | null>(["auth", "me"]); + return me?.id ?? null; +} + /** * Restore persisted query data into the client (called once before the first * paint of the authenticated shell). A build-version or age mismatch discards @@ -106,6 +140,17 @@ export async function restorePersistedQueryCache( await clearPersistedQueryCache(); return; } + // Per-account binding: if the live account is known and differs from the + // snapshot's stored owner, this is a different user on the same browser + // profile — discard + clear rather than hydrate a foreign account's data. + // When the live id is not yet known (auth/me not resolved at restore time) + // we don't gate on it; the session-end wipe is the primary guarantee and + // the build-version + 24 h age gates still apply. + const liveUserId = currentUserId(queryClient); + if (liveUserId !== null && payload.userId !== liveUserId) { + await clearPersistedQueryCache(); + return; + } // Never clobber a query the live client already holds. `hydrate` calls // `setData` per dehydrated query unconditionally, so a snapshot restored @@ -163,6 +208,7 @@ export function startPersistingQueryCache( const payload: PersistedPayload = { buildVersion, savedAt: Date.now(), + userId: currentUserId(queryClient), state, }; void idbSet(payload).catch(() => { @@ -181,7 +227,11 @@ export function startPersistingQueryCache( }; } -/** Wipe the persisted cache. Call on logout (shared-device data hygiene). */ +/** + * Wipe the persisted query cache. Call on every session END — logout AND the + * 401 / session-expiry redirect — so a shared device never leaks one account's + * cached data to the next. + */ export async function clearPersistedQueryCache(): Promise { if (typeof indexedDB === "undefined") return; try { @@ -198,3 +248,50 @@ export async function clearPersistedQueryCache(): Promise { /* best effort */ } } + +/** + * Drop the service-worker offline read-data cache (`healthlog-data-*`). It + * holds the last-synced dashboard JSON and must never survive a session end on + * a shared device. Scoped to the data cache only — the static cache (hashed + * chunks, icons) carries no PII and dropping it would force a needless + * re-download. Best-effort; never throws. + */ +export async function clearServiceWorkerDataCache(): Promise { + if (typeof caches === "undefined") return; + try { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((k) => k.startsWith("healthlog-data-")) + .map((k) => caches.delete(k)), + ); + } catch { + /* best effort */ + } +} + +/** + * Wipe every client-side cache that can hold one account's health data: the + * IndexedDB query snapshot, the SW offline read-data cache, and the SW page + * cache (cached navigation HTML). The single call for a session END (logout or + * 401 / expiry redirect). Best-effort across the board. + */ +export async function clearOfflineCachesForSessionEnd(): Promise { + await clearPersistedQueryCache(); + if (typeof caches !== "undefined") { + try { + const keys = await caches.keys(); + await Promise.all( + keys + .filter( + (k) => + k.startsWith("healthlog-data-") || + k.startsWith("healthlog-pages-"), + ) + .map((k) => caches.delete(k)), + ); + } catch { + /* best effort */ + } + } +} diff --git a/src/lib/pwa/sw-cache-policy.ts b/src/lib/pwa/sw-cache-policy.ts index f733e58d6..b8d35a39b 100644 --- a/src/lib/pwa/sw-cache-policy.ts +++ b/src/lib/pwa/sw-cache-policy.ts @@ -14,14 +14,21 @@ * allowlisted by mistake — mirroring the idempotency cache's refusal. */ -/** Path prefixes whose safe GET reads are eligible for the offline data cache. */ +/** + * Path prefixes whose safe GET reads are eligible for the offline data cache. + * + * v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`) + * are excluded: `/api/insights` prefix-matched the Coach chat endpoint + * (`/api/insights/chat`) into the disk cache, and neither belongs on durable + * storage. Only the dashboard snapshot, the widget layout, the measurement + * reads (incl. the batched daily series), the medication reads, and the + * version probe are cached. + */ export const API_READ_ALLOWLIST = [ "/api/dashboard/snapshot", "/api/dashboard/widgets", "/api/measurements", "/api/medications", - "/api/insights", - "/api/analytics", "/api/version", ] as const;