- 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>
94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
import { buildCorsAllowlist, isOriginAllowed } from './cors.util';
|
|
|
|
const WEBVIEW_ORIGINS = [
|
|
'capacitor://localhost',
|
|
'ionic://localhost',
|
|
'https://localhost',
|
|
];
|
|
|
|
describe('isOriginAllowed', () => {
|
|
const allowlist = buildCorsAllowlist({
|
|
appUrl: 'https://app.example',
|
|
configuredOrigins: ['https://other.example'],
|
|
});
|
|
|
|
it('allows requests with no Origin header', () => {
|
|
expect(isOriginAllowed(undefined, allowlist)).toBe(true);
|
|
expect(isOriginAllowed('', allowlist)).toBe(true);
|
|
});
|
|
|
|
it('allows an exact allowlisted origin', () => {
|
|
expect(isOriginAllowed('https://app.example', allowlist)).toBe(true);
|
|
expect(isOriginAllowed('https://other.example', allowlist)).toBe(true);
|
|
});
|
|
|
|
it('allows each native WebView origin', () => {
|
|
for (const origin of WEBVIEW_ORIGINS) {
|
|
expect(isOriginAllowed(origin, allowlist)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('rejects a foreign credentialed origin', () => {
|
|
// With credentials:true a foreign credentialed origin must be rejected.
|
|
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);
|
|
});
|
|
|
|
it('rejects a host-case mismatch', () => {
|
|
expect(isOriginAllowed('https://APP.example', allowlist)).toBe(false);
|
|
});
|
|
|
|
it('allows no-Origin but rejects cross-origin with an empty allowlist', () => {
|
|
const empty: ReadonlySet<string> = new Set<string>();
|
|
expect(isOriginAllowed(undefined, empty)).toBe(true);
|
|
expect(isOriginAllowed('https://app.example', empty)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildCorsAllowlist', () => {
|
|
it('contains the app URL, each configured origin, and all WebView origins', () => {
|
|
const allowlist = buildCorsAllowlist({
|
|
appUrl: 'https://app.example',
|
|
configuredOrigins: ['https://a.example', 'https://b.example'],
|
|
});
|
|
|
|
expect(allowlist.has('https://app.example')).toBe(true);
|
|
expect(allowlist.has('https://a.example')).toBe(true);
|
|
expect(allowlist.has('https://b.example')).toBe(true);
|
|
for (const origin of WEBVIEW_ORIGINS) {
|
|
expect(allowlist.has(origin)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('deduplicates when a configured origin coincides with the app URL', () => {
|
|
const allowlist = buildCorsAllowlist({
|
|
appUrl: 'https://app.example',
|
|
configuredOrigins: ['https://app.example'],
|
|
});
|
|
|
|
// app URL + WebView origins, the duplicate configured origin collapses.
|
|
expect(allowlist.size).toBe(1 + WEBVIEW_ORIGINS.length);
|
|
});
|
|
|
|
it('always includes every WebView origin even with no configured origins', () => {
|
|
const allowlist = buildCorsAllowlist({
|
|
appUrl: 'https://app.example',
|
|
configuredOrigins: [],
|
|
});
|
|
|
|
for (const origin of WEBVIEW_ORIGINS) {
|
|
expect(allowlist.has(origin)).toBe(true);
|
|
}
|
|
});
|
|
});
|