fix(pwa): stop the offline cache from persisting sensitive health data

The offline layer wrote far too much to disk and cleared it too rarely.
The query persister used a denylist (only auth/session/admin/tokens), so
every other family — coach, insights, illness, labs, mood, cycle — was
dehydrated to IndexedDB in plaintext; the service worker likewise
prefix-cached GET /api/insights, which serves decrypted Coach
conversations. Neither store carried an account marker and both were
wiped only on explicit logout, so a session that ended by expiry or a
401 left health data on disk for the next account on a shared profile.

Flip the persister to a strict allowlist (dashboard snapshot, per-chart
series, widget layout — the offline-dashboard set only) and stamp each
payload with the live account id, discarding on mismatch. Drop
/api/insights and /api/analytics from the worker allowlist. Wipe the
IndexedDB blob and the data/page caches on every session end — the
401/expiry redirect and the logout path both route through one helper.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 18:18:24 +02:00
1 parent 3cf9a262ca
commit b474ea1489
7 files changed
+273 -59

No files matched your search

+6 -2
View File
@@ -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 `<path>/` / `<path>?` 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",
];
+8
View File
@@ -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]);
+9 -23
View File
@@ -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
+114 -12
View File
@@ -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<string, unknown>();
const makeReq = (run: () => void) => {
const req: Record<string, unknown> = {};
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<string, unknown> = { 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();
});
});
+13 -3
View File
@@ -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", () => {
+113 -16
View File
@@ -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<IDBDatabase> {
@@ -82,9 +102,23 @@ async function idbGet<T>(): Promise<T | undefined> {
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<void> {
if (typeof indexedDB === "undefined") return;
try {
@@ -198,3 +248,50 @@ export async function clearPersistedQueryCache(): Promise<void> {
/* 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<void> {
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<void> {
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 */
}
}
}
+10 -3
View File
@@ -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;