Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ CI and staging must not upload authenticated browser captures. Logging must not

## PWA and offline behavior

The service worker and manifest provide installable web assets and an offline app shell. It precaches only the navigation shell and static identity assets; route chunks enter the runtime cache when a user visits them. On first registration, the client also reports same-origin `/assets/` resources that loaded before the worker took control so the first visited route works offline without a forced reload. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make unvisited routes, authenticated Supabase, or AI operations available without a network.
The service worker ([`public/sw.js`](../public/sw.js)) and manifest provide installable web assets and an offline app. At install it precaches the landing page, `app-shell.html`, the static identity assets, and a list the build writes into the worker (`serviceWorkerPrecache` in [`vite.config.ts`](../vite.config.ts)): the shell's scripts and styles, the signed-in layout, the Home, Library, History, Timer and Settings pages, the Spanish and Hungarian strings, and Inter's Latin fonts. After one online visit those pages open offline and the language can be switched there. Other route chunks enter the runtime cache when a user visits them or hovers their link; on first registration the client also reports same-origin `/assets/` resources that loaded before the worker took control. Offline, app routes (`/app`, `/login`, `/share`, the paths `vercel.json` rewrites to the shell) get the precached shell, and a public page gets only its own HTML, stored under its own path when visited. `/auth/callback`, Supabase paths and `/_vercel/` never touch a cache. Each build's caches carry the build's id and the next build's worker deletes them on activation. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make other unvisited routes, authenticated Supabase, or AI operations available without a network.

Only English strings ship in the entry. [`src/i18n.ts`](../src/i18n.ts) fetches the Spanish or Hungarian catalogs when that language is chosen, and the first render waits for them.

## Deployment boundary

Expand Down
2 changes: 1 addition & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Vercel builds and serves the frontend from the Git repository. A push to `main`
- `trailingSlash: false`, which 308-redirects `/app/settings/` to `/app/settings` before routing, since rewrite and redirect sources match exactly;
- 301 redirects from the retired brand pages (`/debatelab`, `/debatelab-by-programcode`, and the rest) to `/`;
- 301 redirects from retired guide slugs to the guide that replaced them, such as `/debate-weighing-guide` to `/how-to-weigh-in-debate`. The `trailingSlash` 308 runs first, so `/debate-weighing-guide/` reaches the same page in two permanent hops and needs no rule of its own;
- revalidating cache headers (`public, max-age=0, must-revalidate`, without `no-store`, so a document can enter the back/forward cache) for `/`, `index.html`, `app-shell.html`, and `theme-init.js`. `sw.js` stays `no-store` so a new service worker is not stuck in the HTTP cache. Hashed `/assets/` are immutable. The unhashed images in `public/` (`favicon.ico`, `favicon.png`, `favicon-72.png`, `hero-visual.jpg`, `placeholder.svg`) are cached for 30 days. Jam's capture iframe is removed on `pagehide` (never via `unload`) so that cross-origin frame cannot veto the cache;
- revalidating cache headers (`public, max-age=0, must-revalidate`, without `no-store`, so a document can enter the back/forward cache) for `/`, `index.html`, `app-shell.html`, and `theme-init.js`. `sw.js` stays `no-store` so a new service worker is not stuck in the HTTP cache. Hashed `/assets/` are immutable. The unhashed images in `public/` (the favicons, `apple-touch-icon.png`, the `icon-*.png` manifest icons, `hero-visual.jpg`, `placeholder.svg`) are cached for 30 days; the test reads `public/`, so a new icon file must be added to that rule. Jam's capture iframe is removed on `pagehide` (never via `unload`) so that cross-origin frame cannot veto the cache;
- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`. The app shell's robots meta is `noindex, nofollow`, the same policy. Public pages, including the prerendered `/expocit/sources`, are `index, follow`. A route deletes the platform `Access-Control-Allow-Origin: *` header on HTML responses and leaves it on static assets;
- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Jam is limited to `https://js.jam.dev` and `https://recorder.jam.dev`; `script-src` does not include `unsafe-eval`. Web Analytics and Speed Insights stay on this origin (`/_vercel/insights` and `/_vercel/speed-insights`), which `'self'` already allows. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header.

Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,15 @@ This sends one tiny typed choice to OpenRouter's Decisions endpoint using the pi
npm run build
```

This proves the Vite production bundle compiles, prerenders the public pages, checks the built site's links, fragments, JSON-LD and basic accessibility with `scripts/check-site.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment.
This proves the Vite production bundle compiles, prerenders the public pages, checks the built site's links, fragments, JSON-LD and basic accessibility with `scripts/check-site.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules, the Spanish and Hungarian catalogs, and the signed-in layout from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment.

## Production service-worker smoke

```bash
npm run test:sw
```

Run this after `npm run build`. It serves `dist/` through Vite preview and proves that a v2 worker cache upgrades to v3 and claims the page, a first-visited route and its display logo reload offline, an unvisited route shows recovery UI, an intent-prefetched route opens offline, and private API paths do not enter Cache Storage. It uses a stub session and blocks external requests, so it is not authenticated backend or deployed-host proof.
Run this after `npm run build`. It serves `dist/` through Vite preview (which applies the `vercel.json` rewrites, so app routes get `app-shell.html` as in production) and proves that a v2 worker cache upgrades to this build's caches and claims the page; that after one visit to `/app`, Home, Library, History, Settings and Timer open offline as fresh navigations, the timer runs, the offline indicator shows, and switching to Spanish works; that a visited public page answers offline only with itself and an unvisited one gets the offline page; that an unvisited route shows recovery UI and an intent-prefetched one opens; and that private API paths and `/auth/callback` do not enter Cache Storage. It uses a stub session and blocks external requests, so it is not authenticated backend or deployed-host proof.

## Credential-free browser gate

Expand Down
129 changes: 93 additions & 36 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
const CACHE_VERSION = 'debate-lab-v5';
// Filled in by the build (serviceWorkerPrecache in vite.config.ts): an id for
// this build's caches, and the hashed scripts, styles and fonts that open the
// app shell and its main pages offline and switch language there. Empty in
// development, where the worker is never registered.
const BUILD = { id: 'dev', assets: [] };

const CACHE_PREFIX = 'debate-lab-v6';
const CACHE_VERSION = `${CACHE_PREFIX}-${BUILD.id}`;
const APP_SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const PAGES_CACHE = `${CACHE_VERSION}-pages`;

// Unhashed files, fetched past the HTTP cache on every install. The two HTML
// documents are required; an install that cannot store them fails and retries.
const REQUIRED_DOCUMENTS = ['/', '/app-shell.html'];
const APP_SHELL = [
'/',
'/index.html',
'/theme-init.js',
'/manifest.webmanifest',
'/favicon.ico',
Expand All @@ -16,49 +25,69 @@ const APP_SHELL = [
'/icon-maskable-512.png',
];

const API_PATH_PREFIXES = [
// vercel.json serves app-shell.html at these paths, so offline the worker does too.
// (/expocit/sources is rewritten too, but its prerendered file is served first,
// so it is a public page here.)
const APP_SHELL_PATHS = [/^\/app(?:\/|$)/, /^\/login\/?$/, /^\/share\//];

const NETWORK_ONLY_PREFIXES = [
// Supabase, also when a preview proxies it on this origin.
'/auth/v1',
'/rest/v1',
'/functions/v1',
'/storage/v1',
'/__staging-supabase/',
// The sign-in callback exchanges a one-time code; it must always reach the server.
'/auth/callback',
// Vercel Analytics and Speed Insights update their own scripts.
'/_vercel/',
];

function htmlMislabeledAsAsset(response, request) {
const type = (response.headers.get('content-type') || '').toLowerCase();
if (!type.includes('text/html')) return false;
const path = new URL(request.url).pathname;
return request.destination === 'script'
|| request.destination === 'style'
|| path.startsWith('/assets/');
}

async function store(cache, url, init) {
const request = new Request(url, init);
const response = await fetch(request);
if (!response.ok || response.redirected || htmlMislabeledAsAsset(response, request)) {
throw new Error(`Could not precache ${url}`);
}
await cache.put(url, response);
}

self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(APP_SHELL_CACHE)
.then((cache) => Promise.allSettled(
APP_SHELL.map(async (url) => {
const response = await fetch(url, { cache: 'reload' });
if (response.ok) await cache.put(url, response);
}),
))
.catch(() => undefined),
);
event.waitUntil((async () => {
const cache = await caches.open(APP_SHELL_CACHE);
// Hashed assets never change, so the HTTP cache may answer them; the page
// that registered this worker has usually just downloaded most of them.
await Promise.all([
...REQUIRED_DOCUMENTS.map((url) => store(cache, url, { cache: 'reload' })),
...BUILD.assets.map((url) => store(cache, url)),
]);
await Promise.allSettled(APP_SHELL.map((url) => store(cache, url, { cache: 'reload' })));
})());
});

self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(
// Earlier builds' caches: their pages reference assets this build no longer has.
keys
.filter((key) => !key.startsWith(CACHE_VERSION))
.filter((key) => !key.startsWith(`${CACHE_VERSION}-`))
.map((key) => caches.delete(key)),
))
.then(() => self.clients.claim()),
);
});

function htmlMislabeledAsAsset(response, request) {
const type = (response.headers.get('content-type') || '').toLowerCase();
if (!type.includes('text/html')) return false;
const path = new URL(request.url).pathname;
return request.destination === 'script'
|| request.destination === 'style'
|| path.startsWith('/assets/');
}

async function cacheFirst(request) {
const cached = await caches.match(request, { ignoreVary: true });
if (cached && !htmlMislabeledAsAsset(cached, request)) return cached;
Expand All @@ -78,19 +107,43 @@ async function cacheFirst(request) {
}
}

async function navigationFallback(request) {
function offlinePage() {
return new Response('<!doctype html><title>Offline</title><main style="font-family:system-ui;padding:2rem"><h1>Offline</h1><p>DebateLab is unavailable until your connection returns.</p></main>', {
status: 503,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}

/** App routes: the network, or offline the shell this build precached. */
async function appShellNavigation(request) {
try {
return await fetch(request);
} catch {
return (await caches.match('/app-shell.html', { cacheName: APP_SHELL_CACHE })) || offlinePage();
}
}

/**
* Public pages are prerendered, each with its own HTML. Each is stored under
* its own path, so offline a page is only ever answered with itself.
*/
async function pageNavigation(event) {
const url = new URL(event.request.url);
const key = `${url.origin}${url.pathname}`;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(APP_SHELL_CACHE);
cache.put('/index.html', response.clone());
const response = await fetch(event.request);
// A redirected response cannot answer a navigation later.
if (response.ok && !response.redirected) {
// Copied now: the page starts reading the original as soon as it is returned.
const copy = response.clone();
event.waitUntil(caches.open(PAGES_CACHE).then((cache) => cache.put(key, copy)));
}
return response;
} catch {
return (await caches.match('/index.html')) ||
new Response('<!doctype html><title>Offline</title><main style="font-family:system-ui;padding:2rem"><h1>Offline</h1><p>DebateLab is unavailable until your connection returns.</p></main>', {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
// The copy saved on the last visit, before the one stored at install.
return (await caches.match(key, { cacheName: PAGES_CACHE, ignoreVary: true }))
|| (await caches.match(key, { ignoreVary: true }))
|| offlinePage();
}
}

Expand All @@ -115,13 +168,17 @@ self.addEventListener('fetch', (event) => {

const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (API_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
if (NETWORK_ONLY_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
// Embeds are framed on other sites: never store one as the app shell, and
// never answer one with the cached shell, which refuses to be framed.
if (url.pathname.startsWith('/embed/')) return;

if (request.mode === 'navigate') {
event.respondWith(navigationFallback(request));
if (APP_SHELL_PATHS.some((pattern) => pattern.test(url.pathname))) {
event.respondWith(appShellNavigation(request));
} else {
event.respondWith(pageNavigation(event));
}
return;
}

Expand Down
4 changes: 3 additions & 1 deletion scripts/check-build-boundaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ function staticGraphFrom(file) {
return graph;
}

const forbiddenFeature = /(?:\/src\/pages\/AdminPage\.tsx$|\/src\/lib\/(?:pdf-export-utils|export-utils)\.ts$|\/node_modules\/(?:jspdf|jspdf-autotable|docx|recharts|reactflow)\/)/i;
// Also kept out: the Spanish and Hungarian catalogs (loaded only when chosen)
// and the signed-in layout (loaded only under /app).
const forbiddenFeature = /(?:\/src\/pages\/AdminPage\.tsx$|\/src\/lib\/(?:pdf-export-utils|export-utils)\.ts$|\/node_modules\/(?:jspdf|jspdf-autotable|docx|recharts|reactflow)\/|\/src\/locales\/(?:tools\/)?(?:es|hu)\.json$|\/src\/components\/layout\/AppLayout\.tsx$)/i;
const initialRoutes = [
{ key: entryKey, pageModules: new Set() },
...[
Expand Down
Loading
Loading