diff --git a/docs/operations.md b/docs/operations.md index ae0c82359..f6a834c45 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -42,15 +42,15 @@ Vercel builds and serves the frontend from the Git repository. A push to `main` - automatic Git deployments: `main` only. `git.deploymentEnabled` sets `main` to true and `**` to false. A branch deploys when any matching rule is true, and a branch that matches nothing defaults to true, so the `**` rule has to stay. `*` does not match a slash, which would leave branches such as `cursor/…` deploying. A push that matches no true rule creates no deployment and does not count toward the 100-deployments-per-day cap; - the Vite build command and `dist` output; -- rewrites of the app routes (`/app`, `/login`, `/share`, `/expocit/sources`) to `app-shell.html`; any other path that is not a file gets `404.html` with a real 404 status; +- rewrites of the app routes (`/app`, `/login`, `/share`, `/expocit/sources`) to `app-shell.html`; any other path that is not a file gets `404.html` with a real 404 status. `/expocit/sources` is also prerendered, and the filesystem is served before rewrites, so that file is what visitors and crawlers get. The rewrite remains for a build that did not emit it; - `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; -- no-cache headers for `/`, `index.html`, `app-shell.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`; -- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`; -- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. 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. +- 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.png`, `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; +- `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. -After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization, SoftwareApplication (with the trial and Pro offers) and FAQPage JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404. +After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, `/expocit/sources`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, `dist/expocit/sources/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization, SoftwareApplication (with the trial and Pro offers) and FAQPage JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404. The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are prerendered the same way, listed in [`src/prerender/tool-pages.tsx`](../src/prerender/tool-pages.tsx) with heads in [`src/lib/seo/tool-pages.ts`](../src/lib/seo/tool-pages.ts). They render their own JSON-LD in the page body, so the dev server and the prerendered HTML carry the same structured data, and their `dateModified` is `TOOL_PAGES_MODIFIED` in that file: bump it with a substantive content change. Their speech times come from [`src/lib/free-tools/formats.ts`](../src/lib/free-tools/formats.ts), which cites a league source per format. @@ -58,7 +58,7 @@ The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are pr [`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. Each `` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, the tool pages' `TOOL_PAGES_MODIFIED`, and the landing page's last source commit. A page without a date is listed without ``. -[`scripts/check-site.mjs`](../scripts/check-site.mjs) runs next and fails the build when an internal link, a `#fragment`, a sitemap URL or an `llms.txt` URL does not resolve (it applies the `vercel.json` redirects and rewrites), when JSON-LD does not parse or carries HTML entities, when an indexed page other than `/terms` and `/privacy` lacks its question H2 (`#short-answer`) as the first H2, or when an image has no alt text, a button has no name or a form field has no label. +[`scripts/check-site.mjs`](../scripts/check-site.mjs) runs next and fails the build when an internal link, a `#fragment`, a sitemap URL or an `llms.txt` URL does not resolve (it applies the `vercel.json` redirects and rewrites), when JSON-LD does not parse or carries HTML entities, when an indexed page other than `/terms`, `/privacy`, and `/expocit/sources` lacks its question H2 (`#short-answer`) as the first H2, or when an image has no alt text, a button has no name or a form field has no label. The guide pages in `public//index.html` are generated, not hand-edited: run `python3 scripts/generate_seo_content.py` and commit its output. Short pages live in its `PAGES` list; pages with long-form copy, their own highlight-card descriptions, and their own FAQs (which also feed the page's FAQPage JSON-LD) live in [`scripts/seo_guides.py`](../scripts/seo_guides.py). Keep `PAGES` in order, because each page's related-guide links are picked by position. A guide entry can set its own `modified` date, which overrides `CONTENT_DATE` for that page alone. @@ -106,7 +106,7 @@ Admins see a **Developer** card in Settings that signs out, sets the key, and re A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so a requested preview is pinned to staging; see [Opt-in previews](#opt-in-previews). -The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. It lists exactly the two project origins from [`src/lib/supabase/projects.ts`](../src/lib/supabase/projects.ts), and `src/test/vercel-deployment-config.test.ts` fails if they drift apart. +The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. It lists exactly the two project origins from [`src/lib/supabase/projects.ts`](../src/lib/supabase/projects.ts), and `src/test/vercel-deployment-config.test.ts` fails if they drift apart. The same file is deployed to production and to `debatelab-staging`, and an unpinned production preview can still select staging, so the staging origin stays in the production site's CSP. ## Opt-in previews diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 8a13a2a83..2c6f1ce6d 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -4,15 +4,49 @@ import { setLocale, stubSupabaseSession } from './helpers/auth'; test('a stalled Jam recorder cannot delay the real application', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.'); await page.route('https://js.jam.dev/**', async () => new Promise(() => undefined)); - const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + const jamRequests: string[] = []; + page.on('request', (request) => { + if (request.url().startsWith('https://js.jam.dev/')) jamRequests.push(request.url()); + }); await page.goto('/', { waitUntil: 'domcontentloaded' }); - await jamRequest; + // Jam waits for an interaction, so the landing page is up before any recorder request. await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible({ timeout: 2_000 }); + expect(jamRequests).toEqual([]); + + const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + await jamRequest; + await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible(); await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); }); +test('pagehide removes the Jam frame and a persisted pageshow does not re-add it (synthetic events, stubbed Jam)', async ({ page, browserName }) => { + test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.'); + await page.route('https://js.jam.dev/**', (route) => route.fulfill({ status: 200, contentType: 'text/javascript', body: '' })); + + await page.goto('/', { waitUntil: 'load' }); + const heading = page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ }); + await expect(heading).toBeVisible(); + const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + await jamRequest; + + // Stand-in for the capture frame Jam injects; the real one is cross-origin. + const frames = await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.dataset.jamCapture = 'true'; + document.body.append(frame); + window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })); + const afterHide = document.querySelectorAll('iframe[data-jam-capture]').length; + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + return { afterHide, afterShow: document.querySelectorAll('iframe[data-jam-capture]').length }; + }); + expect(frames).toEqual({ afterHide: 0, afterShow: 0 }); + await expect(heading).toBeVisible(); +}); + test('public routes keep the real UI after refresh', async ({ page }) => { await setLocale(page, 'en'); for (const [path, label] of [ diff --git a/index.html b/index.html index 36bf83a47..43dd2e7a9 100644 --- a/index.html +++ b/index.html @@ -12,7 +12,9 @@ - + + diff --git a/public/theme-init.js b/public/theme-init.js index e56ec9bb0..2247c956a 100644 --- a/public/theme-init.js +++ b/public/theme-init.js @@ -20,9 +20,19 @@ try { var pathname = window.location.pathname; + var privateRoute = pathname === '/app' || pathname.indexOf('/app/') === 0 + || pathname === '/login' + || pathname === '/share' || pathname.indexOf('/share/') === 0; if (pathname === '/app' || pathname.indexOf('/app/') === 0 || pathname === '/login') { document.documentElement.classList.add('app-shell'); } + // The shell HTML is noindex. Private routes keep that. The sources page is + // public, including when the app-shell rewrite serves this document. + var robots = document.querySelector('meta[name="robots"]'); + if (robots && privateRoute) robots.setAttribute('content', 'noindex, nofollow'); + else if (robots && pathname === '/expocit/sources') { + robots.setAttribute('content', 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'); + } } catch { // Ignore unavailable location or document globals. } diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs index 1f1258218..5fc4c9a6a 100644 --- a/scripts/check-site.mjs +++ b/scripts/check-site.mjs @@ -13,7 +13,7 @@ const DIST = 'dist'; const SITE_ORIGIN = JSON.parse(readFileSync('src/config/site.json', 'utf8')).origin; const vercel = JSON.parse(readFileSync('vercel.json', 'utf8')); // Indexed pages that answer no question of their own. -const NO_SHORT_ANSWER = new Set(['/terms', '/privacy']); +const NO_SHORT_ANSWER = new Set(['/terms', '/privacy', '/expocit/sources']); const problems = []; const report = (where, message) => problems.push(`${where}: ${message}`); diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 7107b2a73..510ad6f7b 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -74,9 +74,12 @@ function pageHtml(shell, page, assets) { [//, ``, 'twitter:title'], [//, ``, 'twitter:description'], ]) html = replaceOnce(html, pattern, value, label); - if (!head.canonicalPath) { - html = replaceOnce(html, //, '', 'robots'); - } + // The shell document is noindex (app, login, share). A public page puts the + // index directive back; a page without a canonical URL stays noindex. + const robots = head.canonicalPath + ? 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1' + : 'noindex, nofollow'; + html = replaceOnce(html, //, ``, 'robots'); const added = []; if (head.canonicalPath) { diff --git a/scripts/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs index 561f2700b..8912650d7 100644 --- a/scripts/test-service-worker-offline.mjs +++ b/scripts/test-service-worker-offline.mjs @@ -132,7 +132,7 @@ async function main() { headers: { 'Cache-Control': 'no-store' }, body: V2_WORKER, })); - await upgradePage.goto(origin, { waitUntil: 'networkidle' }); + await upgradePage.goto(origin, { waitUntil: 'load' }); await upgradePage.evaluate(async () => navigator.serviceWorker.ready); await upgradePage.waitForFunction(async () => { const registration = await navigator.serviceWorker.getRegistration(); @@ -153,7 +153,7 @@ async function main() { const activatedPage = await context.newPage(); await prepare(activatedPage); - await activatedPage.goto(origin, { waitUntil: 'networkidle' }); + await activatedPage.goto(origin, { waitUntil: 'load' }); await activatedPage.evaluate(async () => navigator.serviceWorker.ready); await activatedPage.waitForFunction(async () => { const keys = await caches.keys(); @@ -167,7 +167,7 @@ async function main() { const page = await context.newPage(); await prepare(page); - await page.goto(`${origin}/app`, { waitUntil: 'networkidle' }); + await page.goto(`${origin}/app`, { waitUntil: 'load' }); await page.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); await page.evaluate(async () => navigator.serviceWorker.ready); await page.waitForFunction(async (paths) => { @@ -192,7 +192,7 @@ async function main() { await context.setOffline(false); const prefetchedPage = await context.newPage(); await prepare(prefetchedPage); - await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'networkidle' }); + await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'load' }); await prefetchedPage.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); const attackLink = prefetchedPage.locator('main [data-tour="mode-attack"]'); await attackLink.hover(); diff --git a/src/jam-loader.ts b/src/jam-loader.ts index 83781ef21..677460842 100644 --- a/src/jam-loader.ts +++ b/src/jam-loader.ts @@ -4,6 +4,27 @@ const JAM_SCRIPT_URLS = [ ] as const; const JAM_SCRIPT_TIMEOUT_MS = 3_000; +/** + * Jam stays off the critical path. The capture iframe at recorder.jam.dev + * keeps a worker request open, so the network never goes idle once the + * scripts run. Load on the first interaction, and otherwise only after the + * page has had this long to paint. Callers must not wait for `networkidle`. + * + * That iframe is cross-origin. Chrome cannot tell whether it is eligible for + * the back/forward cache, and an `unload` listener inside it would block the + * whole page. `pagehide` removes the frame first (it does not block the cache + * the way `unload` and `beforeunload` do). The frame is not put back when the + * page is restored: a re-added frame reloads and starts a new handshake, which + * Jam's already-initialized parent script rejects. So once Jam has loaded, it + * stays off on a page restored from the back/forward cache and returns on the + * next full load. If it had not started before the page was hidden, the + * pending trigger may still start it after the restore; that is a clean first + * load, not a reconnect. + */ +export const JAM_PASSIVE_DELAY_MS = 8_000; + +const JAM_FRAME_SELECTOR = 'iframe[data-jam-capture], iframe[data-jam-recorder], iframe[src*="jam.dev"]'; + interface JamLoaderOptions { documentRef?: Document; mediaRecorderAvailable?: boolean; @@ -49,23 +70,46 @@ export async function loadJamRecorder(options: JamLoaderOptions = {}): Promise appendJamModule(documentRef, source))); } +function detachJamFrames(documentRef: Document): void { + for (const frame of documentRef.querySelectorAll(JAM_FRAME_SELECTOR)) frame.remove(); +} + export function scheduleJamRecorder(options: JamLoaderOptions = {}): void { const documentRef = options.documentRef ?? document; const windowRef = documentRef.defaultView ?? window; - const loadWhenIdle = (): void => { + let started = false; + + // pagehide, not unload: unload listeners make the page ineligible for bfcache. + // Nothing is restored on pageshow; see the note on JAM_PASSIVE_DELAY_MS. + windowRef.addEventListener('pagehide', () => { + detachJamFrames(documentRef); + }, { capture: true }); + + const start = (): void => { + if (started) return; + started = true; + windowRef.removeEventListener('pointerdown', onInteract, true); + windowRef.removeEventListener('keydown', onInteract, true); const load = (): void => { void loadJamRecorder(options).catch((): undefined => undefined); }; if (typeof windowRef.requestIdleCallback === 'function') { - windowRef.requestIdleCallback(load); + windowRef.requestIdleCallback(load, { timeout: 2_000 }); } else { windowRef.setTimeout(load, 0); } }; - if (documentRef.readyState === 'complete') { - loadWhenIdle(); - } else { - windowRef.addEventListener('load', loadWhenIdle, { once: true }); - } + const onInteract = (): void => { + start(); + }; + + const arm = (): void => { + windowRef.addEventListener('pointerdown', onInteract, { capture: true, passive: true }); + windowRef.addEventListener('keydown', onInteract, true); + windowRef.setTimeout(start, JAM_PASSIVE_DELAY_MS); + }; + + if (documentRef.readyState === 'complete') arm(); + else windowRef.addEventListener('load', arm, { once: true }); } diff --git a/src/lib/seo/pages.ts b/src/lib/seo/pages.ts index 2d7799902..3c3aa1e79 100644 --- a/src/lib/seo/pages.ts +++ b/src/lib/seo/pages.ts @@ -44,6 +44,11 @@ export const PAGE_HEADS = { description: `How ${BRAND} collects, uses, stores, and shares your information, how AI requests are processed, and the rights you have over your data.`, canonicalPath: '/privacy', }, + '/expocit/sources': { + title: `Sources | ${BRAND}`, + description: `The sources consulted during the research and development of ${BRAND}, listed alphabetically by author surname or institutional name.`, + canonicalPath: '/expocit/sources', + }, ...TOOL_PAGE_HEADS, '*': { title: `Page not found | ${BRAND}`, @@ -53,7 +58,12 @@ export const PAGE_HEADS = { export type PrerenderedRoute = keyof typeof PAGE_HEADS; -/** What index.html, the shell for every other route, says. */ +/** + * Head the client restores for a public page. index.html itself (the app + * shell copied to app-shell.html) says `noindex, nofollow`, matching the + * X-Robots-Tag on /app, /login and /share. Prerender writes `robots` onto + * every page that has a canonical URL. + */ export const SHELL_HEAD = { title: 'DebateLab', description: PAGE_HEADS['/'].description, diff --git a/src/lib/seo/use-page-head.ts b/src/lib/seo/use-page-head.ts index 7e6b15740..640723f85 100644 --- a/src/lib/seo/use-page-head.ts +++ b/src/lib/seo/use-page-head.ts @@ -33,7 +33,7 @@ export function usePageHead(route: PrerenderedRoute) { const head: { title: string; description: string; canonicalPath?: string } = PAGE_HEADS[route]; document.title = head.title; setMeta('description', head.description); - setMeta('robots', head.canonicalPath ? SHELL_HEAD.robots : 'noindex'); + setMeta('robots', head.canonicalPath ? SHELL_HEAD.robots : 'noindex, nofollow'); setCanonical(head.canonicalPath); return () => { document.title = SHELL_HEAD.title; diff --git a/src/pages/SourcesPage.tsx b/src/pages/SourcesPage.tsx index a2f81fc4b..a03056bff 100644 --- a/src/pages/SourcesPage.tsx +++ b/src/pages/SourcesPage.tsx @@ -1,8 +1,10 @@ import { useTranslation } from 'react-i18next'; import { PublicPageShell } from '@/components/layout/PublicPageShell'; +import { usePageHead } from '@/lib/seo/use-page-head'; export default function SourcesPage() { const { t } = useTranslation(); + usePageHead('/expocit/sources'); const sources = [ { authors: "AITHAL, V.; SILVER, J.", diff --git a/src/prerender/entry.tsx b/src/prerender/entry.tsx index 4df6ec36b..539af94cb 100644 --- a/src/prerender/entry.tsx +++ b/src/prerender/entry.tsx @@ -11,6 +11,7 @@ import LandingPage from '@/pages/LandingPage'; import TermsPage from '@/pages/TermsPage'; import PrivacyPage from '@/pages/PrivacyPage'; import NotFound from '@/pages/NotFound'; +import SourcesPage from '@/pages/SourcesPage'; import { TOOL_PAGES } from './tool-pages'; const unavailable = () => Promise.reject(new Error('Not available while prerendering')); @@ -202,6 +203,13 @@ export async function prerenderPages({ landingModified }: { landingModified?: st head: PAGE_HEADS[route], body: render(route, Page), })), + { + route: '/expocit/sources', + file: 'expocit/sources/index.html', + module: 'src/pages/SourcesPage.tsx', + head: PAGE_HEADS['/expocit/sources'], + body: render('/expocit/sources', SourcesPage), + }, { route: '*', file: '404.html', diff --git a/src/test/jam-loader.test.ts b/src/test/jam-loader.test.ts new file mode 100644 index 000000000..0ede1826a --- /dev/null +++ b/src/test/jam-loader.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { JAM_PASSIVE_DELAY_MS, scheduleJamRecorder } from "@/jam-loader"; + +describe("scheduleJamRecorder", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("requestIdleCallback", undefined); + // jsdom's window timers are not the ones Vitest replaces on globalThis. + window.setTimeout = globalThis.setTimeout.bind(globalThis); + window.clearTimeout = globalThis.clearTimeout.bind(globalThis); + document.head.querySelectorAll("script[data-jam-recorder]").forEach((node) => node.remove()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + document.head.querySelectorAll("script[data-jam-recorder]").forEach((node) => node.remove()); + document.body.querySelectorAll("iframe").forEach((frame) => frame.remove()); + }); + + function jamScripts(): string[] { + return [...document.head.querySelectorAll("script[data-jam-recorder]")].map((script) => script.getAttribute("src") ?? ""); + } + + it("stays off the page until the first interaction", () => { + scheduleJamRecorder({ mediaRecorderAvailable: true }); + vi.advanceTimersByTime(JAM_PASSIVE_DELAY_MS - 1); + expect(jamScripts()).toEqual([]); + + window.dispatchEvent(new Event("pointerdown")); + vi.advanceTimersByTime(1); + expect(jamScripts()).toEqual([ + "https://js.jam.dev/recorder.js", + "https://js.jam.dev/capture.js", + ]); + }); + + it("loads after the passive delay when nobody interacts", () => { + scheduleJamRecorder({ mediaRecorderAvailable: true }); + vi.advanceTimersByTime(JAM_PASSIVE_DELAY_MS + 1); + expect(jamScripts()).toEqual([ + "https://js.jam.dev/recorder.js", + "https://js.jam.dev/capture.js", + ]); + }); + + it("does not load where MediaRecorder is missing", () => { + scheduleJamRecorder({ mediaRecorderAvailable: false }); + window.dispatchEvent(new KeyboardEvent("keydown")); + vi.advanceTimersByTime(JAM_PASSIVE_DELAY_MS + 1); + expect(jamScripts()).toEqual([]); + }); + + it("removes Jam frames on pagehide and leaves them off after a back/forward cache restore", () => { + const listened: string[] = []; + const realAdd = window.addEventListener.bind(window); + vi.spyOn(window, "addEventListener").mockImplementation((type, listener, options) => { + listened.push(String(type)); + realAdd(type, listener, options); + }); + + scheduleJamRecorder({ mediaRecorderAvailable: false }); + expect(listened).toContain("pagehide"); + expect(listened).not.toContain("unload"); + expect(listened).not.toContain("beforeunload"); + + const frame = document.createElement("iframe"); + frame.src = "https://recorder.jam.dev/capture"; + frame.dataset.jamCapture = "true"; + document.body.append(frame); + + window.dispatchEvent(new Event("pagehide")); + expect(frame.isConnected).toBe(false); + + const restored = new Event("pageshow"); + Object.defineProperty(restored, "persisted", { value: true }); + window.dispatchEvent(restored); + expect(frame.isConnected).toBe(false); + expect(document.querySelectorAll("iframe[data-jam-capture]")).toHaveLength(0); + }); +}); diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index ebbaf2dea..f4790eb06 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -16,6 +16,11 @@ type VercelConfig = { redirects?: Array<{ source: string; destination: string; statusCode?: number }>; rewrites?: Array<{ source: string; destination: string }>; headers?: HeaderRule[]; + routes?: Array<{ + src?: string; + continue?: boolean; + transforms?: Array<{ op?: string; target?: { key?: string } }>; + }>; }; function loadConfig(): VercelConfig { @@ -175,6 +180,8 @@ describe("Vercel frontend deployment configuration", () => { expect(indexHtml).not.toContain('// Prevent flash of wrong theme'); expect(themeBootstrap).toContain("document.documentElement.classList.add('app-shell')"); expect(themeBootstrap).toContain("pathname === '/login'"); + expect(themeBootstrap).toContain("pathname === '/expocit/sources'"); + expect(themeBootstrap).toContain("noindex, nofollow"); expect(themeBootstrap).toContain('window.__showDebateBootstrapError = showBootstrapError'); expect(themeBootstrap).toContain("debate-trainer-settings"); }); @@ -185,11 +192,23 @@ describe("Vercel frontend deployment configuration", () => { const indexHtml = readProjectFile("index.html"); const bootstrap = readProjectFile("src/bootstrap.ts"); const jamLoader = readProjectFile("src/jam-loader.ts"); + const directive = (name: string) => cspDirective(csp, name); - expect(csp).toContain("script-src 'self' https://*.jam.dev"); - expect(csp).toContain("frame-src 'self' https://accounts.google.com https://*.jam.dev"); - // Loading the recorder is useless if it cannot upload the capture. - expect(csp).toContain("connect-src 'self' https://*.jam.dev"); + // The parent page loads js.jam.dev and frames recorder.jam.dev. A wildcard + // would allow every other host on that domain. Jam's rspack runtime contains + // Function("return this"); do not allow unsafe-eval for that fallback. + expect(directive("script-src")).toBe("script-src 'self' https://js.jam.dev"); + expect(directive("frame-src")).toBe("frame-src 'self' https://accounts.google.com https://recorder.jam.dev"); + expect(directive("connect-src")).toContain("https://js.jam.dev"); + expect(directive("connect-src")).toContain("https://recorder.jam.dev"); + expect(directive("img-src")).toContain("https://js.jam.dev"); + expect(directive("img-src")).toContain("https://recorder.jam.dev"); + expect(csp).not.toContain("*.jam.dev"); + expect(csp).not.toContain("unsafe-eval"); + // Web Analytics and Speed Insights are same-origin (/_vercel/insights and + // /_vercel/speed-insights), so script-src and connect-src 'self' allow them. + expect(directive("script-src")).toContain("'self'"); + expect(directive("connect-src")).toContain("'self'"); expect(indexHtml).toContain(''); expect(indexHtml).toContain(''); expect(indexHtml).toContain('data-startup-shell'); @@ -263,19 +282,66 @@ describe("Vercel frontend deployment configuration", () => { }, ); - it.each(["/", "/index.html", "/app-shell.html", "/theme-init.js", "/sw.js"])( - "prevents stale caching for %s", + it("indexes the public sources page and noindexes the app shell document", () => { + const config = loadConfig(); + const indexHtml = readProjectFile("index.html"); + const prerender = readProjectFile("scripts/prerender.mjs"); + const publicRobots = "index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"; + + expect(headersAt(config, "/expocit/sources")).not.toHaveProperty("X-Robots-Tag"); + expect(headersAt(config, "/")).not.toHaveProperty("X-Robots-Tag"); + expect(headersAt(config, "/app")).toMatchObject({ "X-Robots-Tag": "noindex, nofollow" }); + expect(indexHtml).toContain(''); + expect(prerender).toContain(publicRobots); + expect(prerender).toContain("noindex, nofollow"); + expect(readProjectFile("src/lib/seo/pages.ts")).toContain(`robots: '${publicRobots}'`); + }); + + it.each(["/", "/index.html", "/app-shell.html", "/theme-init.js"])( + "revalidates %s without no-store, so the back/forward cache can keep the document", (source) => { - const config = loadConfig(); - expect(headersFor(config, source)["Cache-Control"]).toContain("no-cache"); - expect(headersFor(config, source)["Cache-Control"]).toContain("max-age=0"); + const cache = headersFor(loadConfig(), source)["Cache-Control"]; + expect(cache).toContain("max-age=0"); + expect(cache).toContain("must-revalidate"); + expect(cache).not.toContain("no-store"); }, ); + it("does not cache the service worker, so an update is not stuck", () => { + const cache = headersFor(loadConfig(), "/sw.js")["Cache-Control"]; + expect(cache).toContain("no-store"); + expect(cache).toContain("max-age=0"); + }); + it("caches hashed Vite assets immutably", () => { const config = loadConfig(); expect(headersFor(config, "/assets/(.*)")["Cache-Control"]).toBe( "public, max-age=31536000, immutable", ); }); + + it("caches the unhashed public images for 30 days", () => { + const config = loadConfig(); + for (const path of ["/favicon.ico", "/favicon.png", "/favicon-72.png", "/hero-visual.png", "/placeholder.svg"]) { + expect(headersAt(config, path)["Cache-Control"]).toBe("public, max-age=2592000"); + } + expect(headersAt(config, "/assets/index-abc123.js")["Cache-Control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + + it("strips the platform Access-Control-Allow-Origin header from HTML and not from assets", () => { + const routes = loadConfig().routes ?? []; + const html = routes.find((route) => route.transforms?.some((transform) => transform.op === "delete")); + expect(html?.continue).toBe(true); + const deleted = html?.transforms?.filter((transform) => transform.op === "delete").map((transform) => transform.target?.key); + expect(deleted).toEqual(["access-control-allow-origin", "Access-Control-Allow-Origin"]); + const pattern = new RegExp(html?.src ?? "$^"); + for (const path of ["/", "/app", "/login", "/share/abc", "/expocit/sources", "/terms", "/index.html", "/online-debate-timer"]) { + expect(pattern.test(path), path).toBe(true); + } + for (const path of ["/favicon.png", "/hero-visual.png", "/assets/main.js", "/theme-init.js", "/sw.js", "/robots.txt", "/_vercel/insights/script.js"]) { + expect(pattern.test(path), path).toBe(false); + } + }); }); diff --git a/vercel.json b/vercel.json index 4adfb3c7f..04fd8245a 100644 --- a/vercel.json +++ b/vercel.json @@ -141,7 +141,7 @@ "headers": [ { "key": "Content-Security-Policy", - "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob: https://*.jam.dev; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://*.jam.dev; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com https://*.jam.dev; connect-src 'self' https://*.jam.dev https://uziwdtkzofptihahfooo.supabase.co https://brifeyypjauyusmkptkx.supabase.co https://*.ingest.sentry.io https://*.sentry.io https://eu.i.posthog.com https://*.posthog.com;" + "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob: https://js.jam.dev https://recorder.jam.dev; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://js.jam.dev; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com https://recorder.jam.dev; connect-src 'self' https://js.jam.dev https://recorder.jam.dev https://uziwdtkzofptihahfooo.supabase.co https://brifeyypjauyusmkptkx.supabase.co https://*.ingest.sentry.io https://*.sentry.io https://eu.i.posthog.com https://*.posthog.com;" }, { "key": "X-Frame-Options", @@ -212,7 +212,7 @@ "headers": [ { "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + "value": "public, max-age=0, must-revalidate" } ] }, @@ -221,7 +221,7 @@ "headers": [ { "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + "value": "public, max-age=0, must-revalidate" } ] }, @@ -230,7 +230,7 @@ "headers": [ { "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + "value": "public, max-age=0, must-revalidate" } ] }, @@ -239,7 +239,7 @@ "headers": [ { "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + "value": "public, max-age=0, must-revalidate" } ] }, @@ -260,6 +260,33 @@ "value": "public, max-age=31536000, immutable" } ] + }, + { + "source": "/(favicon\\.ico|favicon\\.png|favicon-72\\.png|hero-visual\\.png|placeholder\\.svg)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=2592000" + } + ] + } + ], + "routes": [ + { + "src": "^/(?!assets(?:/|$)|_vercel(?:/|$)|.*\\.(?:js|mjs|css|png|jpe?g|gif|webp|avif|svg|ico|txt|xml|json|map|webmanifest|woff2?)$).*$", + "continue": true, + "transforms": [ + { + "type": "response.headers", + "op": "delete", + "target": { "key": "access-control-allow-origin" } + }, + { + "type": "response.headers", + "op": "delete", + "target": { "key": "Access-Control-Allow-Origin" } + } + ] } ] }