chore(offline-sync): tighten SW denylist, drop dead /api cache + http localhost CORS

- Service worker (vite-plugin-pwa/Workbox): add /share/, /mcp, and /robots.txt
  to navigateFallbackDenylist so the SPA app-shell never shadows those
  server-rendered routes (they mirror the server static-serve exclude list — the
  share SEO/OG HTML, the MCP endpoint, and robots.txt must come from the server).
- Remove the dead /api GET NetworkFirst Workbox rule (api-get-cache): offline
  reads are served by the persisted TanStack Query cache (IndexedDB) + y-indexeddb,
  never by an SW HTTP cache, so caching GET /api only risked stale responses. All
  /api is now NetworkOnly. clearOfflineCache still deletes any legacy api-get-cache
  defensively (comment updated to note it is no longer created).
- CORS: drop the cleartext 'http://localhost' native-WebView origin. The Capacitor
  shell uses the secure scheme (capacitor.config cleartext:false, default Android
  scheme https, iOS hosted via CAP_SERVER_URL), so no native client uses it;
  allowing it only widened the credentialed-CORS surface. Keeps capacitor://,
  ionic://, and https://localhost.
- docs/mobile-bootstrap.md: replace the inaccurate 'hand-rolled service worker'
  description with the real Workbox generateSW setup (prompt registration via
  virtual:pwa-register, production-only, denylist, NetworkOnly, RQ/y-indexeddb
  offline reads) and drop http://localhost from the CORS origins list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-22 02:44:44 +03:00
parent a4b4480118
commit 1a53106efe
5 changed files with 65 additions and 30 deletions

View File

@@ -13,8 +13,10 @@ import { OFFLINE_CACHE_KEY } from "./query-persister";
* `OFFLINE_CACHE_KEY`), * `OFFLINE_CACHE_KEY`),
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by * 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
* y-indexeddb in make-offline.ts), and * y-indexeddb in make-offline.ts), and
* 3. the service worker `api-get-cache` Cache Storage entry (private GET /api * 3. any legacy service worker `api-get-cache` Cache Storage entry. The
* responses cached by the Workbox runtime). * 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 * 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 * the remaining steps nor throws to the caller (logout must never be blocked on
@@ -72,9 +74,9 @@ export async function clearOfflineCache(): Promise<void> {
// best-effort: ignore enumeration/deletion failures // best-effort: ignore enumeration/deletion failures
} }
// 3. Clear the service worker API cache (private GET /api responses). The // 3. Clear any legacy service worker API cache. Current builds no longer
// Workbox runtime cache name contains "api-get-cache" (Workbox may prefix it), // create it, but an older client may have left an "api-get-cache" entry
// so match by substring rather than exact name. // (Workbox may prefix the name), so match by substring rather than exact name.
try { try {
if ("caches" in window) { if ("caches" in window) {
const keys = await caches.keys(); const keys = await caches.keys();

View File

@@ -67,7 +67,21 @@ export default defineConfig(({ mode }) => {
// Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these // Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these
// segments are consistently excluded from the SPA fallback, mirroring // segments are consistently excluded from the SPA fallback, mirroring
// the runtimeCaching urlPattern regexes below. // 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, cleanupOutdatedCaches: true,
clientsClaim: true, clientsClaim: true,
// The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts // 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. // self-contained inline regex literals anchored to a path segment boundary.
runtimeCaching: [ runtimeCaching: [
{ urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" }, { urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
// M2 read-path: GET navigation API responses fall back to cache when offline. // All /api stays network-only; offline reads come from the persisted
// Only GET is cached; mutations always hit the network (Workbox caching handlers // React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache.
// 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).
{ urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" }, { urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
], ],
}, },

View File

@@ -3,7 +3,6 @@ import { buildCorsAllowlist, isOriginAllowed } from './cors.util';
const WEBVIEW_ORIGINS = [ const WEBVIEW_ORIGINS = [
'capacitor://localhost', 'capacitor://localhost',
'ionic://localhost', 'ionic://localhost',
'http://localhost',
'https://localhost', 'https://localhost',
]; ];
@@ -34,6 +33,13 @@ describe('isOriginAllowed', () => {
expect(isOriginAllowed('https://evil.example', allowlist)).toBe(false); 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', () => { it('rejects a trailing-slash mismatch', () => {
expect(isOriginAllowed('https://app.example/', allowlist)).toBe(false); expect(isOriginAllowed('https://app.example/', allowlist)).toBe(false);
}); });
@@ -70,11 +76,11 @@ describe('buildCorsAllowlist', () => {
configuredOrigins: ['https://app.example'], 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); 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({ const allowlist = buildCorsAllowlist({
appUrl: 'https://app.example', appUrl: 'https://app.example',
configuredOrigins: [], configuredOrigins: [],

View File

@@ -5,12 +5,23 @@
// allowlist (apart from no-Origin requests) is rejected. // allowlist (apart from no-Origin requests) is rejected.
// Native WebView origins used by the Capacitor/Ionic mobile shell. Always // 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 // trusted so the native client can call the API.
// intentionally out of scope. //
// - `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 = [ const NATIVE_WEBVIEW_ORIGINS = [
'capacitor://localhost', 'capacitor://localhost',
'ionic://localhost', 'ionic://localhost',
'http://localhost',
'https://localhost', 'https://localhost',
] as const; ] as const;

View File

@@ -6,9 +6,22 @@ mobile app for Gitmost, per the first-step checklist in
## What is in the repo now ## What is in the repo now
- **PWA**: web app manifest, a hand-rolled service worker, and production-only - **PWA**: web app manifest plus a service worker generated by `vite-plugin-pwa`
service worker registration in the client. This lets the existing responsive using Workbox (`strategies: "generateSW"` — not hand-rolled). The SW is built
web UI be installed and run as a Progressive Web App. 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 - **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 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 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. token (Keychain / Keystore) and send it as `Authorization: Bearer` on each request.
- **Explicit CORS allowlist**: the server reads a `CORS_ALLOWED_ORIGINS` env - **Explicit CORS allowlist**: the server reads a `CORS_ALLOWED_ORIGINS` env
variable for the allowed origins, and always allows the native WebView origins variable for the allowed origins, and always allows the native WebView origins
(`capacitor://localhost`, `ionic://localhost`, `http://localhost`, (`capacitor://localhost`, `ionic://localhost`, `https://localhost`) so the
`https://localhost`) so the mobile shell can call the API. mobile shell can call the API.
- **Optional OpenAPI / Swagger**: an opt-in OpenAPI/Swagger surface gated behind - **Optional OpenAPI / Swagger**: an opt-in OpenAPI/Swagger surface gated behind
the `SWAGGER_ENABLED` env flag, useful for developing the native client. the `SWAGGER_ENABLED` env flag, useful for developing the native client.
- **Capacitor config**: [capacitor.config.ts](../capacitor.config.ts) at the - **Capacitor config**: [capacitor.config.ts](../capacitor.config.ts) at the