From f4731345e6b5fc6daa319920c6ab631d9b93a041 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:52:48 +0800 Subject: [PATCH 1/5] feat(pwa): add privacy-safe offline app shell --- docs/codebase-index.md | 1 + docs/pwa.md | 339 +++++++++ next.config.ts | 27 + package.json | 1 + playwright.config.ts | 4 +- public/offline.html | 165 +++++ public/sw.js | 330 +++++++++ src/app/globals.css | 27 + src/app/layout.tsx | 11 +- src/app/manifest.ts | 41 +- .../clinical-dashboard/use-theme.ts | 10 +- src/components/pwa-lifecycle.tsx | 352 ++++++++++ src/lib/security-headers.ts | 2 + src/lib/theme.ts | 5 + src/proxy.ts | 14 +- tests/proxy-session-refresh.test.ts | 19 + tests/pwa-lifecycle.dom.test.tsx | 161 +++++ tests/pwa-manifest.test.ts | 80 +++ tests/pwa-service-worker.test.ts | 643 ++++++++++++++++++ tests/security-headers.test.ts | 7 + tests/theme.test.ts | 6 +- tests/ui-pwa.spec.ts | 283 ++++++++ 22 files changed, 2517 insertions(+), 11 deletions(-) create mode 100644 docs/pwa.md create mode 100644 public/offline.html create mode 100644 public/sw.js create mode 100644 src/components/pwa-lifecycle.tsx create mode 100644 tests/pwa-lifecycle.dom.test.tsx create mode 100644 tests/pwa-manifest.test.ts create mode 100644 tests/pwa-service-worker.test.ts create mode 100644 tests/ui-pwa.spec.ts diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 69e1de384..22b5a64bb 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -42,6 +42,7 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map - **Root layout:** `src/app/layout.tsx` — fonts, `AuthProvider`, global CSS - **App shell:** `src/components/clinical-dashboard/global-search-shell.tsx` — canonical route-aware shell and lazy dashboard dispatch. The mockup-named module is a compatibility re-export used only below `/mockups`. +- **PWA:** `docs/pwa.md` — install assets, privacy-first service worker/offline shell, lifecycle, security, and verification - **Home:** `src/app/page.tsx` — dashboard rendered by shell - **Dashboard:** `src/components/ClinicalDashboard.tsx` + `src/components/clinical-dashboard/` - **Modes (12):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, DSM-5 diagnosis, specifiers, formulation, prescribing, tools, Therapy Compass diff --git a/docs/pwa.md b/docs/pwa.md new file mode 100644 index 000000000..4e73e19b8 --- /dev/null +++ b/docs/pwa.md @@ -0,0 +1,339 @@ +# Progressive Web App architecture + +Clinical KB is an installable, production-first PWA with a deliberately limited offline surface. The service worker +improves launch, static-asset reuse, update handling, and failure messaging without turning private clinical data into +durable browser storage. + +The central invariant is: + +> CacheStorage may contain only the generic offline page and explicitly allow-listed public application assets. It +> must never contain clinical queries, answers, documents, uploads, signed URLs, account data, auth responses, API +> responses, RSC payloads, or user-specific HTML. + +This is a product and privacy boundary, not just a performance preference. Any change that broadens the cache +allowlist requires a privacy and security review. + +## Architecture map + +| Concern | Implementation | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Web app manifest | `src/app/manifest.ts` -> `/manifest.webmanifest` | +| Brand and install icons | `src/app/icon.svg`, `src/app/apple-icon.tsx`, `src/app/icons/[variant]/route.tsx`, `src/lib/brand-image.tsx` | +| Service worker | `public/sw.js` -> `/sw.js`, scope `/` | +| Generic offline document | `public/offline.html` -> `/offline.html` | +| Registration, install, update, and connectivity UI | `src/components/pwa-lifecycle.tsx`, mounted once in `src/app/layout.tsx` | +| PWA layout and safe-area styling | `src/app/globals.css` | +| Theme and browser chrome colour | `src/app/layout.tsx`, `src/lib/theme.ts`, `src/components/clinical-dashboard/use-theme.ts` | +| Resource-specific headers | `next.config.ts` | +| Page CSP and public PWA proxy bypass | `src/lib/security-headers.ts`, `src/proxy.ts` | + +The worker is intentionally hand-written and dependency-free. There is no Workbox runtime, generated precache +manifest, or framework HTML app-shell cache to conceal the privacy policy. + +## Installability + +The manifest defines a stable app identity and root scope: + +- `id`, `start_url`, and `scope` are `/`. +- `display` is `standalone`; the document viewport uses `viewport-fit=cover`. +- Language and direction are `en-AU` and `ltr`. +- Categories are `medical`, `productivity`, and `utilities`; related native applications are not preferred. +- The SVG icon is accompanied by generated 192 px and 512 px PNG icons for both `any` and `maskable` purposes. +- The 180 px Apple icon is opaque so iOS does not render transparency as black or fall back to a page screenshot. +- Manifest shortcuts open Ask, Documents, Medication guidance, and Differentials. They are launch shortcuts, not + offline features; each destination still requires the normal network/auth capabilities. +- `appleWebApp.capable`, title, and translucent status-bar metadata support Add to Home Screen on Apple platforms. + +Production installability requires HTTPS, a successful manifest response, reachable icons, and a successfully +registered service worker. Localhost is the browser's secure-context development exception. The custom install card +is shown only when the browser emits `beforeinstallprompt`; browsers that do not expose that event retain their own +install/Add to Home Screen flow. + +The install card is not shown in standalone mode. Choosing **Not now**, or dismissing the browser prompt, suppresses +the custom prompt for 30 days using `clinical-kb-pwa-install-dismissed-at` in localStorage. `appinstalled` clears that +value. Storage failures are treated as non-fatal progressive-enhancement failures. + +The manifest deliberately leaves orientation unrestricted so zoom, rotation, desktop windows, and split-screen use +remain available. Manifest screenshots are also omitted until current production UI can be captured and reviewed at +the declared form factors; prototype/mockup screenshots must not be advertised as the installed product. + +## Cache and privacy contract + +### Explicit allowlist + +The worker caches a request only when its class and every listed condition match. + +| Request class | Conditions | Strategy | Cache | +| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------- | +| `/offline.html` | Fetched during worker installation | Precached; used only after a navigation network failure | Shell, maximum 16 entries | +| `/icon.svg` and `/manifest.webmanifest` | Best-effort during installation | Precached; a failure does not block installation | Shell | +| `/_next/static/*` | Same-origin `GET`; no query string, `Authorization`, or `Range`; destination is `font`, `script`, `style`, or `worker` | Cache first, then network and store | Static, maximum 128 entries | +| `/manifest.webmanifest`, `/icon.svg`, `/apple-icon`, `/icons/*` | Same-origin `GET`; no query string, `Authorization`, or `Range` | Stale while revalidate | Shell, maximum 16 entries | + +Every allowlisted runtime and precache fetch uses `credentials: "omit"`. Before storage, the response must be an exact, +non-redirected, successful same-origin/basic response and must not carry private/no-store caching, attachment, +authentication-challenge, HTML-for-an-asset, or credential-varying metadata. A visible `Set-Cookie` is rejected as an +additional defense, but browser Fetch filters that header; the credential-free request plus proxy/browser no-cookie +assertions are the enforceable controls. Required precache entries also validate their expected MIME type. Cache +writes are bounded; the required offline document is pinned when older shell assets are pruned. + +On development hosts matched by `public/sw.js`'s `LOCAL_HOSTS` guard, the fetch handler does not runtime-cache Next.js +or PWA assets. This prevents development HMR and transient chunks from becoming durable. The installation step still +stores the generic offline fallback and best-effort bootstrap assets needed to exercise the worker. + +### Explicit denylist + +The service worker never writes these request/response classes to CacheStorage: + +- non-`GET` requests; +- cross-origin traffic, including Supabase Storage and signed document/image URLs; +- requests with query strings, `Authorization`, or `Range` headers; +- API, authentication, upload, ingestion, answer, search, registry, and account responses; +- RSC/Flight payloads and dynamic application HTML; +- clinical queries, answers, citations, documents, pages, images, file contents, or user-specific state; +- failed, opaque/cross-origin, private, `no-store`, or cookie-setting responses; +- arbitrary same-origin images or media outside the named PWA asset paths. + +Same-origin page navigations are intercepted only to provide the offline fallback. They are always fetched from the +network and are never written to CacheStorage, even when the URL contains search parameters or the request carries +cookies. All requests that do not match the allowlist pass through the browser's normal network path without a +service-worker `respondWith` handler. + +This policy governs service-worker CacheStorage. Normal HTTP caching remains controlled by each route's response +headers; private API routes must continue to emit their existing `private`/`no-store` policies. + +Because every owned cache entry is public and user-independent, sign-out does not rely on a cache purge for +confidentiality. If user-scoped caching were ever introduced, logout, revocation, multi-tab cleanup, and storage +partition behavior would become mandatory design inputs; that broader cache policy is currently prohibited. + +## Offline semantics + +Offline support means **clear failure handling**, not offline clinical operation: + +1. A controlled navigation first uses navigation preload when available, otherwise a normal network fetch. +2. If that fetch fails, the worker returns the cached, self-contained `/offline.html` document. +3. If the precached document is unexpectedly unavailable, the worker returns a minimal in-memory HTML response with + status `503` and `Cache-Control: no-store`. +4. The offline page states that search, answer generation, private documents, uploads, and account data need a + connection. It contains no retrieved or user-specific content. +5. A cross-route connectivity notice mirrors `navigator.onLine` and offers a reload. A four-second polite status is + shown when connectivity returns. + +`navigator.onLine` is only a browser connectivity hint; it does not prove that the application backend or a provider +is healthy. Existing content may remain visible in an already-open tab, but it is not an offline-data guarantee and +clinical actions remain network-dependent. + +## Registration and update lifecycle + +`PwaLifecycle` is mounted once in the root layout so lifecycle handling is consistent across all routes. + +### Registration + +- Production registers `/sw.js` after the `load` event, using `requestIdleCallback` with a two-second timeout when + available. Registration therefore stays off the critical rendering path. +- Registration uses scope `/` and `updateViaCache: "none"`. +- Unsupported browsers and non-secure contexts continue as ordinary web applications. +- Development does not register by default. Add `?pwa-dev=1` to the first local page load for an explicit test session. +- Registration failure is non-fatal; development logs a warning and the web app remains usable. + +### Worker install and activation + +- Installation must cache `/offline.html`; icon and manifest precaching is best-effort. +- A new worker does not call `skipWaiting` automatically. Existing sessions are not refreshed without the user's + choice. +- On activation, obsolete shell caches and all but the two newest prior static caches bearing the + `clinical-kb-pwa-` prefix are deleted. Navigation preload is enabled when supported, and the worker claims clients. + Cache and preload failures are best-effort so an online network response or the in-memory emergency page remains + available. + +### Update UX + +- An already-waiting worker, or an installing worker that reaches `installed` while a controller exists, shows **An + update is ready**. +- When the page becomes visible or connectivity returns, the registration may check for an update. App-triggered + checks are throttled to at most once per hour; there is no background polling timer. +- **Refresh now** sends `SKIP_WAITING`. The page reloads once after `controllerchange`, so it cannot loop. +- **Later** hides the update for the current lifecycle instance. A later page load can surface the waiting update + again. +- If another tab activates the update, an already-controlled tab receives `controllerchange` and offers its own + refresh. A first-ever worker claim is not misreported as an update. + +Because clients may remain on the prior worker until they accept an update or close their tabs, deployments must keep +server routes compatible with the immediately previous client during rollout. + +## Versioning, invalidation, and storage bounds + +`public/sw.js` owns the manual `CACHE_VERSION`. Current cache names are composed from: + +```text +clinical-kb-pwa-shell- +clinical-kb-pwa-static- +``` + +Operational rules: + +1. Bump `CACHE_VERSION` whenever the offline document, precache contents, cache allowlist, strategy, or response + semantics change, and whenever a deployment must force eviction of previously cached assets. +2. Change the worker script in the same deployment. `/sw.js` is served with `no-cache, no-store, must-revalidate`, and + registration uses `updateViaCache: "none"`, so the browser can detect the new script. +3. Activation deletes only owned obsolete caches. It retains the two newest prior static caches and the new worker + consults them after a current-cache miss, protecting older open tabs that request a lazy chunk after another tab + accepts an update. It does not disturb caches owned by unrelated applications on the origin. +4. Hashed `/_next/static/` assets can safely use cache-first. New builds request new hashes; each static-cache version + has a 128-entry bound. Deployments must still preserve server compatibility with the immediately previous client. +5. Do not reuse an old cache version after a rollback. A rollback may encounter clients and caches from the newer + release; publish another unique version if cache semantics need to move backward. +6. Do not remove `/sw.js` as a way to retire the PWA. Installed workers can outlive that response. A future retirement + must ship an explicit worker that deletes the owned cache prefix and unregisters itself. +7. CacheStorage is evictable and installation can fail under storage pressure. The application must remain a fully + usable online website when that happens; it does not request persistent storage for these replaceable public assets. + +## Headers, CSP, and proxy handling + +PWA bootstrap resources are public, stable, and independent of Supabase sessions. `src/proxy.ts` recognizes `/sw.js`, +`/offline.html`, `/manifest.webmanifest`, `/apple-icon`, and `/icons/*` and returns before nonce generation or session +refresh. Static `/icon.svg` is excluded by the proxy's file matcher. These routes must not redirect to auth, refresh +cookies, or vary by user. No generic public PWA namespace is trusted; future assets must be enumerated explicitly. + +`next.config.ts` applies resource-specific headers: + +| Resource | Required behavior | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `/sw.js` | JavaScript content type; `no-cache, no-store, must-revalidate`; root `Service-Worker-Allowed`; same-origin CORP; script-only self CSP | +| `/offline.html` | Immediate revalidation; no indexing; same-origin CORP; no framing; CSP permits only its inline style and self form action | +| `/manifest.webmanifest` | Public response with immediate revalidation | + +The page CSP adds `worker-src 'self'` and `manifest-src 'self'`. The existing nonce-based production script policy, +Supabase media/connect restrictions, HSTS gating, and other security headers remain unchanged. Do not solve a PWA +resource problem by weakening the page CSP or adding a provider origin to the worker cache. + +## Performance and resilience choices + +- Registration waits until load/idle, avoiding competition with first paint and hydration. +- Navigation preload avoids an avoidable service-worker startup/network waterfall. +- Only immutable hashed framework assets use cache-first. +- Public install assets use stale-while-revalidate and remain bounded. +- Application HTML is network-first without runtime storage, preventing stale authenticated shells and sensitive URL + persistence. +- The offline document is self-contained and has no external font, script, image, API, or provider dependency. +- Cache failures are best-effort after the required offline install step; a cache quota/write error does not break the + live network response. + +## Accessibility, theme, and device fit + +- Lifecycle notices use a polite live region, labelled status/region containers, real buttons, 44 px minimum target + height, and visible focus treatment. +- The notice stack does not take page scroll ownership. Its cards restore pointer events while the container remains + transparent to unrelated interaction. +- Phone and standalone placement accounts for left, right, and bottom safe-area insets and sits above the bottom + composer/home indicator. The offline page uses all four safe-area insets. +- The offline page supports light/dark colour schemes, keyboard focus, responsive type, and forced-colour mode. +- Light and dark theme colours come from `APP_THEME_COLORS`. The pre-hydration theme script updates `theme-color` + before paint, and the theme hook keeps it synchronized after user/OS theme changes. +- Standalone detection covers both `display-mode: standalone` and the Apple `navigator.standalone` extension; the + current mode is exposed as `data-pwa-display-mode` on the document root. +- No PWA-specific animation is required, so reduced-motion users do not receive an avoidable transition. + +## Local development and verification + +Normal development sessions do not register a worker. For focused testing: + +1. Run `npm run ensure` and use the exact project URL it prints. +2. Open that URL with `/?pwa-dev=1` (preserve the printed host and port). +3. In browser DevTools, wait for `/sw.js` to be activated and controlling the page. +4. Test navigation fallback and CacheStorage as described below. Local runtime caching of HMR and Next.js chunks is + disabled by the worker. +5. When finished, use DevTools **Application -> Service Workers -> Unregister**, then clear the two + `clinical-kb-pwa-*` caches. The query flag prevents new registration; it does not automatically remove a worker + registered during an earlier opt-in session. + +Use local/static/mocked checks by default: + +```powershell +npm run verify:cheap +npm run build +npm run ensure +npm run test:e2e:pwa +npm run verify:ui +``` + +The focused Chromium gate blocks local `/api/**`, Supabase, and OpenAI traffic before loading the shell. It validates +the manifest and icon bytes, installability diagnostics, worker/header/scope state, cold offline navigation, +connectivity recovery, and the complete owned CacheStorage inventory without exercising a clinical API. + +Run the smallest relevant check first, then widen. `ensure` must precede browser QA and its project-identity URL must +be used. `verify:release` includes broader production/provider gates and requires explicit confirmation under the +repository API/provider boundary; it is not an automatic PWA check. + +### Manual browser checklist + +Use a clean browser profile for install tests and a demo/mocked/local environment unless live-provider access has been +explicitly authorized. + +- [ ] `/manifest.webmanifest` returns successfully, reports the expected name/scope/start URL/shortcuts, and all icon + URLs load at their declared dimensions. +- [ ] `/sw.js` has JavaScript content type, root scope permission, no-store update headers, and no auth redirect or + `Set-Cookie`. +- [ ] `/offline.html` has the restrictive CSP, noindex policy, no external requests, and readable light/dark rendering. +- [ ] Browser DevTools reports a valid manifest and an activated worker controlling scope `/` with no console errors. +- [ ] Install from browser UI; verify the icon is not cropped for both normal and maskable launch surfaces, then launch + in standalone mode. +- [ ] Verify the custom install card can be installed/dismissed, is keyboard operable, and is absent in standalone + mode. Also verify the platform-native Add to Home Screen path where `beforeinstallprompt` is unavailable. +- [ ] Launch each manifest shortcut and confirm its online route. Do not interpret a shortcut as offline support. +- [ ] Inspect CacheStorage after representative navigation. Every entry must match the allowlist; there must be no API, + query-string, document, signed-URL, RSC, upload, auth, or clinical-content entry. +- [ ] With a controlled page, enable browser offline mode and navigate to a route not already open. The generic offline + document must appear and must not reveal prior content. Restore connectivity and verify the restored notice. +- [ ] Confirm an offline API/document/media request is not answered from a PWA cache. +- [ ] Deploy a worker with a new cache version in a staging environment. Verify the update waits, **Later** does not + force a refresh, **Refresh now** activates it, every older tab is offered a refresh, and only the two newest + prior static caches remain for lazy-chunk compatibility. +- [ ] Check keyboard focus, screen-reader announcements, forced colours, light/dark theme, portrait/landscape, display + cutouts, and the standalone home-indicator area at phone and desktop breakpoints. +- [ ] Repeat the supported browser/device matrix, including at least one Chromium install surface and the Apple Add to + Home Screen surface. Treat browser install UI differences as progressive enhancement, not an app failure. +- [ ] Verify no service worker, watcher, or test cache is left behind in the normal development profile. + +### Deployment checklist + +- [ ] Serve the canonical production origin over HTTPS and configure canonical metadata (`NEXT_PUBLIC_SITE_URL` or the + trusted deployment domain) without relying on forwarded user-controlled hosts. +- [ ] Keep `/sw.js`, `/offline.html`, the manifest, and icon routes public and same-origin. +- [ ] Verify the CDN/reverse proxy preserves `Cache-Control`, `Content-Type`, CSP, CORP, + `Service-Worker-Allowed`, and `X-Robots-Tag` exactly; it must not pin an old `/sw.js`. +- [ ] Ensure redirects do not move the manifest `start_url` or worker script outside scope `/`. +- [ ] Bump `CACHE_VERSION` when required and test upgrade from the currently deployed worker, not only a clean install. +- [ ] Preserve compatibility with the previous waiting/active client during staged rollout and rollback. +- [ ] Review cache contents and storage bounds in the deployed origin before release sign-off. +- [ ] Record any provider-backed release check separately and obtain explicit authorization before running it. + +## Intentionally deferred capabilities + +These omissions are deliberate and must not be added as generic PWA enhancements: + +| Capability | Status and reason | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Push notifications | Deferred. It requires a permission and subscription UX, backend key/subscription lifecycle, revocation, and a clinical privacy policy for lock-screen content. No safe notification payload or product need is currently defined. | +| Background Sync / Periodic Background Sync | Deferred. Queuing or replaying clinical queries, uploads, answers, or mutations risks sensitive local persistence, duplicate writes, stale auth, and actions occurring after the user's context changed. Browser support is also not a correctness guarantee. | +| Web Share Target / inbound sharing | Deferred. Accepting text, URLs, or documents from another app needs an explicit consent, validation, auth, provenance, malware/file-safety, and retention flow. The manifest intentionally has no `share_target`. | +| File handlers | Deferred. Associating Clinical KB with clinical document types could import sensitive files without the existing upload review and validation context. The manifest intentionally has no `file_handlers`. | +| Offline clinical data, search, or answers | Prohibited by the current privacy model. Cached clinical guidance can become stale, lose revocation/auth guarantees, separate answers from source provenance, and expose private content to durable same-origin storage. Only the generic offline shell and public application assets are allowed. | + +Any proposal to enable one of these capabilities needs a product decision, threat model, privacy review, data lifecycle, +revocation and logout behavior, browser-support fallback, accessible consent UX, and targeted offline/update tests before +implementation. + +## Change review checklist + +When changing the PWA: + +1. Re-read the cache invariant and keep the allowlist path- and destination-specific. +2. Confirm request and response guards still reject private, query-bearing, ranged, authenticated, cookie-setting, and + cross-origin content. +3. Update `CACHE_VERSION` when cache contents or semantics change. +4. Keep worker/manifest/offline/icon routes public without session refresh, while preserving their restrictive headers. +5. Test both a clean install and an upgrade from the prior worker. +6. Verify offline failure language does not imply that clinical features work offline. +7. Check installed light/dark chrome, keyboard/screen-reader behavior, maskable icons, safe areas, and update recovery. +8. Run local checks first; stop and request confirmation before any provider-backed verification. diff --git a/next.config.ts b/next.config.ts index c3e5d4cef..a2bf26000 100644 --- a/next.config.ts +++ b/next.config.ts @@ -54,6 +54,33 @@ const nextConfig: NextConfig = { source: "/(.*)", headers: securityHeaders, }, + { + source: "/sw.js", + headers: [ + { key: "Content-Type", value: "application/javascript; charset=utf-8" }, + { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }, + { key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" }, + { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, + { key: "Service-Worker-Allowed", value: "/" }, + ], + }, + { + source: "/offline.html", + headers: [ + { key: "Cache-Control", value: "public, max-age=0, must-revalidate" }, + { + key: "Content-Security-Policy", + value: + "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + }, + { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, + { key: "X-Robots-Tag", value: "noindex, nofollow" }, + ], + }, + { + source: "/manifest.webmanifest", + headers: [{ key: "Cache-Control", value: "public, max-age=0, must-revalidate" }], + }, ]; }, }; diff --git a/package.json b/package.json index b96c2694f..364336e67 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:e2e": "node scripts/run-playwright.mjs", "test:e2e:all": "node scripts/run-playwright.mjs", "test:e2e:accessibility": "node scripts/run-playwright.mjs tests/ui-accessibility.spec.ts --project=chromium", + "test:e2e:pwa": "node scripts/run-playwright.mjs tests/ui-pwa.spec.ts --project=chromium", "test:e2e:critical": "node scripts/run-playwright.mjs --project=chromium --grep @critical", "test:e2e:regression": "node scripts/run-playwright.mjs --project=chromium --grep-invert \"@critical|@quarantine\"", "test:e2e:quarantine": "node scripts/run-playwright.mjs --project=chromium --grep @quarantine --pass-with-no-tests", diff --git a/playwright.config.ts b/playwright.config.ts index 27f0c2c5b..5ab0db28a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,14 +12,14 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // Tag-level filters keep production and prototype journeys disjoint even when // they share a spec file; firefox/webkit retain the legacy full testMatch. const productionSpecPattern = - /.*ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation)\.spec\.ts/; + /.*ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|pwa)\.spec\.ts/; const mockupSpecPattern = /.*ui-(tools|tools-collapse|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation)\.spec\.ts/, + /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|pwa)\.spec\.ts/, timeout: 60_000, // Two CI retries (was 1) absorb the residual ledger flakes (rAF-portal // hydration, sub-pixel tap targets) without masking a real regression, which diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 000000000..382ef0ba2 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,165 @@ + + + + + + + + + + Clinical KB is offline + + + +
+ +

Clinical KB is offline

+

Reconnect to search, generate answers, or open private clinical documents and account data.

+

+ For privacy, the PWA cache stores only this offline screen and public application assets. It does not store or + replay clinical queries, answers, documents, uploads, signed URLs, or API responses. +

+ Try again +
+ + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 000000000..1fd9f44c5 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,330 @@ +/* Clinical KB service worker. + * + * Privacy is the primary cache rule: navigations, RSC payloads, APIs, auth, + * documents, queries, uploads, signed URLs, range requests, and cross-origin + * traffic are always network-only. CacheStorage is limited to the generic + * offline page and explicitly allow-listed public application assets. + */ + +const CACHE_PREFIX = "clinical-kb-pwa-"; +const CACHE_VERSION = "2026-07-15-v1"; +const SHELL_CACHE = `${CACHE_PREFIX}shell-${CACHE_VERSION}`; +const STATIC_CACHE = `${CACHE_PREFIX}static-${CACHE_VERSION}`; +const STATIC_CACHE_PREFIX = `${CACHE_PREFIX}static-`; +const CURRENT_CACHES = new Set([SHELL_CACHE, STATIC_CACHE]); +const OFFLINE_URL = "/offline.html"; +const MAX_STATIC_ENTRIES = 128; +const MAX_SHELL_ENTRIES = 16; +const MAX_RETAINED_STATIC_CACHES = 2; +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +function isSameOrigin(url) { + return url.origin === self.location.origin; +} + +function hasSensitiveRequestSignals(request, url) { + return ( + request.method !== "GET" || + !isSameOrigin(url) || + url.search !== "" || + request.cache === "no-store" || + request.headers.get("cache-control")?.toLowerCase().includes("no-store") || + request.headers.has("authorization") || + request.headers.has("range") + ); +} + +function isImmutableNextAsset(request, url) { + if (hasSensitiveRequestSignals(request, url)) return false; + if (!url.pathname.startsWith("/_next/static/")) return false; + return ["font", "script", "style", "worker"].includes(request.destination); +} + +function isPublicPwaAsset(request, url) { + if (hasSensitiveRequestSignals(request, url)) return false; + return ( + url.pathname === "/manifest.webmanifest" || + url.pathname === "/icon.svg" || + url.pathname === "/apple-icon" || + url.pathname.startsWith("/icons/") + ); +} + +function responseHasSafePublicMetadata(request, response) { + if (!response || response.status !== 200 || response.type !== "basic" || response.redirected) return false; + + if (response.url) { + const requestUrl = new URL(request.url); + const responseUrl = new URL(response.url); + if (!isSameOrigin(responseUrl) || responseUrl.pathname !== requestUrl.pathname || responseUrl.search !== "") { + return false; + } + } + + const cacheControl = response.headers.get("cache-control")?.toLowerCase() ?? ""; + const contentDisposition = response.headers.get("content-disposition")?.toLowerCase() ?? ""; + const vary = + response.headers + .get("vary") + ?.toLowerCase() + .split(",") + .map((value) => value.trim()) ?? []; + + return ( + !cacheControl.includes("no-store") && + !cacheControl.includes("private") && + !contentDisposition.startsWith("attachment") && + !vary.some((value) => value === "*" || value === "cookie" || value === "authorization") && + !response.headers.has("set-cookie") && + !response.headers.has("www-authenticate") + ); +} + +function responseMatchesExpectedRuntimeType(request, response) { + const pathname = new URL(request.url).pathname; + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + + if (pathname.startsWith("/_next/static/")) { + if (request.destination === "style") return contentType === "text/css"; + if (request.destination === "font") { + return ( + contentType.startsWith("font/") || + contentType.startsWith("application/font-") || + contentType === "application/vnd.ms-fontobject" || + contentType === "application/octet-stream" + ); + } + return [ + "application/ecmascript", + "application/javascript", + "application/x-javascript", + "text/ecmascript", + "text/javascript", + ].includes(contentType); + } + + if (pathname === "/manifest.webmanifest") { + return contentType === "application/manifest+json" || contentType === "application/json"; + } + if (pathname === "/icon.svg") return contentType === "image/svg+xml"; + if (pathname === "/apple-icon" || pathname.startsWith("/icons/")) return contentType === "image/png"; + return false; +} + +function responseCanBeStored(request, response) { + if (!responseHasSafePublicMetadata(request, response)) return false; + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + return ( + !contentType.startsWith("text/html") && + !contentType.startsWith("application/xhtml+xml") && + responseMatchesExpectedRuntimeType(request, response) + ); +} + +function responseCanBePrecached(request, response, allowedContentTypes) { + if (!responseHasSafePublicMetadata(request, response)) return false; + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + return allowedContentTypes.includes(contentType); +} + +function withoutCredentials(request) { + return new Request(request, { credentials: "omit" }); +} + +function shouldBypassCacheRead(request) { + const requestCacheControl = request.headers.get("cache-control")?.toLowerCase() ?? ""; + return ( + request.cache === "reload" || + request.cache === "no-cache" || + requestCacheControl.includes("no-cache") || + requestCacheControl.includes("max-age=0") + ); +} + +async function safeCacheNames() { + try { + return await caches.keys(); + } catch { + return []; + } +} + +async function safeCacheMatch(cacheName, request) { + try { + const cache = await caches.open(cacheName); + return await cache.match(request); + } catch { + return undefined; + } +} + +async function matchOwnedStatic(request) { + const current = await safeCacheMatch(STATIC_CACHE, request); + if (current) return current; + + const retainedNames = (await safeCacheNames()) + .filter((name) => name.startsWith(STATIC_CACHE_PREFIX) && name !== STATIC_CACHE) + .slice(-MAX_RETAINED_STATIC_CACHES) + .reverse(); + for (const cacheName of retainedNames) { + const retained = await safeCacheMatch(cacheName, request); + if (retained) return retained; + } + return undefined; +} + +async function putBounded(cacheName, request, response, maxEntries, protectedPathnames = []) { + const cache = await caches.open(cacheName); + await cache.delete(request); + await cache.put(request, response); + const keys = await cache.keys(); + const excess = keys.length - maxEntries; + if (excess > 0) { + const protectedPaths = new Set(protectedPathnames); + const removable = keys.filter((key) => !protectedPaths.has(new URL(key.url).pathname)); + await Promise.all(removable.slice(0, excess).map((key) => cache.delete(key))); + } +} + +async function cacheFirst(event) { + const cached = shouldBypassCacheRead(event.request) ? undefined : await matchOwnedStatic(event.request); + if (cached) return cached; + + const publicRequest = withoutCredentials(event.request); + const response = await fetch(publicRequest); + if (responseCanBeStored(publicRequest, response)) { + event.waitUntil( + putBounded(STATIC_CACHE, event.request, response.clone(), MAX_STATIC_ENTRIES).catch(() => undefined), + ); + } + return response; +} + +async function staleWhileRevalidate(event, cacheName, maxEntries) { + const cached = shouldBypassCacheRead(event.request) ? undefined : await safeCacheMatch(cacheName, event.request); + const publicRequest = withoutCredentials(event.request); + const network = fetch(publicRequest).then((response) => { + if (responseCanBeStored(publicRequest, response)) { + event.waitUntil( + putBounded(cacheName, event.request, response.clone(), maxEntries, [OFFLINE_URL]).catch(() => undefined), + ); + } + return response; + }); + + if (cached) { + event.waitUntil(network.catch(() => undefined)); + return cached; + } + return network; +} + +function emergencyOfflineResponse() { + return new Response( + 'Clinical KB is offline

Clinical KB is offline

Reconnect to continue. No clinical content is stored in the offline fallback.

Try again

', + { + status: 503, + headers: { + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; base-uri 'none'; form-action 'self'", + "Content-Type": "text/html; charset=utf-8", + }, + }, + ); +} + +async function handleNavigation(event) { + try { + const preloaded = await event.preloadResponse; + if (preloaded) return preloaded; + return await fetch(event.request); + } catch { + return (await safeCacheMatch(SHELL_CACHE, OFFLINE_URL)) ?? emergencyOfflineResponse(); + } +} + +async function precachePublicAsset(cache, path, allowedContentTypes) { + const request = new Request(path, { cache: "reload", credentials: "omit" }); + const response = await fetch(request); + if (!responseCanBePrecached(request, response, allowedContentTypes)) { + throw new TypeError(`Unsafe PWA precache response for ${path}`); + } + await cache.put(request, response); +} + +self.addEventListener("install", (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(SHELL_CACHE); + await precachePublicAsset(cache, OFFLINE_URL, ["text/html"]); + await Promise.allSettled([ + precachePublicAsset(cache, "/icon.svg", ["image/svg+xml"]), + precachePublicAsset(cache, "/manifest.webmanifest", ["application/manifest+json", "application/json"]), + ]); + })(), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const names = await safeCacheNames(); + const retainedStaticCaches = new Set( + names + .filter((name) => name.startsWith(STATIC_CACHE_PREFIX) && name !== STATIC_CACHE) + .slice(-MAX_RETAINED_STATIC_CACHES), + ); + await Promise.allSettled( + names + .filter( + (name) => name.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.has(name) && !retainedStaticCaches.has(name), + ) + .map((name) => caches.delete(name)), + ); + if (self.registration.navigationPreload) { + try { + await self.registration.navigationPreload.enable(); + } catch { + // Navigation preload is an optimization; activation must remain usable + // when a browser exposes the API but rejects enabling it. + } + } + try { + await self.clients.claim(); + } catch { + // A later navigation can still become controlled. Do not strand the + // update in activation because immediate claiming was unavailable. + } + })(), + ); +}); + +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (!isSameOrigin(url)) return; + + if (request.mode === "navigate") { + event.respondWith(handleNavigation(event)); + return; + } + + // A normal dev session never persists HMR/chunk responses. The `?pwa-dev=1` + // browser test still exercises registration and the offline navigation shell. + if (LOCAL_HOSTS.has(self.location.hostname)) return; + + if (isImmutableNextAsset(request, url)) { + event.respondWith(cacheFirst(event)); + return; + } + + if (isPublicPwaAsset(request, url)) { + event.respondWith(staleWhileRevalidate(event, SHELL_CACHE, MAX_SHELL_ENTRIES)); + } +}); diff --git a/src/app/globals.css b/src/app/globals.css index c9121de89..a9fb9afe6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1804,6 +1804,33 @@ summary::-webkit-details-marker { padding-bottom: calc(0.5rem + env(safe-area-inset-bottom)); } +/* Global PWA notices sit above the bottom composer and home indicator without + changing page scroll ownership. The lifecycle component renders only when an + offline, restored, installable, or waiting-update state needs attention. */ +.pwa-notice-stack { + position: fixed; + z-index: 95; + left: max(0.75rem, var(--safe-area-left)); + right: max(0.75rem, var(--safe-area-right)); + bottom: calc(max(0.75rem, var(--safe-area-bottom)) + 5.5rem); + display: grid; + gap: 0.75rem; + pointer-events: none; +} + +@media (min-width: 640px) { + .pwa-notice-stack { + left: auto; + width: min(24rem, calc(100vw - 2rem - var(--safe-area-left) - var(--safe-area-right))); + } +} + +@media (display-mode: standalone) { + .pwa-notice-stack { + bottom: calc(max(0.75rem, var(--safe-area-bottom)) + 6rem); + } +} + /* Tabular figures for data, counts, page numbers, byte sizes */ @utility nums { font-variant-numeric: tabular-nums; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 105024c44..61fa12ce0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,11 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; +import { PwaLifecycle } from "@/components/pwa-lifecycle"; import { AuthProvider } from "@/lib/supabase/client"; import { WebVitalsReporter } from "@/components/web-vitals-reporter"; import { resolveMetadataBase } from "@/lib/metadata-base"; +import { APP_THEME_COLORS } from "@/lib/theme"; import "./globals.css"; const geistSans = Geist({ @@ -52,8 +54,8 @@ export const viewport: Viewport = { viewportFit: "cover", colorScheme: "light dark", themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#ffffff" }, - { media: "(prefers-color-scheme: dark)", color: "#060708" }, + { media: "(prefers-color-scheme: light)", color: APP_THEME_COLORS.light }, + { media: "(prefers-color-scheme: dark)", color: APP_THEME_COLORS.dark }, ], }; @@ -70,7 +72,7 @@ export default async function RootLayout({ const nonce = (await headers()).get("x-nonce") ?? undefined; return ( @@ -85,7 +87,7 @@ export default async function RootLayout({ // read it), which reads as a hydration mismatch on this attribute. suppressHydrationWarning dangerouslySetInnerHTML={{ - __html: `(function(){try{var t=localStorage.getItem("clinical-kb-theme");var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`, + __html: `(function(){try{var t=localStorage.getItem("clinical-kb-theme");var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);if(t==="dark"||t==="light"){var c=d?"${APP_THEME_COLORS.dark}":"${APP_THEME_COLORS.light}";document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute("content",c);});}}catch(e){}})();`, }} /> + {children} diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 077555040..f776114d1 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -1,4 +1,5 @@ import type { MetadataRoute } from "next"; +import { APP_THEME_COLORS, DEFAULT_THEME } from "@/lib/theme"; // PWA manifest — makes the app installable with a proper icon. Icons derive from // the single brand-mark source: the SVG for modern browsers, plus generated PNG @@ -9,10 +10,16 @@ export default function manifest(): MetadataRoute.Manifest { name: "Clinical KB", short_name: "Clinical KB", description: "Private medical guideline RAG knowledge base", + id: "/", start_url: "/", + scope: "/", + lang: "en-AU", + dir: "ltr", display: "standalone", - background_color: "#ffffff", - theme_color: "#ffffff", + background_color: APP_THEME_COLORS[DEFAULT_THEME], + theme_color: APP_THEME_COLORS[DEFAULT_THEME], + categories: ["medical", "productivity", "utilities"], + prefer_related_applications: false, icons: [ { src: "/icon.svg", type: "image/svg+xml", sizes: "any" }, { src: "/icons/icon-192", type: "image/png", sizes: "192x192", purpose: "any" }, @@ -20,5 +27,35 @@ export default function manifest(): MetadataRoute.Manifest { { src: "/icons/maskable-192", type: "image/png", sizes: "192x192", purpose: "maskable" }, { src: "/icons/maskable-512", type: "image/png", sizes: "512x512", purpose: "maskable" }, ], + shortcuts: [ + { + name: "Ask Clinical KB", + short_name: "Ask", + description: "Open a source-backed clinical question", + url: "/?mode=answer&focus=1", + icons: [{ src: "/icons/icon-192", type: "image/png", sizes: "192x192" }], + }, + { + name: "Search documents", + short_name: "Documents", + description: "Find source documents and evidence passages", + url: "/documents/search?mode=documents&focus=1", + icons: [{ src: "/icons/icon-192", type: "image/png", sizes: "192x192" }], + }, + { + name: "Medication guidance", + short_name: "Medication", + description: "Open medication dosing and monitoring guidance", + url: "/?mode=prescribing&focus=1", + icons: [{ src: "/icons/icon-192", type: "image/png", sizes: "192x192" }], + }, + { + name: "Differentials", + short_name: "Differentials", + description: "Compare causes and clinical clues", + url: "/differentials?focus=1", + icons: [{ src: "/icons/icon-192", type: "image/png", sizes: "192x192" }], + }, + ], }; } diff --git a/src/components/clinical-dashboard/use-theme.ts b/src/components/clinical-dashboard/use-theme.ts index a600bdcb6..922cd8ac5 100644 --- a/src/components/clinical-dashboard/use-theme.ts +++ b/src/components/clinical-dashboard/use-theme.ts @@ -2,7 +2,7 @@ import { useEffect, useSyncExternalStore } from "react"; -import { DEFAULT_THEME, nextTheme, resolveThemePreference, type ResolvedTheme } from "@/lib/theme"; +import { APP_THEME_COLORS, DEFAULT_THEME, nextTheme, resolveThemePreference, type ResolvedTheme } from "@/lib/theme"; const themeStorageKey = "clinical-kb-theme"; const themeChangeEvent = "clinical-kb-theme-change"; @@ -18,6 +18,12 @@ function getServerThemeSnapshot(): ResolvedTheme { return DEFAULT_THEME; } +function syncThemeColorMetadata(theme: ResolvedTheme) { + for (const element of document.querySelectorAll('meta[name="theme-color"]')) { + element.content = APP_THEME_COLORS[theme]; + } +} + function subscribeTheme(onStoreChange: () => void) { if (typeof window === "undefined") return () => undefined; const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); @@ -39,12 +45,14 @@ export function useTheme() { useEffect(() => { document.documentElement.classList.toggle("dark", theme === "dark"); + syncThemeColorMetadata(theme); }, [theme]); function toggleTheme() { const resolved = nextTheme(theme); window.localStorage.setItem(themeStorageKey, resolved); document.documentElement.classList.toggle("dark", resolved === "dark"); + syncThemeColorMetadata(resolved); window.dispatchEvent(new Event(themeChangeEvent)); } diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx new file mode 100644 index 000000000..6782699f7 --- /dev/null +++ b/src/components/pwa-lifecycle.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; + +const SERVICE_WORKER_URL = "/sw.js"; +const INSTALL_DISMISSAL_KEY = "clinical-kb-pwa-install-dismissed-at"; +const INSTALL_DISMISSAL_MS = 30 * 24 * 60 * 60 * 1000; +const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; + +type InstallChoice = { outcome: "accepted" | "dismissed"; platform: string }; + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise; +}; + +type NavigatorWithStandalone = Navigator & { standalone?: boolean }; + +function subscribeConnectivity(onStoreChange: () => void) { + window.addEventListener("online", onStoreChange); + window.addEventListener("offline", onStoreChange); + return () => { + window.removeEventListener("online", onStoreChange); + window.removeEventListener("offline", onStoreChange); + }; +} + +function getConnectivitySnapshot() { + return navigator.onLine; +} + +function getServerConnectivitySnapshot() { + return true; +} + +function isStandaloneDisplay() { + return ( + window.matchMedia("(display-mode: standalone)").matches || + (navigator as NavigatorWithStandalone).standalone === true + ); +} + +function wasInstallRecentlyDismissed() { + try { + const dismissedAt = Number(window.localStorage.getItem(INSTALL_DISMISSAL_KEY)); + if (!Number.isFinite(dismissedAt) || dismissedAt <= 0) return false; + if (Date.now() - dismissedAt < INSTALL_DISMISSAL_MS) return true; + window.localStorage.removeItem(INSTALL_DISMISSAL_KEY); + } catch { + // Storage can be unavailable in private/restricted contexts. Installation + // remains a progressive enhancement, so a storage failure is non-fatal. + } + return false; +} + +function rememberInstallDismissal() { + try { + window.localStorage.setItem(INSTALL_DISMISSAL_KEY, String(Date.now())); + } catch { + // See wasInstallRecentlyDismissed: the prompt can still be dismissed for + // this render even when persistence is unavailable. + } +} + +const cardClassName = + "pointer-events-auto rounded-xl border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] p-4 text-[color:var(--text)] shadow-[var(--shadow-elevated)]"; +const primaryButtonClassName = + "inline-flex min-h-11 items-center justify-center rounded-lg bg-[color:var(--clinical-accent)] px-3.5 py-2 text-sm font-semibold text-[color:var(--clinical-accent-contrast)] transition-colors hover:bg-[color:var(--clinical-accent-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; +const secondaryButtonClassName = + "inline-flex min-h-11 items-center justify-center rounded-lg border border-[color:var(--border-lux)] px-3.5 py-2 text-sm font-semibold text-[color:var(--text)] transition-colors hover:bg-[color:var(--surface-subtle)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +/** + * Owns installability, service-worker updates, and cross-route connectivity UI. + * The worker is production-first; `?pwa-dev=1` enables a cache-safe localhost + * path for focused browser tests without persisting normal HMR assets. + */ +export function PwaLifecycle() { + const isOnline = useSyncExternalStore(subscribeConnectivity, getConnectivitySnapshot, getServerConnectivitySnapshot); + const [connectionRestored, setConnectionRestored] = useState(false); + const [installPrompt, setInstallPrompt] = useState(null); + const [waitingWorker, setWaitingWorker] = useState(null); + const [activatedUpdateReady, setActivatedUpdateReady] = useState(false); + const registrationRef = useRef(null); + const lastUpdateCheckRef = useRef(0); + const updateDismissedRef = useRef(false); + const refreshRequestedRef = useRef(false); + const reloadingRef = useRef(false); + const hasSeenControllerRef = useRef(false); + + useEffect(() => { + let restoredTimer: number | undefined; + + const handleOffline = () => { + if (restoredTimer) window.clearTimeout(restoredTimer); + setConnectionRestored(false); + }; + const handleOnline = () => { + if (restoredTimer) window.clearTimeout(restoredTimer); + setConnectionRestored(true); + restoredTimer = window.setTimeout(() => setConnectionRestored(false), 4_000); + }; + + window.addEventListener("offline", handleOffline); + window.addEventListener("online", handleOnline); + return () => { + if (restoredTimer) window.clearTimeout(restoredTimer); + window.removeEventListener("offline", handleOffline); + window.removeEventListener("online", handleOnline); + }; + }, []); + + useEffect(() => { + const displayMode = window.matchMedia("(display-mode: standalone)"); + const syncDisplayMode = () => { + document.documentElement.dataset.pwaDisplayMode = isStandaloneDisplay() ? "standalone" : "browser"; + }; + syncDisplayMode(); + displayMode.addEventListener("change", syncDisplayMode); + return () => displayMode.removeEventListener("change", syncDisplayMode); + }, []); + + useEffect(() => { + const handleInstallPrompt = (event: Event) => { + const deferredPrompt = event as BeforeInstallPromptEvent; + deferredPrompt.preventDefault(); + if (!isStandaloneDisplay() && !wasInstallRecentlyDismissed()) setInstallPrompt(deferredPrompt); + }; + const handleInstalled = () => { + setInstallPrompt(null); + document.documentElement.dataset.pwaDisplayMode = "standalone"; + try { + window.localStorage.removeItem(INSTALL_DISMISSAL_KEY); + } catch { + // Installation succeeded; storage cleanup is best-effort only. + } + }; + + window.addEventListener("beforeinstallprompt", handleInstallPrompt); + window.addEventListener("appinstalled", handleInstalled); + return () => { + window.removeEventListener("beforeinstallprompt", handleInstallPrompt); + window.removeEventListener("appinstalled", handleInstalled); + }; + }, []); + + useEffect(() => { + const developmentOptIn = new URLSearchParams(window.location.search).get("pwa-dev") === "1"; + if (process.env.NODE_ENV !== "production" && !developmentOptIn) return; + if (!("serviceWorker" in navigator) || window.isSecureContext === false) return; + + let cancelled = false; + let cancelScheduledRegistration: () => void = () => {}; + const registrationCleanups = new Set<() => void>(); + hasSeenControllerRef.current = Boolean(navigator.serviceWorker.controller); + + const exposeWaitingWorker = (worker: ServiceWorker | null) => { + if (!cancelled && worker && !updateDismissedRef.current) setWaitingWorker(worker); + }; + + const watchInstallingWorker = (registration: ServiceWorkerRegistration) => { + const worker = registration.installing; + if (!worker) return; + const handleStateChange = () => { + if (worker.state === "installed" && navigator.serviceWorker.controller) { + exposeWaitingWorker(registration.waiting ?? worker); + } + }; + worker.addEventListener("statechange", handleStateChange); + registrationCleanups.add(() => worker.removeEventListener("statechange", handleStateChange)); + }; + + const checkForUpdates = () => { + const registration = registrationRef.current; + if (!registration || document.visibilityState !== "visible" || !navigator.onLine) return; + if (Date.now() - lastUpdateCheckRef.current < UPDATE_CHECK_INTERVAL_MS) return; + lastUpdateCheckRef.current = Date.now(); + void registration.update().catch(() => undefined); + }; + + const handleControllerChange = () => { + if (reloadingRef.current) return; + const wasPreviouslyControlled = hasSeenControllerRef.current; + hasSeenControllerRef.current = true; + if (refreshRequestedRef.current) { + reloadingRef.current = true; + window.location.reload(); + return; + } + setWaitingWorker(null); + if (!cancelled && wasPreviouslyControlled && !updateDismissedRef.current) setActivatedUpdateReady(true); + }; + + const register = async () => { + try { + const registration = await navigator.serviceWorker.register(SERVICE_WORKER_URL, { + scope: "/", + updateViaCache: "none", + }); + if (cancelled) return; + registrationRef.current = registration; + exposeWaitingWorker(registration.waiting); + watchInstallingWorker(registration); + const handleUpdateFound = () => watchInstallingWorker(registration); + registration.addEventListener("updatefound", handleUpdateFound); + registrationCleanups.add(() => registration.removeEventListener("updatefound", handleUpdateFound)); + lastUpdateCheckRef.current = Date.now(); + } catch (error) { + if (process.env.NODE_ENV === "development") console.warn("Clinical KB PWA registration failed", error); + } + }; + + const scheduleRegistration = () => { + const idleWindow = window as unknown as { + cancelIdleCallback?: Window["cancelIdleCallback"]; + requestIdleCallback?: Window["requestIdleCallback"]; + }; + const requestIdle = idleWindow.requestIdleCallback?.bind(window); + const cancelIdle = idleWindow.cancelIdleCallback?.bind(window); + + if (requestIdle && cancelIdle) { + const idleId = requestIdle(() => void register(), { timeout: 2_000 }); + cancelScheduledRegistration = () => cancelIdle(idleId); + } else { + const timeoutId = window.setTimeout(() => void register(), 0); + cancelScheduledRegistration = () => window.clearTimeout(timeoutId); + } + }; + + const handleLoad = () => scheduleRegistration(); + if (document.readyState === "complete") scheduleRegistration(); + else window.addEventListener("load", handleLoad, { once: true }); + + navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange); + document.addEventListener("visibilitychange", checkForUpdates); + window.addEventListener("online", checkForUpdates); + + return () => { + cancelled = true; + cancelScheduledRegistration(); + for (const cleanup of registrationCleanups) cleanup(); + registrationCleanups.clear(); + window.removeEventListener("load", handleLoad); + navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange); + document.removeEventListener("visibilitychange", checkForUpdates); + window.removeEventListener("online", checkForUpdates); + registrationRef.current = null; + }; + }, []); + + const dismissInstall = () => { + rememberInstallDismissal(); + setInstallPrompt(null); + }; + + const requestInstall = async () => { + if (!installPrompt) return; + try { + await installPrompt.prompt(); + const choice = await installPrompt.userChoice; + if (choice.outcome === "dismissed") rememberInstallDismissal(); + setInstallPrompt(null); + } catch { + // The browser owns this prompt and may withdraw it between eligibility and + // the click. Leave the web app usable and hide the stale affordance. + setInstallPrompt(null); + } + }; + + const applyUpdate = () => { + refreshRequestedRef.current = true; + if (activatedUpdateReady) { + reloadingRef.current = true; + window.location.reload(); + } else if (waitingWorker) waitingWorker.postMessage({ type: "SKIP_WAITING" }); + else { + reloadingRef.current = true; + window.location.reload(); + } + }; + + const dismissUpdate = () => { + updateDismissedRef.current = true; + setWaitingWorker(null); + setActivatedUpdateReady(false); + }; + + const showUpdate = isOnline && (Boolean(waitingWorker) || activatedUpdateReady); + const showInstall = isOnline && !showUpdate && Boolean(installPrompt); + if (isOnline && !connectionRestored && !showInstall && !showUpdate) return null; + + return ( +
+ {!isOnline ? ( +
+

+ You appear to be offline +

+

+ Clinical search, answers, private documents, uploads, and account data require a connection. +

+ +
+ ) : null} + + {connectionRestored ? ( +
+

Connection restored

+
+ ) : null} + + {showUpdate ? ( +
+

+ An update is ready +

+

+ Refresh when convenient to use the latest Clinical KB version. +

+
+ + +
+
+ ) : null} + + {showInstall ? ( +
+

+ Install Clinical KB +

+

+ Open it from your device like an app. Private clinical features still require a connection. +

+
+ + +
+
+ ) : null} +
+ ); +} diff --git a/src/lib/security-headers.ts b/src/lib/security-headers.ts index 76cf2900b..8cd0915e3 100644 --- a/src/lib/security-headers.ts +++ b/src/lib/security-headers.ts @@ -78,6 +78,8 @@ export function buildContentSecurityPolicy({ "img-src 'self' data: blob: https://*.supabase.co; " + "media-src 'self' https://*.supabase.co; " + "connect-src 'self' https://*.supabase.co; " + + "worker-src 'self'; " + + "manifest-src 'self'; " + scriptSrc + "style-src 'self' 'unsafe-inline'" ); diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 2448d8831..22bd96c15 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -2,6 +2,11 @@ export type ResolvedTheme = "light" | "dark"; export const DEFAULT_THEME: ResolvedTheme = "dark"; +export const APP_THEME_COLORS = { + light: "#ffffff", + dark: "#060708", +} as const satisfies Record; + export function resolveThemePreference(storedTheme: string | null | undefined, prefersDark: boolean): ResolvedTheme { if (storedTheme === "light" || storedTheme === "dark") return storedTheme; return prefersDark ? "dark" : "light"; diff --git a/src/proxy.ts b/src/proxy.ts index e387fcd5f..a053bc6b7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -27,12 +27,25 @@ const documentFlowRedirects: Record = { "/mockups/document-search-command": "/documents/search", }; +const publicPwaPaths = new Set(["/sw.js", "/offline.html", "/manifest.webmanifest", "/apple-icon"]); + +export function isPublicPwaPath(pathname: string) { + return publicPwaPaths.has(pathname) || pathname.startsWith("/icons/"); +} + // Same runtime flags next.config.ts uses for the static headers, so the nonce'd // CSP matches the rest of the policy (unsafe-eval in dev, HTTPS upgrade off local // http). Evaluated once at module load. const { isDevelopment, isLocalHttpRuntime } = resolveRuntimeFlags(); export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + // PWA bootstrap assets are public and deliberately independent from a user's + // auth session. Let next.config.ts apply their stable resource-specific headers + // without generating a page nonce or refreshing Supabase cookies. + if (isPublicPwaPath(pathname)) return NextResponse.next(); + // A fresh, unguessable nonce per request (see Next.js CSP guide). Buffer+base64 // matches the documented pattern and keeps the value header-safe. const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); @@ -68,7 +81,6 @@ export async function proxy(request: NextRequest) { return response; }; - const { pathname } = request.nextUrl; const redirectTarget = documentFlowRedirects[pathname]; if (redirectTarget) { diff --git a/tests/proxy-session-refresh.test.ts b/tests/proxy-session-refresh.test.ts index 66512b103..dbc4e642b 100644 --- a/tests/proxy-session-refresh.test.ts +++ b/tests/proxy-session-refresh.test.ts @@ -76,4 +76,23 @@ describe("proxy session refresh scoping", () => { expect(getUser).not.toHaveBeenCalled(); }); + + it.each(["/sw.js", "/offline.html", "/manifest.webmanifest", "/apple-icon", "/icons/icon-192"])( + "keeps the public PWA bootstrap path %s independent from user sessions", + async (path) => { + const { proxy } = await import("../src/proxy"); + const response = await proxy(requestWithSessionCookie(path)); + + expect(getUser).not.toHaveBeenCalled(); + expect(response.headers.get("content-security-policy")).toBeNull(); + }, + ); + + it("does not classify clinical API routes as public PWA resources", async () => { + const { isPublicPwaPath } = await import("../src/proxy"); + + expect(isPublicPwaPath("/api/answer")).toBe(false); + expect(isPublicPwaPath("/documents/private-id")).toBe(false); + expect(isPublicPwaPath("/pwa/private-export.json")).toBe(false); + }); }); diff --git a/tests/pwa-lifecycle.dom.test.tsx b/tests/pwa-lifecycle.dom.test.tsx new file mode 100644 index 000000000..999c2da29 --- /dev/null +++ b/tests/pwa-lifecycle.dom.test.tsx @@ -0,0 +1,161 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PwaLifecycle } from "@/components/pwa-lifecycle"; + +type MockWorker = ServiceWorker & { postMessage: ReturnType }; + +function createWorker(): MockWorker { + const worker = new EventTarget() as MockWorker; + Object.defineProperties(worker, { + state: { configurable: true, value: "installed" }, + postMessage: { configurable: true, value: vi.fn() }, + }); + return worker; +} + +function installServiceWorkerStub(waiting: ServiceWorker | null = null, controlled = Boolean(waiting)) { + const registration = new EventTarget() as ServiceWorkerRegistration & { + update: ReturnType; + }; + Object.defineProperties(registration, { + waiting: { configurable: true, value: waiting }, + installing: { configurable: true, value: null }, + update: { configurable: true, value: vi.fn().mockResolvedValue(undefined) }, + }); + + const container = new EventTarget() as ServiceWorkerContainer & { + register: ReturnType; + }; + Object.defineProperties(container, { + controller: { configurable: true, value: controlled ? {} : null }, + register: { configurable: true, value: vi.fn().mockResolvedValue(registration) }, + }); + Object.defineProperty(navigator, "serviceWorker", { configurable: true, value: container }); + + return { container, registration }; +} + +function dispatchInstallEligibility(outcome: "accepted" | "dismissed" = "accepted") { + const prompt = vi.fn().mockResolvedValue(undefined); + const event = new Event("beforeinstallprompt", { cancelable: true }); + Object.assign(event, { + prompt, + userChoice: Promise.resolve({ outcome, platform: "web" }), + }); + fireEvent(window, event); + return prompt; +} + +beforeEach(() => { + window.history.replaceState({}, "", "/"); + window.localStorage.clear(); + delete document.documentElement.dataset.pwaDisplayMode; + Object.defineProperty(window, "isSecureContext", { configurable: true, value: true }); + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + Object.defineProperty(window, "requestIdleCallback", { + configurable: true, + value: vi.fn((callback: IdleRequestCallback) => { + callback({ didTimeout: false, timeRemaining: () => 50 }); + return 1; + }), + }); + Object.defineProperty(window, "cancelIdleCallback", { configurable: true, value: vi.fn() }); +}); + +describe("PwaLifecycle", () => { + it("does not register a worker in a normal non-production development session", async () => { + const { container } = installServiceWorkerStub(); + render(); + + await Promise.resolve(); + expect(container.register).not.toHaveBeenCalled(); + }); + + it("registers the root-scoped worker only through the explicit local PWA test opt-in", async () => { + window.history.replaceState({}, "", "/?pwa-dev=1"); + const { container } = installServiceWorkerStub(); + render(); + + await waitFor(() => + expect(container.register).toHaveBeenCalledWith("/sw.js", { + scope: "/", + updateViaCache: "none", + }), + ); + }); + + it("announces lost and restored connectivity without claiming private data is available offline", async () => { + render(); + + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + fireEvent.offline(window); + expect(await screen.findByRole("region", { name: "You appear to be offline" })).toBeInTheDocument(); + expect( + screen.getByText(/clinical search, answers, private documents, uploads, and account data require a connection/i), + ).toBeInTheDocument(); + + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + fireEvent.online(window); + expect(await screen.findByText("Connection restored")).toBeInTheDocument(); + }); + + it("shows install UI only after browser eligibility and invokes the deferred prompt from a user action", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole("region", { name: "Install Clinical KB" })).not.toBeInTheDocument(); + const prompt = dispatchInstallEligibility(); + const installRegion = await screen.findByRole("region", { name: "Install Clinical KB" }); + expect(installRegion).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Install app" })); + expect(prompt).toHaveBeenCalledTimes(1); + await waitFor(() => expect(installRegion).not.toBeInTheDocument()); + }); + + it("requires a user decision before activating a waiting update", async () => { + window.history.replaceState({}, "", "/?pwa-dev=1"); + const waitingWorker = createWorker(); + installServiceWorkerStub(waitingWorker); + const user = userEvent.setup(); + render(); + + expect(await screen.findByText("An update is ready")).toBeInTheDocument(); + dispatchInstallEligibility(); + expect(screen.queryByRole("region", { name: "Install Clinical KB" })).not.toBeInTheDocument(); + expect(waitingWorker.postMessage).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Refresh now" })); + expect(waitingWorker.postMessage).toHaveBeenCalledWith({ type: "SKIP_WAITING" }); + }); + + it("offers a refresh when another tab activates an update instead of silently leaving stale UI", async () => { + window.history.replaceState({}, "", "/?pwa-dev=1"); + const { container } = installServiceWorkerStub(null, true); + render(); + + await waitFor(() => expect(container.register).toHaveBeenCalled()); + act(() => { + container.dispatchEvent(new Event("controllerchange")); + }); + + expect(await screen.findByRole("region", { name: "An update is ready" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refresh now" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Later" })).toBeInTheDocument(); + }); + + it("does not misreport the first controller claim as an application update", async () => { + window.history.replaceState({}, "", "/?pwa-dev=1"); + const { container } = installServiceWorkerStub(); + render(); + + await waitFor(() => expect(container.register).toHaveBeenCalled()); + act(() => { + container.dispatchEvent(new Event("controllerchange")); + }); + + expect(screen.queryByRole("region", { name: "An update is ready" })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/pwa-manifest.test.ts b/tests/pwa-manifest.test.ts new file mode 100644 index 000000000..13a8c7d6d --- /dev/null +++ b/tests/pwa-manifest.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import manifest from "../src/app/manifest"; +import { APP_THEME_COLORS, DEFAULT_THEME } from "../src/lib/theme"; + +describe("PWA manifest and public bootstrap resources", () => { + const appManifest = manifest(); + + it("defines a stable, scoped, standalone application identity", () => { + expect(appManifest).toMatchObject({ + id: "/", + start_url: "/", + scope: "/", + display: "standalone", + lang: "en-AU", + dir: "ltr", + background_color: APP_THEME_COLORS[DEFAULT_THEME], + theme_color: APP_THEME_COLORS[DEFAULT_THEME], + prefer_related_applications: false, + }); + expect(appManifest.name).toBeTruthy(); + expect(appManifest.short_name).toBeTruthy(); + expect(appManifest.description).toBeTruthy(); + }); + + it("provides install icons at required sizes with separate maskable artwork", () => { + const icons = appManifest.icons ?? []; + + for (const size of ["192x192", "512x512"]) { + expect(icons).toEqual(expect.arrayContaining([expect.objectContaining({ sizes: size, purpose: "any" })])); + expect(icons).toEqual(expect.arrayContaining([expect.objectContaining({ sizes: size, purpose: "maskable" })])); + } + + for (const icon of icons) { + expect(icon.src).toMatch(/^\//); + expect(icon.src).not.toMatch(/[?#]/); + } + }); + + it("keeps shortcuts inside app scope and free of clinical or credential payloads", () => { + expect(appManifest.shortcuts?.length).toBeGreaterThan(0); + + for (const shortcut of appManifest.shortcuts ?? []) { + const url = new URL(shortcut.url, "https://clinical-kb.invalid"); + expect(url.origin).toBe("https://clinical-kb.invalid"); + expect(url.pathname.startsWith("/")).toBe(true); + expect(url.username).toBe(""); + expect(url.password).toBe(""); + for (const forbiddenKey of ["q", "query", "answer", "document", "token", "key", "runId"]) { + expect(url.searchParams.has(forbiddenKey)).toBe(false); + } + } + }); + + it("does not advertise unsupported sensitive-capability handlers", () => { + expect(appManifest).not.toHaveProperty("share_target"); + expect(appManifest).not.toHaveProperty("file_handlers"); + expect(appManifest).not.toHaveProperty("protocol_handlers"); + }); + + it("ships a script-free, generic offline document with an explicit privacy boundary", () => { + const offlineHtml = readFileSync(join(process.cwd(), "public", "offline.html"), "utf8"); + + expect(offlineHtml).not.toMatch(/ { + const nextConfig = readFileSync(join(process.cwd(), "next.config.ts"), "utf8"); + + expect(nextConfig).toContain('source: "/sw.js"'); + expect(nextConfig).toContain('value: "no-cache, no-store, must-revalidate"'); + expect(nextConfig).toContain('{ key: "Service-Worker-Allowed", value: "/" }'); + expect(nextConfig).toContain('source: "/offline.html"'); + expect(nextConfig).toContain('{ key: "X-Robots-Tag", value: "noindex, nofollow" }'); + }); +}); diff --git a/tests/pwa-service-worker.test.ts b/tests/pwa-service-worker.test.ts new file mode 100644 index 000000000..31b095452 --- /dev/null +++ b/tests/pwa-service-worker.test.ts @@ -0,0 +1,643 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createContext, runInContext } from "node:vm"; + +import { describe, expect, it, vi } from "vitest"; + +const WORKER_SOURCE = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); +const OFFLINE_DOCUMENT = readFileSync(resolve(process.cwd(), "public/offline.html"), "utf8"); +const PRODUCTION_ORIGIN = "https://app.clinical-kb.test"; + +type WorkerListener = (event: unknown) => void; +type NetworkHandler = (request: Request) => Promise; + +interface WorkerRequestInit extends RequestInit { + destination?: string; +} + +function basicResponse(body: BodyInit | null, init: ResponseInit = {}): Response { + const response = new Response(body, init); + Object.defineProperty(response, "type", { configurable: true, value: "basic" }); + return response; +} + +class ExtendableEventHarness { + private readonly lifetimePromises: Promise[] = []; + + waitUntil(promise: Promise): void { + this.lifetimePromises.push(Promise.resolve(promise)); + } + + async settle(): Promise { + let settledCount = 0; + while (settledCount < this.lifetimePromises.length) { + const pending = this.lifetimePromises.slice(settledCount); + settledCount = this.lifetimePromises.length; + await Promise.all(pending); + } + } +} + +class FetchEventHarness extends ExtendableEventHarness { + readonly preloadResponse: Promise; + responsePromise: Promise | undefined; + + constructor( + readonly request: Request, + preloadResponse?: Response, + ) { + super(); + this.preloadResponse = Promise.resolve(preloadResponse); + } + + respondWith(response: Response | Promise): void { + this.responsePromise = Promise.resolve(response); + } +} + +interface StoredCacheEntry { + request: Request; + response: Response; +} + +interface CachePut { + cacheName: string; + url: string; +} + +class CacheHarness { + private readonly entries = new Map(); + + constructor( + readonly name: string, + private readonly origin: string, + private readonly RequestConstructor: typeof Request, + private readonly networkFetch: (input: RequestInfo | URL) => Promise, + private readonly putLog: CachePut[], + ) {} + + private toRequest(input: RequestInfo | URL): Request { + return input instanceof Request ? input : new this.RequestConstructor(input); + } + + private toUrl(input: RequestInfo | URL): string { + if (input instanceof Request) return input.url; + return new URL(String(input), this.origin).href; + } + + async add(input: RequestInfo | URL): Promise { + const request = this.toRequest(input); + const response = await this.networkFetch(request); + if (!response.ok) throw new TypeError(`Cache.add failed with HTTP ${response.status}`); + await this.put(request, response); + } + + async delete(input: RequestInfo | URL): Promise { + return this.entries.delete(this.toUrl(input)); + } + + async keys(): Promise { + return [...this.entries.values()].map(({ request }) => request); + } + + async match(input: RequestInfo | URL): Promise { + return this.entries.get(this.toUrl(input))?.response.clone(); + } + + async put(input: RequestInfo | URL, response: Response): Promise { + const request = this.toRequest(input); + this.entries.set(request.url, { request, response: response.clone() }); + this.putLog.push({ cacheName: this.name, url: request.url }); + } + + urls(): string[] { + return [...this.entries.keys()].sort(); + } +} + +class CacheStorageHarness { + private readonly stores = new Map(); + readonly deletedNames: string[] = []; + readonly putLog: CachePut[] = []; + + constructor( + private readonly origin: string, + private readonly RequestConstructor: typeof Request, + private readonly networkFetch: (input: RequestInfo | URL) => Promise, + ) {} + + async delete(name: string): Promise { + this.deletedNames.push(name); + return this.stores.delete(name); + } + + async keys(): Promise { + return [...this.stores.keys()]; + } + + async open(name: string): Promise { + let cache = this.stores.get(name); + if (!cache) { + cache = new CacheHarness(name, this.origin, this.RequestConstructor, this.networkFetch, this.putLog); + this.stores.set(name, cache); + } + return cache; + } + + entryUrls(): string[] { + return [...this.stores.values()].flatMap((cache) => cache.urls()).sort(); + } + + clearMutationLog(): void { + this.deletedNames.length = 0; + this.putLog.length = 0; + } +} + +interface FetchDispatchResult { + handled: boolean; + response?: Response; +} + +function createWorkerHarness(origin = PRODUCTION_ORIGIN) { + const listeners = new Map(); + let networkHandler: NetworkHandler = async (request) => { + const pathname = new URL(request.url).pathname; + if (pathname === "/offline.html") { + return basicResponse(OFFLINE_DOCUMENT, { + headers: { "Cache-Control": "public, max-age=0, must-revalidate", "Content-Type": "text/html; charset=utf-8" }, + }); + } + if (pathname === "/icon.svg") { + return basicResponse('', { + headers: { "Cache-Control": "public, max-age=0", "Content-Type": "image/svg+xml" }, + }); + } + if (pathname === "/manifest.webmanifest") { + return basicResponse("{}", { + headers: { "Cache-Control": "public, max-age=0", "Content-Type": "application/manifest+json" }, + }); + } + return basicResponse(`public asset: ${pathname}`, { + headers: { "Cache-Control": "public, max-age=3600" }, + }); + }; + + class WorkerRequest extends Request { + constructor(input: RequestInfo | URL, init: WorkerRequestInit = {}) { + const { destination, mode, ...nativeInit } = init; + const resolvedInput = input instanceof Request ? input : new URL(String(input), origin); + const requestInit: RequestInit = nativeInit; + if (mode && mode !== "navigate") requestInit.mode = mode; + super(resolvedInput, requestInit); + + if (mode === "navigate") { + Object.defineProperty(this, "mode", { configurable: true, value: "navigate" }); + } + Object.defineProperty(this, "destination", { + configurable: true, + value: destination ?? (input instanceof Request ? input.destination : ""), + }); + } + } + + const networkFetch = vi.fn(async (input: RequestInfo | URL) => { + const request = input instanceof Request ? input : new WorkerRequest(input); + return networkHandler(request); + }); + const cacheStorage = new CacheStorageHarness(origin, WorkerRequest, networkFetch); + const enableNavigationPreload = vi.fn(async () => undefined); + const claimClients = vi.fn(async () => undefined); + const skipWaiting = vi.fn(async () => undefined); + + const workerGlobal = { + addEventListener(type: string, listener: WorkerListener) { + const registered = listeners.get(type) ?? []; + registered.push(listener); + listeners.set(type, registered); + }, + clients: { claim: claimClients }, + location: new URL(origin), + registration: { navigationPreload: { enable: enableNavigationPreload } }, + skipWaiting, + }; + + runInContext( + WORKER_SOURCE, + createContext({ + URL, + Headers, + Request: WorkerRequest, + Response, + caches: cacheStorage, + console, + fetch: networkFetch, + self: workerGlobal, + }), + { filename: "public/sw.js" }, + ); + + const dispatch = (type: string, event: unknown): void => { + for (const listener of listeners.get(type) ?? []) listener(event); + }; + + return { + Request: WorkerRequest, + caches: cacheStorage, + claimClients, + enableNavigationPreload, + networkFetch, + skipWaiting, + async activate(): Promise { + const event = new ExtendableEventHarness(); + dispatch("activate", event); + await event.settle(); + }, + async dispatchFetch(request: Request, preloadResponse?: Response): Promise { + const event = new FetchEventHarness(request, preloadResponse); + dispatch("fetch", event); + + if (!event.responsePromise) { + await event.settle(); + return { handled: false }; + } + + const response = await event.responsePromise; + await event.settle(); + return { handled: true, response }; + }, + async install(): Promise { + const event = new ExtendableEventHarness(); + dispatch("install", event); + await event.settle(); + }, + message(data: unknown): void { + dispatch("message", { data }); + }, + setNetworkHandler(handler: NetworkHandler): void { + networkHandler = handler; + }, + }; +} + +describe("PWA service worker cache and lifecycle policy", () => { + it("precaches only the generic offline shell and public PWA identity assets during install", async () => { + const worker = createWorkerHarness(); + + await worker.install(); + + const cacheNames = await worker.caches.keys(); + expect(cacheNames).toHaveLength(1); + expect(cacheNames[0]).toMatch(/^clinical-kb-pwa-shell-/); + expect(worker.caches.entryUrls()).toEqual([ + `${PRODUCTION_ORIGIN}/icon.svg`, + `${PRODUCTION_ORIGIN}/manifest.webmanifest`, + `${PRODUCTION_ORIGIN}/offline.html`, + ]); + + const shellCache = await worker.caches.open(cacheNames[0]); + const offlineResponse = await shellCache.match("/offline.html"); + expect(await offlineResponse?.text()).toBe(OFFLINE_DOCUMENT); + expect(OFFLINE_DOCUMENT).toContain("Clinical KB is offline"); + expect(OFFLINE_DOCUMENT).toMatch(/does not store or\s+replay clinical queries, answers, documents/); + expect(worker.networkFetch).toHaveBeenCalledTimes(3); + for (const [request] of worker.networkFetch.mock.calls) { + expect((request as Request).credentials).toBe("omit"); + } + expect(worker.skipWaiting).not.toHaveBeenCalled(); + }); + + it("fails installation instead of caching an unsafe required offline response", async () => { + const worker = createWorkerHarness(); + worker.setNetworkHandler(async () => + basicResponse("private gateway", { + headers: { "Cache-Control": "private", "Content-Type": "text/html; charset=utf-8" }, + }), + ); + + await expect(worker.install()).rejects.toThrow("Unsafe PWA precache response for /offline.html"); + expect(worker.caches.entryUrls()).toEqual([]); + }); + + it("keeps optional identity assets best-effort when one returns the wrong MIME type", async () => { + const worker = createWorkerHarness(); + worker.setNetworkHandler(async (request) => { + const pathname = new URL(request.url).pathname; + if (pathname === "/offline.html") { + return basicResponse(OFFLINE_DOCUMENT, { + headers: { "Cache-Control": "public, max-age=0", "Content-Type": "text/html; charset=utf-8" }, + }); + } + if (pathname === "/icon.svg") { + return basicResponse("", { + headers: { "Cache-Control": "public, max-age=0", "Content-Type": "image/svg+xml" }, + }); + } + return basicResponse("not a manifest", { + headers: { "Cache-Control": "public, max-age=0", "Content-Type": "text/html; charset=utf-8" }, + }); + }); + + await worker.install(); + + expect(worker.caches.entryUrls()).toEqual([`${PRODUCTION_ORIGIN}/icon.svg`, `${PRODUCTION_ORIGIN}/offline.html`]); + }); + + it("deletes obsolete shell and older static caches while retaining two prior static versions", async () => { + const worker = createWorkerHarness(); + await worker.install(); + const [currentShellCache] = await worker.caches.keys(); + const obsoleteShellCache = "clinical-kb-pwa-shell-2025-v1"; + const staticCaches = [ + "clinical-kb-pwa-static-2025-v1", + "clinical-kb-pwa-static-2026-06-v1", + "clinical-kb-pwa-static-2026-07-v1", + ]; + const unrelatedCache = "another-product-cache-v4"; + + for (const name of [obsoleteShellCache, ...staticCaches, unrelatedCache]) await worker.caches.open(name); + worker.caches.clearMutationLog(); + + await worker.activate(); + + expect((await worker.caches.keys()).sort()).toEqual( + [currentShellCache, ...staticCaches.slice(-2), unrelatedCache].sort(), + ); + expect(worker.caches.deletedNames.sort()).toEqual([obsoleteShellCache, staticCaches[0]].sort()); + expect(worker.enableNavigationPreload).toHaveBeenCalledOnce(); + expect(worker.claimClients).toHaveBeenCalledOnce(); + expect(worker.skipWaiting).not.toHaveBeenCalled(); + }); + + it("calls skipWaiting only for an explicit SKIP_WAITING user message", async () => { + const worker = createWorkerHarness(); + await worker.install(); + await worker.activate(); + + worker.message(undefined); + worker.message("SKIP_WAITING"); + worker.message({ type: "OTHER" }); + worker.message({ type: "skip_waiting" }); + expect(worker.skipWaiting).not.toHaveBeenCalled(); + + worker.message({ type: "SKIP_WAITING" }); + expect(worker.skipWaiting).toHaveBeenCalledOnce(); + }); + + it("returns the offline shell for a failed navigation without caching navigated HTML", async () => { + const worker = createWorkerHarness(); + await worker.install(); + worker.caches.clearMutationLog(); + worker.networkFetch.mockClear(); + + const onlineUrl = `${PRODUCTION_ORIGIN}/private/clinical-answer`; + worker.setNetworkHandler(async (request) => { + if (request.url === onlineUrl) { + return basicResponse("private answer", { + headers: { "Cache-Control": "private, no-store", "Content-Type": "text/html" }, + }); + } + throw new TypeError("offline"); + }); + + const onlineNavigation = await worker.dispatchFetch( + new worker.Request(onlineUrl, { destination: "document", mode: "navigate" }), + ); + expect(await onlineNavigation.response?.text()).toContain("private answer"); + expect(worker.caches.entryUrls()).not.toContain(onlineUrl); + + worker.setNetworkHandler(async () => { + throw new TypeError("offline"); + }); + const failedUrl = `${PRODUCTION_ORIGIN}/documents/private-record`; + const failedNavigation = await worker.dispatchFetch( + new worker.Request(failedUrl, { destination: "document", mode: "navigate" }), + ); + + expect(failedNavigation.handled).toBe(true); + expect(failedNavigation.response?.status).toBe(200); + expect(await failedNavigation.response?.text()).toBe(OFFLINE_DOCUMENT); + expect(worker.caches.entryUrls()).not.toContain(failedUrl); + expect(worker.caches.putLog).toEqual([]); + }); + + it("returns the in-memory emergency page when CacheStorage is unavailable offline", async () => { + const worker = createWorkerHarness(); + worker.setNetworkHandler(async () => { + throw new TypeError("offline"); + }); + vi.spyOn(worker.caches, "open").mockRejectedValue(new Error("CacheStorage unavailable")); + + const result = await worker.dispatchFetch( + new worker.Request(`${PRODUCTION_ORIGIN}/cold-offline`, { mode: "navigate" }), + ); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(503); + expect(await result.response?.text()).toContain("No clinical content is stored"); + }); + + it("cache-first serves a production-host same-origin hashed static asset", async () => { + const worker = createWorkerHarness(); + const assetUrl = `${PRODUCTION_ORIGIN}/_next/static/chunks/app-8f31c9a2.js`; + worker.setNetworkHandler(async () => + basicResponse("self.__HASHED_CHUNK__ = true;", { + headers: { "Cache-Control": "public, max-age=31536000, immutable", "Content-Type": "text/javascript" }, + }), + ); + const request = new worker.Request(assetUrl, { destination: "script" }); + + const first = await worker.dispatchFetch(request); + const second = await worker.dispatchFetch(request); + + expect(first.handled).toBe(true); + expect(second.handled).toBe(true); + expect(await first.response?.text()).toContain("HASHED_CHUNK"); + expect(await second.response?.text()).toContain("HASHED_CHUNK"); + expect(worker.networkFetch).toHaveBeenCalledOnce(); + expect((worker.networkFetch.mock.calls[0]?.[0] as Request).credentials).toBe("omit"); + expect(worker.caches.entryUrls()).toEqual([assetUrl]); + expect((await worker.caches.keys())[0]).toMatch(/^clinical-kb-pwa-static-/); + }); + + it("honours a reload request by bypassing an existing static-cache entry", async () => { + const worker = createWorkerHarness(); + const assetUrl = `${PRODUCTION_ORIGIN}/_next/static/chunks/reload-8f31c9a2.js`; + let revision = 1; + worker.setNetworkHandler(async () => + basicResponse(`self.__REVISION__ = ${revision};`, { + headers: { "Cache-Control": "public, max-age=31536000", "Content-Type": "text/javascript" }, + }), + ); + + await worker.dispatchFetch(new worker.Request(assetUrl, { destination: "script" })); + revision = 2; + const reloaded = await worker.dispatchFetch( + new worker.Request(assetUrl, { cache: "reload", destination: "script" }), + ); + + expect(await reloaded.response?.text()).toContain("REVISION__ = 2"); + expect(worker.networkFetch).toHaveBeenCalledTimes(2); + }); + + it("falls through to the network when CacheStorage reads fail for an online static asset", async () => { + const worker = createWorkerHarness(); + const assetUrl = `${PRODUCTION_ORIGIN}/_next/static/chunks/cache-failure-8f31c9a2.js`; + vi.spyOn(worker.caches, "open").mockRejectedValue(new Error("CacheStorage unavailable")); + worker.setNetworkHandler(async () => + basicResponse("self.__NETWORK_FALLBACK__ = true;", { + headers: { "Cache-Control": "public, max-age=31536000", "Content-Type": "text/javascript" }, + }), + ); + + const result = await worker.dispatchFetch(new worker.Request(assetUrl, { destination: "script" })); + + expect(result.handled).toBe(true); + expect(await result.response?.text()).toContain("NETWORK_FALLBACK"); + expect(worker.networkFetch).toHaveBeenCalledOnce(); + }); + + it("serves a retained prior-version static chunk to an old tab after activation", async () => { + const worker = createWorkerHarness(); + const assetUrl = `${PRODUCTION_ORIGIN}/_next/static/chunks/old-client-8f31c9a2.js`; + const request = new worker.Request(assetUrl, { destination: "script" }); + const oldCache = await worker.caches.open("clinical-kb-pwa-static-2026-07-v0"); + await oldCache.put( + request, + basicResponse("self.__OLD_CLIENT_CHUNK__ = true;", { + headers: { "Cache-Control": "public, max-age=31536000", "Content-Type": "text/javascript" }, + }), + ); + worker.caches.clearMutationLog(); + worker.setNetworkHandler(async () => { + throw new TypeError("old chunk no longer on origin"); + }); + + await worker.activate(); + const result = await worker.dispatchFetch(request); + + expect(result.handled).toBe(true); + expect(await result.response?.text()).toContain("OLD_CLIENT_CHUNK"); + expect(worker.networkFetch).not.toHaveBeenCalled(); + }); + + it.each<{ + absoluteUrl?: string; + cache?: RequestCache; + destination?: string; + headers?: Record; + label: string; + method?: string; + url?: string; + }>([ + { label: "API", url: "/api/search" }, + { label: "auth", url: "/auth/callback" }, + { label: "private document", url: "/documents/private-record.pdf", destination: "document" }, + { label: "undefined PWA namespace", url: "/pwa/private-export.json", destination: "" }, + { label: "clinical query", url: "/search?q=lithium" }, + { label: "RSC payload", url: "/dashboard?_rsc=private-tree", headers: { RSC: "1" } }, + { + label: "cross-origin static asset", + absoluteUrl: "https://cdn.example.test/_next/static/chunks/app-8f31c9a2.js", + destination: "script", + }, + { + label: "non-GET static request", + url: "/_next/static/chunks/app-8f31c9a2.js", + destination: "script", + method: "POST", + }, + { + label: "authorized static request", + url: "/_next/static/chunks/app-8f31c9a2.js", + destination: "script", + headers: { Authorization: "Bearer private-token" }, + }, + { + label: "range static request", + url: "/_next/static/media/font-8f31c9a2.woff2", + destination: "font", + headers: { Range: "bytes=0-99" }, + }, + { + label: "query-string static request", + url: "/_next/static/chunks/app-8f31c9a2.js?tenant=private", + destination: "script", + }, + { + label: "no-store static request", + url: "/_next/static/chunks/app-8f31c9a2.js", + destination: "script", + cache: "no-store", + }, + ])( + "does not intercept or cache a $label request", + async ({ absoluteUrl, cache, destination, headers, method, url }) => { + const worker = createWorkerHarness(); + const request = new worker.Request(absoluteUrl ?? `${PRODUCTION_ORIGIN}${url}`, { + cache, + destination, + headers: headers as HeadersInit | undefined, + method, + }); + + const result = await worker.dispatchFetch(request); + + expect(result.handled).toBe(false); + expect(worker.networkFetch).not.toHaveBeenCalled(); + expect(worker.caches.entryUrls()).toEqual([]); + expect(worker.caches.putLog).toEqual([]); + }, + ); + + it("never prunes the required offline fallback while bounding shell assets", async () => { + const worker = createWorkerHarness(); + await worker.install(); + worker.setNetworkHandler(async () => + basicResponse("png", { + headers: { "Cache-Control": "public, max-age=3600", "Content-Type": "image/png" }, + }), + ); + + for (let index = 0; index < 20; index += 1) { + await worker.dispatchFetch( + new worker.Request(`${PRODUCTION_ORIGIN}/icons/runtime-${index}`, { destination: "image" }), + ); + } + + const shellEntries = worker.caches.entryUrls(); + expect(shellEntries).toContain(`${PRODUCTION_ORIGIN}/offline.html`); + expect(shellEntries).toHaveLength(16); + }); + + it.each<{ headers: Record; label: string }>([ + { label: "private", headers: { "Cache-Control": "private, max-age=31536000" } }, + { label: "no-store", headers: { "Cache-Control": "public, no-store, max-age=31536000" } }, + { + label: "Set-Cookie", + headers: { "Cache-Control": "public, max-age=31536000", "Set-Cookie": "session=private" }, + }, + { label: "HTML", headers: { "Content-Type": "text/html; charset=utf-8" } }, + { label: "wrong-MIME", headers: { "Content-Type": "application/json" } }, + { label: "cookie-varying", headers: { Vary: "Accept-Encoding, Cookie" } }, + { label: "authorization-varying", headers: { Vary: "Authorization" } }, + { label: "attachment", headers: { "Content-Disposition": 'attachment; filename="private.bin"' } }, + { label: "authentication-challenge", headers: { "WWW-Authenticate": 'Bearer realm="private"' } }, + ])("serves but never stores a $label response", async ({ headers, label }) => { + const worker = createWorkerHarness(); + const assetUrl = `${PRODUCTION_ORIGIN}/_next/static/chunks/${label.toLowerCase()}-8f31c9a2.js`; + worker.setNetworkHandler(async () => + basicResponse(`/* ${label} response */`, { + headers: { "Content-Type": "text/javascript", ...headers }, + }), + ); + + const result = await worker.dispatchFetch(new worker.Request(assetUrl, { destination: "script" })); + + expect(result.handled).toBe(true); + expect(await result.response?.text()).toContain(label); + expect(worker.networkFetch).toHaveBeenCalledOnce(); + expect(worker.caches.entryUrls()).toEqual([]); + expect(worker.caches.putLog).toEqual([]); + }); +}); diff --git a/tests/security-headers.test.ts b/tests/security-headers.test.ts index 674f6adb2..169f8d60c 100644 --- a/tests/security-headers.test.ts +++ b/tests/security-headers.test.ts @@ -53,6 +53,13 @@ describe("security headers", () => { expect(byKey.get("X-Frame-Options")).toBe("DENY"); expect(byKey.get("Cross-Origin-Opener-Policy")).toBe("same-origin"); }); + + it("restricts PWA workers and manifests to this origin", () => { + const workerSrc = csp.split(";").find((directive) => directive.trim().startsWith("worker-src")); + const manifestSrc = csp.split(";").find((directive) => directive.trim().startsWith("manifest-src")); + expect(workerSrc?.trim()).toBe("worker-src 'self'"); + expect(manifestSrc?.trim()).toBe("manifest-src 'self'"); + }); }); } diff --git a/tests/theme.test.ts b/tests/theme.test.ts index 4cf00fa45..648c2ddc5 100644 --- a/tests/theme.test.ts +++ b/tests/theme.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { nextTheme, resolveThemePreference } from "../src/lib/theme"; +import { APP_THEME_COLORS, nextTheme, resolveThemePreference } from "../src/lib/theme"; describe("theme helpers", () => { it("uses stored explicit theme before system preference", () => { @@ -16,4 +16,8 @@ describe("theme helpers", () => { expect(nextTheme("light")).toBe("dark"); expect(nextTheme("dark")).toBe("light"); }); + + it("keeps installed-app browser chrome aligned with both application themes", () => { + expect(APP_THEME_COLORS).toEqual({ light: "#ffffff", dark: "#060708" }); + }); }); diff --git a/tests/ui-pwa.spec.ts b/tests/ui-pwa.spec.ts new file mode 100644 index 000000000..910eb8729 --- /dev/null +++ b/tests/ui-pwa.spec.ts @@ -0,0 +1,283 @@ +import { expect, test, type BrowserContext, type Page } from "playwright/test"; + +const PWA_ENTRY = "/?pwa-dev=1"; +const WORKER_PATH = "/sw.js"; +const PWA_CACHE_PREFIX = "clinical-kb-pwa-"; + +type ManifestIcon = { + src: string; + type: string; + sizes: string; + purpose?: string; +}; + +type IconProbe = { + src: string; + status: number; + mime: string; + width: number | null; + height: number | null; + hasPngSignature: boolean | null; +}; + +async function openControlledPwa(page: Page) { + await page.goto(PWA_ENTRY, { waitUntil: "load" }); + + await expect + .poll( + () => + page.evaluate(async () => { + const registration = await navigator.serviceWorker.getRegistration("/"); + return registration?.active?.state ?? "missing"; + }), + { message: "the root-scope PWA worker should activate", timeout: 30_000 }, + ) + .toBe("activated"); + + // A controlled navigation gives Chromium a stable point at which to evaluate + // the matching worker and avoids asserting transient installability errors. + await page.reload({ waitUntil: "load" }); + await expect + .poll(() => page.evaluate(() => navigator.serviceWorker.controller?.scriptURL ?? ""), { + message: "the page should be controlled by the PWA worker", + timeout: 15_000, + }) + .toMatch(/\/sw\.js$/); +} + +async function probeManifestIcons(page: Page, icons: ManifestIcon[]): Promise { + return page.evaluate(async (manifestIcons) => { + const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]; + + return Promise.all( + manifestIcons.map(async (icon) => { + const response = await fetch(new URL(icon.src, window.location.origin), { + cache: "no-store", + credentials: "omit", + }); + const body = await response.arrayBuffer(); + const bytes = new Uint8Array(body); + const mime = response.headers.get("content-type")?.split(";")[0]?.trim() ?? ""; + let width: number | null = null; + let height: number | null = null; + let hasPngSignature: boolean | null = null; + + if (mime === "image/png") { + hasPngSignature = pngSignature.every((byte, index) => bytes[index] === byte); + if (hasPngSignature && bytes.length >= 24) { + const view = new DataView(body); + width = view.getUint32(16); + height = view.getUint32(20); + } + } else if (mime === "image/svg+xml") { + const svg = new TextDecoder().decode(bytes); + const viewBox = svg.match(/viewBox=["']\s*[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)\s*["']/i); + width = viewBox ? Number(viewBox[1]) : null; + height = viewBox ? Number(viewBox[2]) : null; + } + + return { + src: icon.src, + status: response.status, + mime, + width, + height, + hasPngSignature, + }; + }), + ); + }, icons); +} + +async function clearPwaBrowserState(context: BrowserContext, page: Page) { + await context.setOffline(false); + if (page.isClosed() || !page.url().startsWith("http")) return; + + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all( + registrations + .filter((registration) => registration.active?.scriptURL.endsWith("/sw.js")) + .map((registration) => registration.unregister()), + ); + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames + .filter((cacheName) => cacheName.startsWith(PWA_CACHE_PREFIX)) + .map((cacheName) => caches.delete(cacheName)), + ); + }); +} + +function isAllowedPublicCachePath(pathname: string) { + return ( + pathname === "/offline.html" || + pathname === "/manifest.webmanifest" || + pathname === "/icon.svg" || + pathname === "/apple-icon" || + pathname.startsWith("/icons/") || + pathname.startsWith("/_next/static/") + ); +} + +test.describe("Clinical KB PWA", () => { + test.beforeEach(async ({ browserName, context }) => { + test.skip(browserName !== "chromium", "The installability protocol and focused PWA gate are Chromium-only."); + + // This gate validates the public application shell only. Block every local + // API and provider request so browser QA cannot consume credentials, touch + // live data, or make a clinical workflow look available offline. + await context.route("**/api/**", (route) => route.abort("blockedbyclient")); + await context.route(/^https:\/\/[^/]*\.supabase\.co\//i, (route) => route.abort("blockedbyclient")); + await context.route(/^https:\/\/api\.openai\.com\//i, (route) => route.abort("blockedbyclient")); + }); + + test.afterEach(async ({ browserName, context, page }) => { + if (browserName === "chromium") await clearPwaBrowserState(context, page); + }); + + test("has a browser-valid manifest, installable icons, and a root worker", async ({ context, page }) => { + await openControlledPwa(page); + + const session = await context.newCDPSession(page); + const appManifest = await session.send("Page.getAppManifest"); + expect(new URL(appManifest.url).pathname).toBe("/manifest.webmanifest"); + expect(appManifest.errors, JSON.stringify(appManifest.errors)).toEqual([]); + expect(appManifest.data).toBeTruthy(); + + const manifest = JSON.parse(appManifest.data ?? "{}") as { + id?: string; + start_url?: string; + scope?: string; + display?: string; + icons?: ManifestIcon[]; + }; + expect(manifest).toMatchObject({ id: "/", start_url: "/", scope: "/", display: "standalone" }); + + const icons = manifest.icons ?? []; + expect(icons.length).toBeGreaterThanOrEqual(5); + const probes = await probeManifestIcons(page, icons); + const probesBySource = new Map(probes.map((probe) => [probe.src, probe])); + + for (const icon of icons) { + const probe = probesBySource.get(icon.src); + if (!probe) throw new Error(`Manifest icon was not probed: ${icon.src}`); + + expect(probe.status, icon.src).toBe(200); + expect(probe.mime, icon.src).toBe(icon.type); + expect(probe.width, icon.src).toBeGreaterThan(0); + expect(probe.height, icon.src).toBeGreaterThan(0); + + if (icon.sizes !== "any") { + const size = icon.sizes.match(/^(\d+)x(\d+)$/); + expect(size, `${icon.src} should declare concrete dimensions`).not.toBeNull(); + expect(probe.width, icon.src).toBe(Number(size?.[1])); + expect(probe.height, icon.src).toBe(Number(size?.[2])); + expect(probe.hasPngSignature, icon.src).toBe(true); + } + } + + const workerResponse = await context.request.get(new URL(WORKER_PATH, page.url()).toString()); + expect(workerResponse.status()).toBe(200); + const workerHeaders = workerResponse.headers(); + expect(workerHeaders["content-type"]).toMatch(/^application\/javascript\b/i); + expect(workerHeaders["cache-control"]).toContain("no-cache"); + expect(workerHeaders["cache-control"]).toContain("no-store"); + expect(workerHeaders["service-worker-allowed"]).toBe("/"); + expect(workerHeaders["cross-origin-resource-policy"]).toBe("same-origin"); + expect(workerHeaders["content-security-policy"]).toContain("default-src 'self'"); + expect(workerHeaders["content-security-policy"]).toContain("script-src 'self'"); + expect(workerHeaders["set-cookie"]).toBeUndefined(); + + const manifestResponse = await context.request.get(new URL("/manifest.webmanifest", page.url()).toString()); + expect(manifestResponse.status()).toBe(200); + expect(manifestResponse.headers()["content-type"]).toMatch(/^application\/manifest\+json\b/i); + expect(manifestResponse.headers()["cache-control"]).toContain("must-revalidate"); + expect(manifestResponse.headers()["set-cookie"]).toBeUndefined(); + + const offlineDocumentResponse = await context.request.get(new URL("/offline.html", page.url()).toString()); + expect(offlineDocumentResponse.status()).toBe(200); + expect(offlineDocumentResponse.headers()["content-security-policy"]).toContain("default-src 'none'"); + expect(offlineDocumentResponse.headers()["x-robots-tag"]).toBe("noindex, nofollow"); + expect(offlineDocumentResponse.headers()["set-cookie"]).toBeUndefined(); + + const workerState = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.getRegistration("/"); + return { + origin: window.location.origin, + scope: registration?.scope ?? null, + scriptURL: registration?.active?.scriptURL ?? null, + activeState: registration?.active?.state ?? null, + updateViaCache: registration?.updateViaCache ?? null, + controllerURL: navigator.serviceWorker.controller?.scriptURL ?? null, + }; + }); + expect(workerState).toEqual({ + origin: workerState.origin, + scope: `${workerState.origin}/`, + scriptURL: `${workerState.origin}/sw.js`, + activeState: "activated", + updateViaCache: "none", + controllerURL: `${workerState.origin}/sw.js`, + }); + + await expect + .poll( + async () => { + const { installabilityErrors } = await session.send("Page.getInstallabilityErrors"); + return installabilityErrors.map((error) => error.errorId); + }, + { + message: "Chromium should report no stable installability errors after the root worker controls the page", + timeout: 15_000, + }, + ) + .toEqual([]); + }); + + test("serves a cold offline fallback, recovers online, and keeps private URLs out of CacheStorage", async ({ + context, + page, + }) => { + await openControlledPwa(page); + + await context.setOffline(true); + const coldPath = `/pwa-offline-cold-${Date.now()}`; + const offlineResponse = await page.goto(coldPath, { waitUntil: "domcontentloaded" }); + expect(offlineResponse?.status()).toBe(200); + expect(offlineResponse?.fromServiceWorker()).toBe(true); + await expect(page).toHaveURL(new RegExp(`${coldPath}$`)); + await expect(page.getByRole("heading", { name: "Clinical KB is offline" })).toBeVisible(); + await expect(page.getByText("does not store or replay clinical queries", { exact: false })).toBeVisible(); + + await context.setOffline(false); + await Promise.all([ + page.waitForURL((url) => url.pathname === "/" && url.search === "", { waitUntil: "domcontentloaded" }), + page.getByRole("link", { name: "Try again" }).click(), + ]); + await expect(page).toHaveTitle("Clinical KB"); + await expect(page.locator("#main-content")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Clinical KB is offline" })).toHaveCount(0); + + const inventory = await page.evaluate(async () => { + const entries: Array<{ cacheName: string; url: string }> = []; + for (const cacheName of await caches.keys()) { + const cache = await caches.open(cacheName); + for (const request of await cache.keys()) entries.push({ cacheName, url: request.url }); + } + return entries; + }); + + expect(inventory.length).toBeGreaterThan(0); + const origin = new URL(page.url()).origin; + const sensitivePath = /\/(?:api|auth|login|account|documents?|search|answers?|uploads?)(?:\/|$)/i; + for (const entry of inventory) { + const cachedUrl = new URL(entry.url); + expect(entry.cacheName).toMatch(new RegExp(`^${PWA_CACHE_PREFIX}`)); + expect(cachedUrl.origin, entry.url).toBe(origin); + expect(cachedUrl.search, entry.url).toBe(""); + expect(cachedUrl.pathname, entry.url).not.toMatch(sensitivePath); + expect(isAllowedPublicCachePath(cachedUrl.pathname), entry.url).toBe(true); + } + }); +}); From 35fa8c929d44a9bd84b3f7f2b795354d3b6dae02 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:37:04 +0800 Subject: [PATCH 2/5] test(pwa): pass cache prefix into browser cleanup --- tests/ui-pwa.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ui-pwa.spec.ts b/tests/ui-pwa.spec.ts index 910eb8729..17e70dbf6 100644 --- a/tests/ui-pwa.spec.ts +++ b/tests/ui-pwa.spec.ts @@ -93,7 +93,7 @@ async function clearPwaBrowserState(context: BrowserContext, page: Page) { await context.setOffline(false); if (page.isClosed() || !page.url().startsWith("http")) return; - await page.evaluate(async () => { + await page.evaluate(async (cachePrefix) => { const registrations = await navigator.serviceWorker.getRegistrations(); await Promise.all( registrations @@ -102,11 +102,9 @@ async function clearPwaBrowserState(context: BrowserContext, page: Page) { ); const cacheNames = await caches.keys(); await Promise.all( - cacheNames - .filter((cacheName) => cacheName.startsWith(PWA_CACHE_PREFIX)) - .map((cacheName) => caches.delete(cacheName)), + cacheNames.filter((cacheName) => cacheName.startsWith(cachePrefix)).map((cacheName) => caches.delete(cacheName)), ); - }); + }, PWA_CACHE_PREFIX); } function isAllowedPublicCachePath(pathname: string) { @@ -121,6 +119,8 @@ function isAllowedPublicCachePath(pathname: string) { } test.describe("Clinical KB PWA", () => { + test.describe.configure({ timeout: 120_000 }); + test.beforeEach(async ({ browserName, context }) => { test.skip(browserName !== "chromium", "The installability protocol and focused PWA gate are Chromium-only."); From 8f2d66408e99f38e34bf68bd0b4c06111acdf1a4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:37:57 +0800 Subject: [PATCH 3/5] docs(review): record PWA release review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 5eeb0f47c..79a6a6765 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -561,3 +561,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-15 | codex/design-polish-pass | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f + reviewed working diff | full live design, responsive, UX, accessibility, design-system, routing, performance, lint, testing, documentation, and release-readiness review | No P0/P1 reproduced. Fixed the P2 duplicate phone scroll surface in the shared standalone shell and added a regression test; fixed mode-menu Tab dismissal; explicitly hid decorative dynamic icons; restricted press scaling to motion-safe environments; moved sheet backdrops and forced-colour glass/backdrop behavior onto design tokens. External target fidelity remains blocked because no independent Figma/mockup source was supplied. | Live 30-route desktop + 21-route phone sweep; targeted 320/390/639/768/1440/1920 proofs; `npm run verify:cheap` (2417 passed/1 skipped); `npm run verify:ui` (175/175); focused keyboard 1/1; accessibility media/axe 5/5; scoped Prettier, ESLint, TypeScript, type-scale, and icon-scale passed. Provider-backed checks not run. | | 2026-07-15 | codex/documents-closed-default | 49f63791bced2b1764a11ab723aea94b45b026b6 | documents viewer disclosure defaults and related defect hunt | Fixed the inconsistent default-open document viewer sections by making indexed text, high-yield summary, tables/diagrams, and indexing details a native mutually exclusive closed disclosure group. The section navigation opens its requested disclosure and deep-linked evidence still reveals its target. The hunt also removed the explicitly open nested table-review queue, preserved printable summary content through the browser print lifecycle, and added cold-server readiness guards to the affected viewer tests. No other high-confidence default-open defect remains in the live Documents scope. | `npm run verify:cheap`; TypeScript; focused ESLint/Prettier; clean-worktree mocked Chromium coverage for deep-linked evidence, structured summary, closed/mutually-exclusive disclosures, navigation opening, and print state restore; `git diff --check`. Turbopack could not run through the local external `node_modules` junction, so clean browser verification used Next's supported Webpack dev mode. No Supabase/OpenAI/live-provider checks run. | | 2026-07-15 | HEAD / main snapshot (detached review worktree) | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f | comprehensive repository review | Changes requested: two P1 defects (high-risk clinical claim support can accept a different trigger condition on lexical overlap; document-mode URL auto-run loops on navigation and leaves search loading indefinitely), one P2 supply-chain guard gap (the action-pin checker accepts mutable major tags and ignores SHA-pinned major versions), and one P3 orientation-doc gap (DSM and legacy specifier routes are absent from the codebase index). No P0 found. | Node 24/npm 11 and `npm ls --depth=0`; format, runtime, lint, TypeScript, sitemap/brand/icon/type-scale, docs links/scripts, CI/action/Codex guards; coverage 259 files passed/1 skipped and 2,417 tests passed/1 skipped; offline RAG 36 fixtures plus 282 tests; production build/client-secret scan; deployment boot smoke; critical Chromium 8/10 with two reproducible document-search failures; accessibility 5/5; viewport/focus checks through 1920x1080. Provider-backed governance, quality, drift, tenancy, hosted CI, and the cross-browser matrix were blocked or skipped by policy/targeted failures. | +| 2026-07-17 | codex/pwa-privacy-safe-20260717 | 35fa8c929d44a9bd84b3f7f2b795354d3b6dae02 | privacy-safe PWA shell and merge-readiness review | No remaining high-confidence product defect in the changed scope. The pre-push browser gate found and fixed one P2 test defect: cleanup referenced `PWA_CACHE_PREFIX` without passing it into the browser context, and the cold installability flow now has a focused 120-second budget. The worker caches only the generic offline page and allow-listed public shell assets; navigations, APIs, auth, queries, documents, uploads, signed URLs, range requests, and cross-origin traffic remain network-only. | Current-main integration; focused Vitest 81/81; full uncached ESLint; TypeScript; scoped Prettier and diff checks; production Webpack build generated 1,043 pages and the client-bundle secret scan passed; full Vitest produced 2,506 passes plus six contention timeouts, with all affected files passing 24/24 serially; focused Chromium PWA 2/2. No Supabase/OpenAI/live-provider checks run. | From 5b227f496236caeb59db50b6dc44450ef515fea0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:59:05 +0800 Subject: [PATCH 4/5] fix(pwa): strengthen public asset privacy coverage --- src/proxy.ts | 2 +- tests/proxy-session-refresh.test.ts | 2 +- tests/pwa-service-worker.test.ts | 4 ---- tests/ui-pwa.spec.ts | 22 ++++++++++++++++++++++ 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index a053bc6b7..da089ed92 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -27,7 +27,7 @@ const documentFlowRedirects: Record = { "/mockups/document-search-command": "/documents/search", }; -const publicPwaPaths = new Set(["/sw.js", "/offline.html", "/manifest.webmanifest", "/apple-icon"]); +const publicPwaPaths = new Set(["/sw.js", "/offline.html", "/manifest.webmanifest", "/apple-icon", "/icon.svg"]); export function isPublicPwaPath(pathname: string) { return publicPwaPaths.has(pathname) || pathname.startsWith("/icons/"); diff --git a/tests/proxy-session-refresh.test.ts b/tests/proxy-session-refresh.test.ts index dbc4e642b..c380b94e1 100644 --- a/tests/proxy-session-refresh.test.ts +++ b/tests/proxy-session-refresh.test.ts @@ -77,7 +77,7 @@ describe("proxy session refresh scoping", () => { expect(getUser).not.toHaveBeenCalled(); }); - it.each(["/sw.js", "/offline.html", "/manifest.webmanifest", "/apple-icon", "/icons/icon-192"])( + it.each(["/sw.js", "/offline.html", "/manifest.webmanifest", "/apple-icon", "/icon.svg", "/icons/icon-192"])( "keeps the public PWA bootstrap path %s independent from user sessions", async (path) => { const { proxy } = await import("../src/proxy"); diff --git a/tests/pwa-service-worker.test.ts b/tests/pwa-service-worker.test.ts index 31b095452..ed5339c81 100644 --- a/tests/pwa-service-worker.test.ts +++ b/tests/pwa-service-worker.test.ts @@ -613,10 +613,6 @@ describe("PWA service worker cache and lifecycle policy", () => { it.each<{ headers: Record; label: string }>([ { label: "private", headers: { "Cache-Control": "private, max-age=31536000" } }, { label: "no-store", headers: { "Cache-Control": "public, no-store, max-age=31536000" } }, - { - label: "Set-Cookie", - headers: { "Cache-Control": "public, max-age=31536000", "Set-Cookie": "session=private" }, - }, { label: "HTML", headers: { "Content-Type": "text/html; charset=utf-8" } }, { label: "wrong-MIME", headers: { "Content-Type": "application/json" } }, { label: "cookie-varying", headers: { Vary: "Accept-Encoding, Cookie" } }, diff --git a/tests/ui-pwa.spec.ts b/tests/ui-pwa.spec.ts index 17e70dbf6..c99e69e84 100644 --- a/tests/ui-pwa.spec.ts +++ b/tests/ui-pwa.spec.ts @@ -201,6 +201,11 @@ test.describe("Clinical KB PWA", () => { expect(offlineDocumentResponse.headers()["x-robots-tag"]).toBe("noindex, nofollow"); expect(offlineDocumentResponse.headers()["set-cookie"]).toBeUndefined(); + const svgIconResponse = await context.request.get(new URL("/icon.svg", page.url()).toString()); + expect(svgIconResponse.status()).toBe(200); + expect(svgIconResponse.headers()["content-type"]).toMatch(/^image\/svg\+xml\b/i); + expect(svgIconResponse.headers()["set-cookie"]).toBeUndefined(); + const workerState = await page.evaluate(async () => { const registration = await navigator.serviceWorker.getRegistration("/"); return { @@ -259,6 +264,22 @@ test.describe("Clinical KB PWA", () => { await expect(page.locator("#main-content")).toBeVisible(); await expect(page.getByRole("heading", { name: "Clinical KB is offline" })).toHaveCount(0); + const privateProbePath = `/api/pwa-private-probe-${Date.now()}`; + await context.route(`**${privateProbePath}`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "Cache-Control": "private, no-store" }, + body: JSON.stringify({ private: true }), + }), + ); + const privateProbe = await page.evaluate(async (path) => { + const response = await fetch(path, { credentials: "include" }); + return { status: response.status, body: await response.json() }; + }, privateProbePath); + expect(privateProbe).toEqual({ status: 200, body: { private: true } }); + const privateProbeUrl = new URL(privateProbePath, page.url()).href; + const inventory = await page.evaluate(async () => { const entries: Array<{ cacheName: string; url: string }> = []; for (const cacheName of await caches.keys()) { @@ -269,6 +290,7 @@ test.describe("Clinical KB PWA", () => { }); expect(inventory.length).toBeGreaterThan(0); + expect(inventory.map((entry) => entry.url)).not.toContain(privateProbeUrl); const origin = new URL(page.url()).origin; const sensitivePath = /\/(?:api|auth|login|account|documents?|search|answers?|uploads?)(?:\/|$)/i; for (const entry of inventory) { From 50dc7bce5b1bcfe93f21c2cd212f681597cb1033 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:41:48 +0800 Subject: [PATCH 5/5] test(ui): wait for header collapse geometry --- tests/ui-smoke.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 31cb5931d..b4c3948a8 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3314,6 +3314,17 @@ test.describe("Clinical KB UI smoke coverage", () => { }, visibleGeometry.maxOffset); await expect(collapseHost).toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => collapseHost.getAttribute("data-scroll-hidden"), { timeout: 1_000 }).toBe("true"); + // The hidden attribute flips before the 240ms grid-row transition has + // released the header's layout space. Wait for rendered geometry so this + // assertion cannot race the animation on faster or slower CI runners. + await expect + .poll(async () => + header.evaluate((node) => { + const rect = node.getBoundingClientRect(); + return Math.max(0, rect.bottom) - Math.max(0, rect.top); + }), + ) + .toBe(0); const collapsedGeometry = await main.evaluate((node) => ({ clientHeight: node.clientHeight, maxOffset: node.scrollHeight - node.clientHeight,