feat(pwa): real offline read value via SW data cache + query persistence

An installed PWA opened offline rendered empty skeletons forever: the
service worker hard-excluded every /api/* path and there was no client
query persistence. Close both gaps for read value.

Service worker: add a stale-while-revalidate branch for an allowlist of
safe GET /api/* reads (dashboard snapshot + widgets, measurements,
medications, insights, analytics, version) in a release-versioned
healthlog-data cache. Two-layer refusal — a path deny guard for any
auth/token/session/login segment plus a body-level secret refusal for
hlk_/hlr_/sk- shapes and no-store responses — so a secret-shaped body or
an auth surface can never be persisted. Mutations stay network-only.

Client: add a dependency-free IndexedDB query persister that hydrates the
last successful reads before the first authenticated paint, build-version
and age gated, debounced on cache change, with an auth/session/admin/token
denylist. Cleared on logout alongside the SW page and data caches so a
shared device never leaks one account's data.

Offline banner now states it is showing last-synced data when a cached
snapshot is present. Bump release to v1.18.6.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 15:18:48 +02:00
1 parent dd44ddeb70
commit bb08aa42dc
17 files changed
+660 -15

No files matched your search

+1 -1
View File
@@ -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.
+2 -1
View File
@@ -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."
+2 -1
View File
@@ -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."
+2 -1
View File
@@ -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."
+2 -1
View File
@@ -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."
+2 -1
View File
@@ -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."
+2 -1
View File
@@ -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."
+1 -1
View File
@@ -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",
+119 -4
View File
@@ -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 `<path>/` / `<path>?` 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
@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
+13 -1
View File
@@ -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 (
<div
role="status"
@@ -70,7 +79,10 @@ export function OfflineBanner() {
className="bg-warning/15 border-warning/40 text-foreground flex items-center justify-center gap-2 border-b px-3 py-2 text-xs"
>
<WifiOff className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="text-center">{t("offlineBanner.message")}</span>
<span className="text-center">
{t("offlineBanner.message")}
{hasCachedData ? ` ${t("offlineBanner.showingCached")}` : ""}
</span>
</div>
);
}
+32
View File
@@ -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}
>
<AppSettingsProvider>
<QueryPersistenceBridge />
<DashboardSnapshotPreloader />
{children}
<Toaster position="bottom-right" richColors />
+11 -1
View File
@@ -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
@@ -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<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;
},
};
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();
});
});
@@ -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);
});
});
+169
View File
@@ -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<IDBDatabase> {
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<void> {
const db = await openDb();
await new Promise<void>((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<T>(): Promise<T | undefined> {
const db = await openDb();
const result = await new Promise<T | undefined>((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<void> {
if (typeof indexedDB === "undefined") return;
try {
const payload = await idbGet<PersistedPayload>();
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<typeof setTimeout> | 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<void> {
if (typeof indexedDB === "undefined") return;
try {
const db = await openDb();
await new Promise<void>((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 */
}
}
+60
View File
@@ -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;
}