mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
3cf9a262ca
commit
b474ea1489
7 files changed
+273
-59
No files matched your search
+6
-2
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user