diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2490f1275..5fb3d8be5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.18.5 + version: 1.18.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 945324bc9..0d6890d30 100644 --- a/messages/de.json +++ b/messages/de.json @@ -191,7 +191,8 @@ "backToDashboard": "Zurück zum Dashboard" }, "offlineBanner": { - "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist." + "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", + "showingCached": "Zeigt deine zuletzt synchronisierten Daten." }, "demoBanner": { "message": "Demo-Modus — Änderungen werden nicht gespeichert." diff --git a/messages/en.json b/messages/en.json index 9285cc541..0895bb5d8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -191,7 +191,8 @@ "backToDashboard": "Back to dashboard" }, "offlineBanner": { - "message": "No connection — your changes will save once you're back online." + "message": "No connection — your changes will save once you're back online.", + "showingCached": "Showing your last synced data." }, "demoBanner": { "message": "Demo mode — changes are not saved." diff --git a/messages/es.json b/messages/es.json index 4eb44003b..ecc64c96c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -191,7 +191,8 @@ "backToDashboard": "Volver al panel" }, "offlineBanner": { - "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea." + "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea.", + "showingCached": "Mostrando tus últimos datos sincronizados." }, "demoBanner": { "message": "Modo demo — los cambios no se guardan." diff --git a/messages/fr.json b/messages/fr.json index 4a61eb47d..ea1705a4a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -191,7 +191,8 @@ "backToDashboard": "Retour au tableau de bord" }, "offlineBanner": { - "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne." + "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne.", + "showingCached": "Affichage de tes dernières données synchronisées." }, "demoBanner": { "message": "Mode démo — les modifications ne sont pas enregistrées." diff --git a/messages/it.json b/messages/it.json index 386325fff..3aef973cb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -191,7 +191,8 @@ "backToDashboard": "Torna alla dashboard" }, "offlineBanner": { - "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online." + "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online.", + "showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati." }, "demoBanner": { "message": "Modalità demo — le modifiche non vengono salvate." diff --git a/messages/pl.json b/messages/pl.json index 853a30a39..d91c6f7bd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -191,7 +191,8 @@ "backToDashboard": "Powrót do panelu" }, "offlineBanner": { - "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online." + "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online.", + "showingCached": "Wyświetlane są ostatnio zsynchronizowane dane." }, "demoBanner": { "message": "Tryb demo — zmiany nie są zapisywane." diff --git a/package.json b/package.json index 6b434e375..7d097d9ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.18.5", + "version": "1.18.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 32d3f1aa6..8735e44b8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,11 +36,60 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.18.4"; + /* @sw-version-fallback */ "v1.18.6"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; +// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` +// reads. Stale-while-revalidate: an installed PWA opened offline now renders +// the LAST cached dashboard/series instead of empty skeletons forever. +const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; const MAX_PAGE_ENTRIES = 30; +const MAX_DATA_ENTRIES = 60; + +// Safe-GET read allowlist. ONLY idempotent reads that render the core views +// and carry NO secret-shaped body. Auth, mutations and token endpoints are +// never listed — and `isCacheableApiResponse` is a second, body-level guard +// (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. +const API_READ_ALLOWLIST = [ + "/api/dashboard/snapshot", + "/api/dashboard/widgets", + "/api/measurements", + "/api/medications", + "/api/insights", + "/api/analytics", + "/api/version", +]; + +// Defence in depth: even an allowlisted path is refused if its pathname looks +// like an auth/token/secret surface. +const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i; + +function isAllowlistedApiRead(pathname) { + if (API_DENY_RE.test(pathname)) return false; + return API_READ_ALLOWLIST.some( + (p) => + pathname === p || + pathname.startsWith(p + "/") || + pathname.startsWith(p + "?"), + ); +} + +// Body-level secret refusal: never persist a body carrying a token/secret- +// shaped pattern even if the path was allowlisted by mistake. +const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/; + +function isCacheableApiResponse(response, bodyText) { + if (!response || !response.ok) return false; + const cacheControl = response.headers.get("Cache-Control") || ""; + if (/no-store/i.test(cacheControl)) return false; + if (typeof bodyText === "string" && SECRET_BODY_RE.test(bodyText)) { + return false; + } + return true; +} // App shell files to precache on install const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"]; @@ -64,7 +113,10 @@ self.addEventListener("activate", (event) => { .then((keys) => Promise.all( keys - .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE) + .filter( + (k) => + k !== STATIC_CACHE && k !== PAGE_CACHE && k !== DATA_CACHE, + ) .map((k) => caches.delete(k)), ), ), @@ -87,8 +139,15 @@ self.addEventListener("fetch", (event) => { // Only handle same-origin GET requests if (request.method !== "GET" || url.origin !== self.location.origin) return; - // API calls — network only (never serve stale data) - if (url.pathname.startsWith("/api/")) return; + // API calls. Allowlisted safe GET reads use stale-while-revalidate so the + // installed PWA renders last-synced data offline; everything else (auth, + // mutations, anything not on the list) stays network-only. + if (url.pathname.startsWith("/api/")) { + if (isAllowlistedApiRead(url.pathname)) { + event.respondWith(staleWhileRevalidateApi(request)); + } + return; + } // Next.js static assets — cache-first (immutable hashed filenames) if (url.pathname.startsWith("/_next/static/")) { @@ -126,6 +185,62 @@ async function cacheFirst(request, cacheName) { return response; } +/** + * Stale-while-revalidate for allowlisted safe GET `/api/*` reads. + * + * Serves the cached copy immediately (instant offline value) while a + * background fetch refreshes it. With no cache yet it awaits the network and, + * if that fails offline, returns a JSON 503 so the client's TanStack Query + * cache / IndexedDB persister (also added in v1.18.6) supplies the data. + * + * Caching is gated twice: `isAllowlistedApiRead` already filtered the path, + * and `isCacheableApiResponse` inspects the actual body so a `no-store` or + * secret-shaped (`hlk_`/`hlr_`/`sk-`) response is never persisted — the same + * refusal discipline the idempotency cache uses. + */ +async function staleWhileRevalidateApi(request) { + const cache = await caches.open(DATA_CACHE); + const cached = await cache.match(request); + + const network = fetch(request) + .then(async (response) => { + // Read the body once to body-screen it, then reconstruct a fresh + // Response so both the cache.put and the return value have an + // unconsumed body. + const bodyText = await response + .clone() + .text() + .catch(() => undefined); + if (isCacheableApiResponse(response, bodyText)) { + const toStore = new Response(bodyText, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + await cache.put(request, toStore); + trimCache(DATA_CACHE, MAX_DATA_ENTRIES); + } + return response; + }) + .catch(() => null); + + if (cached) { + // Refresh in the background; don't block the cached response on it. + return cached; + } + + const fresh = await network; + if (fresh) return fresh; + + return new Response( + JSON.stringify({ data: null, error: "offline", meta: { offline: true } }), + { + status: 503, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); +} + /** * Privacy gate for the navigation page cache. Most app routes are * client-fetch-only shells with no server-rendered PII (health JSON loads diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index a70a35ae9..af43498ce 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -13,6 +13,7 @@ import { join } from "node:path"; import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { OfflineBanner } from "../offline-banner"; @@ -27,8 +28,14 @@ function loadMessages(locale: string): { offlineBanner: { message: string } } { } function render(node: React.ReactNode, locale: "en" | "de" = "en") { + // v1.18.6 — the banner now reads the cached dashboard snapshot to decide + // whether to append the "showing last synced data" hint, so it needs a + // QueryClient in context. + const queryClient = new QueryClient(); return renderToStaticMarkup( - {node}, + + {node} + , ); } diff --git a/src/components/layout/offline-banner.tsx b/src/components/layout/offline-banner.tsx index 23c0cff15..f95a247ec 100644 --- a/src/components/layout/offline-banner.tsx +++ b/src/components/layout/offline-banner.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from "react"; import { WifiOff } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; /** * v1.4.43 QoL (M5) — slim banner that surfaces "you're offline" while @@ -25,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context"; */ export function OfflineBanner() { const { t } = useTranslations(); + const queryClient = useQueryClient(); // Initialise to `true` so the banner stays hidden during SSR + the // first client render. The effect below flips it true/false after // mount based on `navigator.onLine`. (We do NOT read navigator on @@ -62,6 +65,12 @@ export function OfflineBanner() { if (isOnline) return null; + // v1.18.6 — when an offline install is showing last-synced data (the SW's + // allowlisted read cache + the IndexedDB query persister hydrated the + // dashboard snapshot), say so honestly rather than implying a blank screen. + const hasCachedData = + queryClient.getQueryData(queryKeys.dashboardSnapshot()) != null; + return (
); } diff --git a/src/components/providers.tsx b/src/components/providers.tsx index dfba33019..ad4e28da3 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -23,6 +23,12 @@ import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications"; +import { + restorePersistedQueryCache, + startPersistingQueryCache, +} from "@/lib/pwa/query-persister"; + +const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? ""; // ── Theme Context ──────────────────────────────────── @@ -147,6 +153,31 @@ function DashboardSnapshotPreloader() { return null; } +// ── Offline query persistence ──────────────────────── +// +// v1.18.6 — hydrate the last-synced query cache from IndexedDB before the +// first authenticated paint, then debounce-persist successful reads back. +// Combined with the service worker's allowlisted stale-while-revalidate API +// branch, an installed PWA opened offline renders last-known data instead of +// empty skeletons. Build-version + age gated; cleared on logout. +function QueryPersistenceBridge() { + const queryClient = useQueryClient(); + useEffect(() => { + let stop: (() => void) | undefined; + let cancelled = false; + void restorePersistedQueryCache(queryClient, SHELL_VERSION).finally(() => { + if (!cancelled) { + stop = startPersistingQueryCache(queryClient, SHELL_VERSION); + } + }); + return () => { + cancelled = true; + stop?.(); + }; + }, [queryClient]); + return null; +} + // ── Root Providers ─────────────────────────────────── export function Providers({ @@ -181,6 +212,7 @@ export function Providers({ initialMessages={initialMessages} > + {children} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index fefbbc258..d73adbca9 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -10,6 +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"; export interface AuthUser { id: string; @@ -203,10 +204,19 @@ export function useLogout() { if (typeof caches !== "undefined") { void caches.keys().then((keys) => { for (const key of keys) { - if (key.startsWith("healthlog-pages-")) void caches.delete(key); + // 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(); 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 new file mode 100644 index 000000000..8f085b01d --- /dev/null +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +import { + clearPersistedQueryCache, + isPersistableKey, + restorePersistedQueryCache, + startPersistingQueryCache, +} from "@/lib/pwa/query-persister"; + +describe("isPersistableKey — what survives to disk", () => { + it("persists health-data query families", () => { + expect(isPersistableKey(["dashboard", "snapshot"])).toBe(true); + expect(isPersistableKey(["measurements", "list"])).toBe(true); + expect(isPersistableKey(["medications"])).toBe(true); + }); + + it("never persists auth / session / admin / token families", () => { + expect(isPersistableKey(["auth", "me"])).toBe(false); + expect(isPersistableKey(["session"])).toBe(false); + expect(isPersistableKey(["admin", "users"])).toBe(false); + expect(isPersistableKey(["tokens"])).toBe(false); + expect(isPersistableKey(["apiTokens"])).toBe(false); + }); +}); + +describe("persister degrades gracefully without IndexedDB", () => { + const original = (globalThis as { indexedDB?: unknown }).indexedDB; + + beforeEach(() => { + // jsdom/node here has no indexedDB; assert the guards no-op cleanly. + delete (globalThis as { indexedDB?: unknown }).indexedDB; + }); + + afterEach(() => { + if (original !== undefined) { + (globalThis as { indexedDB?: unknown }).indexedDB = original; + } + }); + + it("restore resolves without throwing", async () => { + const qc = new QueryClient(); + await expect( + restorePersistedQueryCache(qc, "v1.18.6"), + ).resolves.toBeUndefined(); + }); + + it("start returns a callable no-op unsubscribe", () => { + const qc = new QueryClient(); + const stop = startPersistingQueryCache(qc, "v1.18.6"); + expect(typeof stop).toBe("function"); + expect(() => stop()).not.toThrow(); + }); + + it("clear resolves without throwing", async () => { + await expect(clearPersistedQueryCache()).resolves.toBeUndefined(); + }); +}); + +describe("persister round-trips through a fake IndexedDB", () => { + const original = (globalThis as { indexedDB?: unknown }).indexedDB; + + afterEach(() => { + if (original !== undefined) { + (globalThis as { indexedDB?: unknown }).indexedDB = original; + } else { + delete (globalThis as { indexedDB?: unknown }).indexedDB; + } + vi.restoreAllMocks(); + }); + + it("hydrates a saved success result and skips a foreign build version", async () => { + // Minimal in-memory IDB shim covering the open/put/get/delete the + // persister uses. Avoids pulling a new dev dependency. + 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; + }, + }; + + 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. + vi.useFakeTimers(); + source.setQueryData(["measurements", "list"], [{ id: "a" }]); + vi.advanceTimersByTime(1100); + vi.useRealTimers(); + await new Promise((r) => setTimeout(r, 0)); + stop(); + + const matchingBuild = new QueryClient(); + await restorePersistedQueryCache(matchingBuild, "v1.18.6"); + expect(matchingBuild.getQueryData(["dashboard", "snapshot"])).toEqual({ + weightKg: 81, + }); + + const foreignBuild = new QueryClient(); + await restorePersistedQueryCache(foreignBuild, "v9.9.9"); + expect(foreignBuild.getQueryData(["dashboard", "snapshot"])).toBeUndefined(); + }); +}); diff --git a/src/lib/pwa/__tests__/sw-cache-policy.test.ts b/src/lib/pwa/__tests__/sw-cache-policy.test.ts new file mode 100644 index 000000000..db93bd5b8 --- /dev/null +++ b/src/lib/pwa/__tests__/sw-cache-policy.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { + isAllowlistedApiRead, + isCacheableApiResponse, +} from "@/lib/pwa/sw-cache-policy"; + +describe("isAllowlistedApiRead — offline read boundary", () => { + it("caches the curated safe GET read endpoints", () => { + expect(isAllowlistedApiRead("/api/dashboard/snapshot")).toBe(true); + expect(isAllowlistedApiRead("/api/dashboard/widgets")).toBe(true); + expect(isAllowlistedApiRead("/api/measurements")).toBe(true); + expect( + 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); + }); + + it("never caches auth / session / token / login surfaces", () => { + expect(isAllowlistedApiRead("/api/auth/me")).toBe(false); + expect(isAllowlistedApiRead("/api/auth/logout")).toBe(false); + expect(isAllowlistedApiRead("/api/sessions")).toBe(false); + expect(isAllowlistedApiRead("/api/tokens")).toBe(false); + expect(isAllowlistedApiRead("/api/login")).toBe(false); + expect(isAllowlistedApiRead("/api/webauthn/options")).toBe(false); + }); + + 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); + }); + + it("does not cache endpoints outside the allowlist", () => { + expect(isAllowlistedApiRead("/api/admin/users")).toBe(false); + expect(isAllowlistedApiRead("/api/devices")).toBe(false); + expect(isAllowlistedApiRead("/api/withings/callback")).toBe(false); + }); + + it("does not match a prefix that is only a partial path segment", () => { + // `/api/measurements-export` must NOT match `/api/measurements`. + expect(isAllowlistedApiRead("/api/measurements-export")).toBe(false); + }); +}); + +describe("isCacheableApiResponse — body/header refusal", () => { + it("caches a successful read with a plain body", () => { + expect( + isCacheableApiResponse({ + ok: true, + cacheControl: "private, max-age=0", + bodyText: JSON.stringify({ data: { weightKg: 80 }, error: null }), + }), + ).toBe(true); + }); + + it("refuses non-ok responses", () => { + expect(isCacheableApiResponse({ ok: false, bodyText: "{}" })).toBe(false); + }); + + it("refuses a no-store response", () => { + expect( + isCacheableApiResponse({ + ok: true, + cacheControl: "no-store", + bodyText: "{}", + }), + ).toBe(false); + }); + + it("refuses secret-shaped bodies (hlk_ / hlr_ / sk-)", () => { + expect( + isCacheableApiResponse({ + ok: true, + bodyText: JSON.stringify({ data: { token: "hlk_abc123def" } }), + }), + ).toBe(false); + expect( + isCacheableApiResponse({ + ok: true, + bodyText: JSON.stringify({ data: { refresh: "hlr_xyz789ghi" } }), + }), + ).toBe(false); + expect( + isCacheableApiResponse({ + ok: true, + bodyText: JSON.stringify({ data: { key: "sk-livesecret00" } }), + }), + ).toBe(false); + }); +}); diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts new file mode 100644 index 000000000..3d7f5a578 --- /dev/null +++ b/src/lib/pwa/query-persister.ts @@ -0,0 +1,169 @@ +/** + * Minimal IndexedDB-backed TanStack Query persistence (v1.18.6). + * + * No external persister dependency: a tiny IDB key/value store plus a manual + * dehydrate-on-cache-change / hydrate-on-boot bridge. The goal is offline + * READ value — an installed PWA opened offline hydrates the last cached + * dashboard/series instantly instead of painting empty skeletons forever. + * + * 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. + * - Respects the centralised queryKey factory: it reads existing query keys, + * it never invents new ones. + */ + +import { + dehydrate, + hydrate, + type DehydratedState, + type QueryClient, +} from "@tanstack/react-query"; + +const DB_NAME = "healthlog-query-cache"; +const STORE = "kv"; +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", +]; + +export function isPersistableKey(queryKey: readonly unknown[]): boolean { + const head = queryKey[0]; + if (typeof head !== "string") return true; + return !PERSIST_DENYLIST.includes(head); +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function idbSet(value: unknown): Promise { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put(value, KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); +} + +async function idbGet(): Promise { + const db = await openDb(); + const result = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + const req = tx.objectStore(STORE).get(KEY); + req.onsuccess = () => resolve(req.result as T | undefined); + req.onerror = () => reject(req.error); + }); + db.close(); + return result; +} + +interface PersistedPayload { + buildVersion: string; + savedAt: number; + state: DehydratedState; +} + +/** + * Restore persisted query data into the client (called once before the first + * paint of the authenticated shell). A build-version or age mismatch discards + * the snapshot rather than hydrating a stale/foreign-schema cache. + */ +export async function restorePersistedQueryCache( + queryClient: QueryClient, + buildVersion: string, +): Promise { + if (typeof indexedDB === "undefined") return; + try { + const payload = await idbGet(); + if (!payload) return; + if (payload.buildVersion !== buildVersion) { + await clearPersistedQueryCache(); + return; + } + if (Date.now() - payload.savedAt > MAX_AGE_MS) { + await clearPersistedQueryCache(); + return; + } + hydrate(queryClient, payload.state); + } catch { + // Persistence is best-effort; never block boot on a cache restore. + } +} + +/** + * Subscribe to cache changes and debounce-persist successful, persistable + * queries to IndexedDB. Returns an unsubscribe function. + */ +export function startPersistingQueryCache( + queryClient: QueryClient, + buildVersion: string, +): () => void { + if (typeof indexedDB === "undefined") return () => {}; + + let timer: ReturnType | undefined; + + const flush = () => { + const state = dehydrate(queryClient, { + shouldDehydrateQuery: (query) => + query.state.status === "success" && isPersistableKey(query.queryKey), + }); + const payload: PersistedPayload = { + buildVersion, + savedAt: Date.now(), + state, + }; + void idbSet(payload).catch(() => { + /* best effort */ + }); + }; + + const unsubscribe = queryClient.getQueryCache().subscribe(() => { + if (timer) clearTimeout(timer); + timer = setTimeout(flush, 1000); + }); + + return () => { + if (timer) clearTimeout(timer); + unsubscribe(); + }; +} + +/** Wipe the persisted cache. Call on logout (shared-device data hygiene). */ +export async function clearPersistedQueryCache(): Promise { + if (typeof indexedDB === "undefined") return; + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(KEY); + tx.objectStore(STORE).delete(VERSION_KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } catch { + /* best effort */ + } +} diff --git a/src/lib/pwa/sw-cache-policy.ts b/src/lib/pwa/sw-cache-policy.ts new file mode 100644 index 000000000..f733e58d6 --- /dev/null +++ b/src/lib/pwa/sw-cache-policy.ts @@ -0,0 +1,60 @@ +/** + * Service-worker offline-read cache policy (v1.18.6). + * + * The runtime copy of this logic lives inline in `public/sw.js` (a service + * worker cannot import app modules at runtime). This module is the + * unit-tested source of truth for the safety boundary — the allowlist match, + * the auth/token deny guard, and the body-level secret refusal — so the SW's + * caching decisions are covered by `pnpm test`. Keep the two in lockstep when + * either changes. + * + * Boundary: ONLY idempotent GET reads that render the core views are cached. + * Auth/mutation/token surfaces are never cached, and a body carrying a + * secret-shaped token (`hlk_`/`hlr_`/`sk-`) is refused even if its path was + * allowlisted by mistake — mirroring the idempotency cache's refusal. + */ + +/** Path prefixes whose safe GET reads are eligible for the offline data cache. */ +export const API_READ_ALLOWLIST = [ + "/api/dashboard/snapshot", + "/api/dashboard/widgets", + "/api/measurements", + "/api/medications", + "/api/insights", + "/api/analytics", + "/api/version", +] as const; + +/** Auth/token surfaces refused even when nested under an allowlisted prefix. */ +export const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i; + +/** Secret-shaped token patterns refused at the response-body level. */ +export const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/; + +export function isAllowlistedApiRead(pathname: string): boolean { + if (API_DENY_RE.test(pathname)) return false; + return API_READ_ALLOWLIST.some( + (p) => + pathname === p || + pathname.startsWith(p + "/") || + pathname.startsWith(p + "?"), + ); +} + +/** + * Decide whether an allowlisted response may be persisted. `ok` + no + * `no-store` + no secret-shaped body. Mirrors `isCacheableApiResponse` in + * `public/sw.js`. + */ +export function isCacheableApiResponse(opts: { + ok: boolean; + cacheControl?: string | null; + bodyText?: string; +}): boolean { + if (!opts.ok) return false; + if (/no-store/i.test(opts.cacheControl || "")) return false; + if (typeof opts.bodyText === "string" && SECRET_BODY_RE.test(opts.bodyText)) { + return false; + } + return true; +}