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:
@@ -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();
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user