diff --git a/apps/client/src/features/offline/clear-offline-cache.ts b/apps/client/src/features/offline/clear-offline-cache.ts index 990632c8..d5f3667f 100644 --- a/apps/client/src/features/offline/clear-offline-cache.ts +++ b/apps/client/src/features/offline/clear-offline-cache.ts @@ -13,8 +13,10 @@ import { OFFLINE_CACHE_KEY } from "./query-persister"; * `OFFLINE_CACHE_KEY`), * 2. the Yjs page documents (IndexedDB databases named `page.` created by * y-indexeddb in make-offline.ts), and - * 3. the service worker `api-get-cache` Cache Storage entry (private GET /api - * responses cached by the Workbox runtime). + * 3. any legacy service worker `api-get-cache` Cache Storage entry. The + * Workbox runtime no longer creates this cache (the GET /api NetworkFirst + * rule was removed — offline reads come from the persisted RQ cache), so + * this is now a defensive cleanup for caches left by older app versions. * * Fully best-effort: every step is isolated so a single failure neither blocks * the remaining steps nor throws to the caller (logout must never be blocked on @@ -72,9 +74,9 @@ export async function clearOfflineCache(): Promise { // best-effort: ignore enumeration/deletion failures } - // 3. Clear the service worker API cache (private GET /api responses). The - // Workbox runtime cache name contains "api-get-cache" (Workbox may prefix it), - // so match by substring rather than exact name. + // 3. Clear any legacy service worker API cache. Current builds no longer + // create it, but an older client may have left an "api-get-cache" entry + // (Workbox may prefix the name), so match by substring rather than exact name. try { if ("caches" in window) { const keys = await caches.keys(); diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index 9cb92264..b9a9ee80 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -67,7 +67,21 @@ export default defineConfig(({ mode }) => { // Segment-anchored (`^/(/|$)`) so navigation requests to these // segments are consistently excluded from the SPA fallback, mirroring // the runtimeCaching urlPattern regexes below. - navigateFallbackDenylist: [/^\/api(\/|$)/, /^\/collab(\/|$)/, /^\/socket\.io(\/|$)/], + // + // `/share`, `/mcp`, and `/robots.txt` mirror the server static-serve + // exclude list (apps/server/src/main.ts setGlobalPrefix `exclude`): + // robots.txt, the SEO/OG/analytics-injected public share HTML, and the + // embedded MCP endpoint are served by server controllers, so the SW must + // never shadow them with the precached index.html app shell (doing so + // would break SEO and MCP). + navigateFallbackDenylist: [ + /^\/api(\/|$)/, + /^\/collab(\/|$)/, + /^\/socket\.io(\/|$)/, + /^\/share(\/|$)/, + /^\/mcp(\/|$)/, + /^\/robots\.txt$/, + ], cleanupOutdatedCaches: true, clientsClaim: true, // The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts @@ -77,19 +91,8 @@ export default defineConfig(({ mode }) => { // self-contained inline regex literals anchored to a path segment boundary. runtimeCaching: [ { urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" }, - // M2 read-path: GET navigation API responses fall back to cache when offline. - // Only GET is cached; mutations always hit the network (Workbox caching handlers - // only match GET by default, but scope explicitly for clarity/safety). - { - urlPattern: ({ url, request }) => /^\/api(\/|$)/.test(url.pathname) && request.method === "GET", - handler: "NetworkFirst", - options: { - cacheName: "api-get-cache", - networkTimeoutSeconds: 5, - expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 }, - }, - }, - // Any non-GET /api stays network-only (never served stale). + // All /api stays network-only; offline reads come from the persisted + // React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache. { urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" }, ], }, diff --git a/apps/server/src/integrations/environment/cors.util.spec.ts b/apps/server/src/integrations/environment/cors.util.spec.ts index 65d59e92..527eebee 100644 --- a/apps/server/src/integrations/environment/cors.util.spec.ts +++ b/apps/server/src/integrations/environment/cors.util.spec.ts @@ -3,7 +3,6 @@ import { buildCorsAllowlist, isOriginAllowed } from './cors.util'; const WEBVIEW_ORIGINS = [ 'capacitor://localhost', 'ionic://localhost', - 'http://localhost', 'https://localhost', ]; @@ -34,6 +33,13 @@ describe('isOriginAllowed', () => { expect(isOriginAllowed('https://evil.example', allowlist)).toBe(false); }); + it('rejects the cleartext http://localhost origin', () => { + // The native shell uses the secure scheme (https://localhost) on Android and + // the capacitor:// custom scheme on iOS, so cleartext http://localhost must + // not be trusted. + expect(isOriginAllowed('http://localhost', allowlist)).toBe(false); + }); + it('rejects a trailing-slash mismatch', () => { expect(isOriginAllowed('https://app.example/', allowlist)).toBe(false); }); @@ -70,11 +76,11 @@ describe('buildCorsAllowlist', () => { configuredOrigins: ['https://app.example'], }); - // app URL + 4 WebView origins, the duplicate configured origin collapses. + // app URL + WebView origins, the duplicate configured origin collapses. expect(allowlist.size).toBe(1 + WEBVIEW_ORIGINS.length); }); - it('always includes the four WebView origins even with no configured origins', () => { + it('always includes every WebView origin even with no configured origins', () => { const allowlist = buildCorsAllowlist({ appUrl: 'https://app.example', configuredOrigins: [], diff --git a/apps/server/src/integrations/environment/cors.util.ts b/apps/server/src/integrations/environment/cors.util.ts index c3ce00ef..f42f6c1d 100644 --- a/apps/server/src/integrations/environment/cors.util.ts +++ b/apps/server/src/integrations/environment/cors.util.ts @@ -5,12 +5,23 @@ // allowlist (apart from no-Origin requests) is rejected. // Native WebView origins used by the Capacitor/Ionic mobile shell. Always -// trusted so the native client can call the API. CORS hardening of these is -// intentionally out of scope. +// trusted so the native client can call the API. +// +// - `capacitor://localhost` — iOS native custom scheme. +// - `ionic://localhost` — legacy native custom scheme. +// - `https://localhost` — Android default secure scheme. +// +// The cleartext `http://localhost` origin is intentionally NOT trusted: the +// Capacitor shell uses the secure scheme (capacitor.config.ts sets +// `cleartext: false` and does not override `androidScheme`, so Capacitor's +// default Android scheme is `https` => origin `https://localhost`), and iOS runs +// in hosted mode (`server.url` = CAP_SERVER_URL, whose origin is the app URL +// already in the allowlist). No native client legitimately uses +// `http://localhost`, so allowing it would only widen the credentialed-CORS +// surface to arbitrary local http content. const NATIVE_WEBVIEW_ORIGINS = [ 'capacitor://localhost', 'ionic://localhost', - 'http://localhost', 'https://localhost', ] as const; diff --git a/docs/mobile-bootstrap.md b/docs/mobile-bootstrap.md index 55a7cfa6..d5869646 100644 --- a/docs/mobile-bootstrap.md +++ b/docs/mobile-bootstrap.md @@ -6,9 +6,22 @@ mobile app for Gitmost, per the first-step checklist in ## What is in the repo now -- **PWA**: web app manifest, a hand-rolled service worker, and production-only - service worker registration in the client. This lets the existing responsive - web UI be installed and run as a Progressive Web App. +- **PWA**: web app manifest plus a service worker generated by `vite-plugin-pwa` + using Workbox (`strategies: "generateSW"` — not hand-rolled). The SW is built + for production only (`devOptions: { enabled: false }`) and uses + `registerType: "prompt"`, so the user is asked to apply an update rather than it + auto-updating; registration goes through `virtual:pwa-register/react` + (`useRegisterSW`) in `apps/client/src/pwa/pwa-update-prompt.tsx`, mounted from + `main.tsx` and skipped inside the Capacitor native WebView. The SW precaches the + app shell (`globPatterns` js/css/html/...) and serves `navigateFallback: + "index.html"` for SPA routes, with `navigateFallbackDenylist` excluding the + server-owned routes `/api`, `/collab`, `/socket.io`, `/share/`, `/mcp`, and + `/robots.txt`. `runtimeCaching` keeps `/collab`, `/socket.io`, and all `/api` + as `NetworkOnly` — offline reads are served by the persisted TanStack Query + cache (IndexedDB) and `y-indexeddb` for the page Yjs doc, not by an SW HTTP + cache. This lets the existing responsive web UI be installed and run as a + Progressive Web App. See [docs/offline-sync-plan.md](./offline-sync-plan.md) for + the full offline/sync design. - **Backend mobile auth**: opt-in token return from the login flow. The login request accepts a `returnToken` flag (must be sent as a JSON boolean) that makes the server include the auth token in the response body, and the server already @@ -18,8 +31,8 @@ mobile app for Gitmost, per the first-step checklist in token (Keychain / Keystore) and send it as `Authorization: Bearer` on each request. - **Explicit CORS allowlist**: the server reads a `CORS_ALLOWED_ORIGINS` env variable for the allowed origins, and always allows the native WebView origins - (`capacitor://localhost`, `ionic://localhost`, `http://localhost`, - `https://localhost`) so the mobile shell can call the API. + (`capacitor://localhost`, `ionic://localhost`, `https://localhost`) so the + mobile shell can call the API. - **Optional OpenAPI / Swagger**: an opt-in OpenAPI/Swagger surface gated behind the `SWAGGER_ENABLED` env flag, useful for developing the native client. - **Capacitor config**: [capacitor.config.ts](../capacitor.config.ts) at the