diff --git a/astro.config.mjs b/astro.config.mjs index 3bbd89d..4b8b364 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -6,11 +6,12 @@ import { defineConfig } from "astro/config"; import pagefind from "astro-pagefind"; export default defineConfig({ - site: "https://letsauth.eu", + site: "https://www.letsauth.eu", trailingSlash: "always", // Bunny Storage serves /index.html for "/folder/" but has nothing to match "/folder" // or an extensionless page, so pages are folders and every internal link ends in a slash. - build: { format: "directory" }, + // Bundled scripts, styles and fonts go to /assets/ instead of Astro's default /_astro/. + build: { format: "directory", assets: "assets" }, // Old addresses from before the page tree was flattened (docs/architecture.md, "Pages"). Each // becomes a small page that forwards to the new address; the JSON exports moved too but a // forwarding page is HTML, so those need the edge rules listed in docs/deploy.md. diff --git a/docs/architecture.md b/docs/architecture.md index 3922485..8e0d67e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,9 +142,10 @@ scan. [`scanner.md`](scanner.md) has the request list, the summary and the job. - The scanner image goes to Scaleway's container registry through `.github/workflows/scanner-deploy.yml`, also only after a green CI run. - Analytics is Plausible (EU), emitted only when `PUBLIC_PLAUSIBLE_DOMAIN` is set. No cookies - that require consent and no third-party script: `src/lib/analytics.ts` posts to `/r/e.json` - on letsauth.eu, which a Bunny edge rule forwards to plausible.io. A prerendered page counts - only once it is shown. [`deploy.md`](deploy.md) has the proxy setup. + that require consent and no third-party request: the personal Plausible script loads from + `/r/s.js` and posts to `/r/e.json` on letsauth.eu, which Bunny edge rules forward to + plausible.io. A prerendered page counts only once it is shown. [`deploy.md`](deploy.md) has + the proxy setup. ## Workflow diff --git a/docs/deploy.md b/docs/deploy.md index 10f071c..d480bd3 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -43,28 +43,32 @@ call alone. Rotate both from the Bunny dashboard and update the secrets. ## Plausible through letsauth.eu -Pages do not load Plausible's script. `src/lib/analytics.ts`, bundled into each page, posts -events to `/r/e.json` on letsauth.eu, and a pull zone edge rule forwards that path to +Pages load the site's personal Plausible script from `/r/s.js` and it sends events to +`/r/e.json`, both on letsauth.eu. Two pull zone edge rules forward them to plausible.io: the +script to `https://plausible.io/js/` (cached for an hour), the events to `https://plausible.io/api/event` with the visitor's IP in `X-Forwarded-For` (without it, -Plausible sees every visit come from the CDN and its bot filter drops them). The path is neutral -on purpose: blocklists match `/js/script.js` and `/analytics/`, and it has an extension because -Bunny answers an extensionless path with a 301 before an edge rule applies -([Plausible's proxy guide](https://plausible.io/docs/proxy/introduction)). +Plausible sees every visit come from the CDN and its bot filter drops them). The paths are +neutral on purpose: blocklists match `/js/script.js` and `/analytics/`, and they have an +extension because Bunny answers an extensionless path with a 301 before an edge rule applies +([Plausible's proxy guide](https://plausible.io/docs/proxy/introduction)). The personal script +name is `PLAUSIBLE_SCRIPT` in `src/lib/reach.ts` (Plausible, site settings, "Site +installation"); change it there if Plausible issues a new one. The rules live in `scripts/lib/bunny-edge-rules.ts`. `pnpm edge-rules` prints them; `pnpm edge-rules --apply` (with `BUNNY_API_KEY` and `BUNNY_PULL_ZONE_ID`) syncs them. The deploy workflow runs the sync before the upload once `BUNNY_SYNC_EDGE_RULES` is `true`, and fails while -`PLAUSIBLE_DOMAIN` is set without it, because the pages would post to a path nothing forwards. +`PLAUSIBLE_DOMAIN` is set without it, because the pages would load a path nothing forwards. Only rules whose description starts with `[repo] ` are created, updated or deleted; rules made by hand in the dashboard, such as the redirects below, are left alone. -The script reports only on the production host, never under automation (Playwright, -Lighthouse), and not in a browser where `localStorage.plausible_ignore` is `"true"`. It sends a -pageview when a page is shown: a page prerendered by speculation rules waits for -`prerenderingchange`, so it counts once if opened and never if not. A page restored from the -back/forward cache counts again, as with Plausible's own script. It also sends -`Outbound Link: Click`, `File Download` (the JSON exports) and `404` with a `path` property; -add those three as custom event goals in the Plausible site settings to see them. +Outbound link clicks and file downloads are the script's own, switched on in the Plausible site +settings; the init snippet adds `json` to the download extensions so the exports count. +Pageviews are sent by `src/lib/reach.ts` (a neutral name, because bundles are named after +their modules and blocklists match "analytics" in a path) (`autoCapturePageviews: false`), only on +letsauth.eu or www.letsauth.eu and never under automation (Playwright, Lighthouse): a page +prerendered by speculation rules waits for `prerenderingchange`, so it counts once if opened and +never if not. A back/forward cache restore and an island's in-place navigation count again. A +404 also sends a `404` event with a `path` property; add it as a custom event goal to see it. ## Manual deploy diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt index db8ab12..20f9a07 100644 --- a/public/.well-known/security.txt +++ b/public/.well-known/security.txt @@ -5,5 +5,5 @@ Contact: mailto:letsauth@janbrennenstuhl.eu Contact: https://github.com/letsauthlab/letsauth/security/advisories/new Expires: 2027-10-14T00:00:00.000Z Preferred-Languages: en, de -Canonical: https://letsauth.eu/.well-known/security.txt +Canonical: https://www.letsauth.eu/.well-known/security.txt Policy: https://github.com/letsauthlab/letsauth/blob/main/SECURITY.md diff --git a/public/robots.txt b/public/robots.txt index bab45d1..4efa82b 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,3 +1,3 @@ User-agent: * Allow: / -Sitemap: https://letsauth.eu/sitemap-index.xml +Sitemap: https://www.letsauth.eu/sitemap-index.xml diff --git a/scripts/lib/bunny-edge-rules.ts b/scripts/lib/bunny-edge-rules.ts index c21b28a..53d36ee 100644 --- a/scripts/lib/bunny-edge-rules.ts +++ b/scripts/lib/bunny-edge-rules.ts @@ -1,4 +1,4 @@ -import { PLAUSIBLE_EVENT_PATH } from "../../src/lib/analytics"; +import { PLAUSIBLE_EVENT_PATH, PLAUSIBLE_SCRIPT, PLAUSIBLE_SCRIPT_PATH } from "../../src/lib/reach"; /** * The Bunny pull zone edge rules this repository owns. Each carries `MANAGED_PREFIX` in its @@ -51,6 +51,27 @@ export function edgeRules(): EdgeRule[] { // Plausible proxy (https://plausible.io/docs/proxy/introduction). Host is set in case the // pull zone forwards the visitor's; X-Forwarded-For carries the visitor's IP, without which // Plausible sees every visit come from the CDN and its bot filter drops them. + { + Description: `${MANAGED_PREFIX}Plausible script`, + Enabled: true, + ActionType: Action.OriginUrl, + ActionParameter1: `https://plausible.io/js/${PLAUSIBLE_SCRIPT}`, + ActionParameter2: null, + ActionParameter3: null, + Triggers: [ + { + Type: Trigger.Url, + PatternMatches: [`*${PLAUSIBLE_SCRIPT_PATH}`], + PatternMatchingType: Match.Any, + Parameter1: null, + }, + ], + TriggerMatchingType: Match.Any, + ExtraActions: [ + extra(Action.SetRequestHeader, "Host", "plausible.io"), + extra(Action.OverrideCacheTime, "3600"), + ], + }, { Description: `${MANAGED_PREFIX}Plausible events`, Enabled: true, diff --git a/scripts/lib/links.ts b/scripts/lib/links.ts index b2962a1..b3fbf24 100644 --- a/scripts/lib/links.ts +++ b/scripts/lib/links.ts @@ -11,7 +11,7 @@ export function brokenLinks(directory: string): string[] { if (entry.isDirectory()) visit(file); else if (entry.isFile() && entry.name.endsWith(".html")) { const route = `/${relative(root, file).split(sep).join("/")}`; - const base = new URL(route, "https://letsauth.eu"); + const base = new URL(route, "https://www.letsauth.eu"); // Astro emits quoted hrefs; fragments are not file targets and are intentionally ignored. for (const match of readFileSync(file, "utf8").matchAll( /\bhref\s*=\s*["']([^"']*)["']/gi, diff --git a/src/components/Analytics.astro b/src/components/Analytics.astro deleted file mode 100644 index b6a7472..0000000 --- a/src/components/Analytics.astro +++ /dev/null @@ -1,26 +0,0 @@ ---- -// Plausible through the site's own address; src/lib/analytics.ts has the rules. -import { PLAUSIBLE_EVENT_PATH } from "@/lib/analytics"; - -interface Props { - domain: string; - notFound?: boolean; -} - -const { domain, notFound = false } = Astro.props; -const host = (Astro.site ?? new URL("https://letsauth.eu")).hostname; ---- - - - diff --git a/src/components/Explorer.svelte b/src/components/Explorer.svelte index d3e9f3a..81a08fb 100644 --- a/src/components/Explorer.svelte +++ b/src/components/Explorer.svelte @@ -18,8 +18,8 @@ * Durations come from the motion tokens, which are 0ms under reduced motion. */ import { onMount } from "svelte"; -import { SOFT_NAVIGATION } from "@/lib/analytics"; import { durationToken, reducedMotion } from "@/lib/motion"; +import { SOFT_NAVIGATION } from "@/lib/reach"; /** M6: how long the chain must sit in view, untouched, before the scene starts by itself. */ const SETTLE_MS = 1000; diff --git a/src/components/Reach.astro b/src/components/Reach.astro new file mode 100644 index 0000000..38f314a --- /dev/null +++ b/src/components/Reach.astro @@ -0,0 +1,23 @@ +--- +// Plausible through the site's own address; src/lib/reach.ts has the rules. +import { PLAUSIBLE_SCRIPT_PATH, plausibleInit } from "@/lib/reach"; + +interface Props { + notFound?: boolean; +} + +const { notFound = false } = Astro.props; +const host = (Astro.site ?? new URL("https://www.letsauth.eu")).hostname; +--- + + + + diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 4fb01ea..6e89a2f 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -4,8 +4,8 @@ import "@/styles/global.css"; // (the fontsource CSS declares them with font-display: swap; Vite gives both the same hashed URL). import fontBody from "@fontsource-variable/geist/files/geist-latin-wght-normal.woff2?url"; import fontMono from "@fontsource-variable/geist-mono/files/geist-mono-latin-wght-normal.woff2?url"; -import Analytics from "@/components/Analytics.astro"; import BrandLogo from "@/components/BrandLogo.astro"; +import Reach from "@/components/Reach.astro"; import { LAB, LEGAL_LINKS, OPERATOR, REPO } from "@/lib/site"; interface Props { @@ -20,7 +20,7 @@ interface Props { } const { title, description, path, noindex = false, image = "/og.png" } = Astro.props; -const site = Astro.site ?? new URL("https://letsauth.eu"); +const site = Astro.site ?? new URL("https://www.letsauth.eu"); const canonical = new URL(path ?? Astro.url.pathname, site); const plausibleDomain = import.meta.env.PUBLIC_PLAUSIBLE_DOMAIN as string | undefined; const fullTitle = @@ -149,7 +149,7 @@ const nav = [ if (t === "light" || t === "dark") document.documentElement.dataset.theme = t; } catch {} - {plausibleDomain && } + {plausibleDomain && } diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts deleted file mode 100644 index 55cccc8..0000000 --- a/src/lib/analytics.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Plausible, sent by the site's own module to the site's own address. A Bunny edge rule forwards - * `PLAUSIBLE_EVENT_PATH` to plausible.io (scripts/lib/bunny-edge-rules.ts), so no page loads a - * third-party script and blocklists that match plausible.io do not drop the count. - * - * One pageview per shown document. A page prerendered by speculation rules runs its scripts - * before the reader opens it, or although they never do; it waits for `prerenderingchange`. - * A page restored from the back/forward cache counts again, as Plausible's own script does, and - * so does a page an island swaps in place (the explorer's toggles), which says so with - * `SOFT_NAVIGATION`. - * - * Only the browser bundle runs `startAnalytics`; the build imports nothing here that fetches. - */ - -/** Needs a file extension: Bunny answers an extensionless path with a 301 before an edge rule. */ -export const PLAUSIBLE_EVENT_PATH = "/r/e.json"; - -/** The window event an island dispatches after it pushed a new URL and swapped the page in place. */ -export const SOFT_NAVIGATION = "letsauth:navigate"; - -export interface AnalyticsConfig { - domain: string; - endpoint: string; - /** The production hostname; local, preview and CI builds never report. */ - host: string; - notFound: boolean; -} - -export type Props = Record; -export type Send = (name: string, props?: Props) => void; - -/** Read from the data attributes `Analytics.astro` writes. */ -export function readConfig(el: HTMLElement | null): AnalyticsConfig | null { - const d = el?.dataset; - if (!d?.domain || !d.endpoint || !d.host) return null; - return { domain: d.domain, endpoint: d.endpoint, host: d.host, notFound: "notFound" in d }; -} - -const DOWNLOAD = /\.(json|csv|pdf|zip|png|svg)$/i; - -/** The event a click on a link records: outbound links and the JSON exports, nothing else. */ -export function linkEvent( - href: string, - here: string, - download = false, -): { name: string; props: Props } | null { - let url: URL; - try { - url = new URL(href, here); - } catch { - return null; - } - if (url.protocol !== "https:" && url.protocol !== "http:") return null; - if (url.host !== new URL(here).host) - return { name: "Outbound Link: Click", props: { url: url.href } }; - if (download || DOWNLOAD.test(url.pathname)) - return { name: "File Download", props: { url: url.href } }; - return null; -} - -type Win = Pick< - Window, - "document" | "location" | "navigator" | "innerWidth" | "addEventListener" -> & { - fetch: typeof fetch; - localStorage?: Storage; -}; - -function ignored(win: Win): boolean { - try { - // Plausible's own opt-out key, so one setting excludes a browser from every site. - return win.localStorage?.getItem("plausible_ignore") === "true"; - } catch { - return false; - } -} - -/** Starts reporting and returns the sender, or null when this page must not report. */ -export function startAnalytics(win: Win, config: AnalyticsConfig): Send | null { - if (win.location.hostname !== config.host || win.navigator.webdriver || ignored(win)) return null; - const doc = win.document as Document & { prerendering?: boolean }; - const shown = new Promise((resolve) => { - if (doc.prerendering) - doc.addEventListener("prerenderingchange", () => resolve(), { once: true }); - else resolve(); - }); - const send: Send = (name, props) => { - void shown.then(() => { - const body = { - n: name, - u: win.location.href, - d: config.domain, - r: doc.referrer || null, - w: win.innerWidth, - ...(props ? { p: props } : {}), - }; - // keepalive lets an outbound click report while the page unloads. - win - .fetch(config.endpoint, { - method: "POST", - body: JSON.stringify(body), - headers: { "Content-Type": "text/plain" }, - keepalive: true, - credentials: "omit", - }) - .catch(() => {}); - }); - }; - - send("pageview"); - if (config.notFound) send("404", { path: win.location.pathname }); - win.addEventListener("pageshow", (event) => { - if ((event as PageTransitionEvent).persisted) send("pageview"); - }); - win.addEventListener(SOFT_NAVIGATION, () => send("pageview")); - - const onClick = (event: Event) => { - const e = event as MouseEvent; - if (e.type === "auxclick" && e.button !== 1) return; - const link = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null; - if (!link) return; - const hit = linkEvent(link.href, win.location.href, link.hasAttribute("download")); - if (hit) send(hit.name, hit.props); - }; - doc.addEventListener("click", onClick); - doc.addEventListener("auxclick", onClick); - return send; -} diff --git a/src/lib/breaches.ts b/src/lib/breaches.ts index 4064292..16304ad 100644 --- a/src/lib/breaches.ts +++ b/src/lib/breaches.ts @@ -60,7 +60,7 @@ export function buildBreachesExport(data: Dataset, generatedAt = new Date().toIS version: BREACHES_EXPORT_VERSION, generatedAt, license: licenseLine(), - source: "https://letsauth.eu/breaches/", + source: "https://www.letsauth.eu/breaches/", vulnerabilities: breachList(data).map(({ vulnerability, replay }) => ({ ...vulnerability, replay: replay ? `/explorer/${replay.scenario.id}/${replay.toggle.id}/` : null, diff --git a/src/lib/explorer.ts b/src/lib/explorer.ts index 1ce68ec..010108b 100644 --- a/src/lib/explorer.ts +++ b/src/lib/explorer.ts @@ -179,7 +179,7 @@ export function buildExplorerExport(data: Dataset, generatedAt = new Date().toIS version: EXPLORER_EXPORT_VERSION, generatedAt, license: licenseLine(), - source: "https://letsauth.eu/explorer/", + source: "https://www.letsauth.eu/explorer/", scenarios: scenarioList(data).map((s) => s.scenario), }; } diff --git a/src/lib/export.ts b/src/lib/export.ts index fac984e..a7fffba 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -38,7 +38,7 @@ export function buildExport(data: Dataset, generatedAt = new Date().toISOString( version: EXPORT_VERSION, generatedAt, license: licenseLine(), - source: "https://letsauth.eu/mcp/", + source: "https://www.letsauth.eu/mcp/", spec: { id: spec.id, name: spec.name, currentRevision: current.id, revisions: spec.revisions }, features: data.features, servers: matrix.rows, @@ -67,7 +67,7 @@ export function buildServerExport( version: EXPORT_VERSION, generatedAt, license: licenseLine(), - source: `https://letsauth.eu/mcp/servers/${server.id}/`, + source: `https://www.letsauth.eu/mcp/servers/${server.id}/`, spec: { id: spec.id, name: spec.name, diff --git a/src/lib/reach.ts b/src/lib/reach.ts new file mode 100644 index 0000000..0af9474 --- /dev/null +++ b/src/lib/reach.ts @@ -0,0 +1,79 @@ +/** + * Plausible through the site's own address. Pages load the site's personal Plausible script from + * `PLAUSIBLE_SCRIPT_PATH` and it sends events to `PLAUSIBLE_EVENT_PATH`; Bunny edge rules forward + * both to plausible.io (scripts/lib/bunny-edge-rules.ts), so blocklists that match plausible.io do + * not drop the count. Outbound links and file downloads are the script's own, switched on in the + * Plausible site settings. + * + * Pageviews are sent from here, not by the script (`autoCapturePageviews: false`): one per shown + * document. A page prerendered by speculation rules runs its scripts before the reader opens it, + * or although they never do; it waits for `prerenderingchange`. A page restored from the + * back/forward cache counts again, as Plausible's own capture does, and so does a page an island + * swaps in place (the explorer's toggles), which says so with `SOFT_NAVIGATION`. + * + * Bundled file names come from module and component names, and blocklists match words such as + * "analytics", "tracking" or "plausible" in a path: this module and `Reach.astro` keep neutral + * names so the pageview code is not blocked while the proxied script gets through. + */ + +/** Paths need a file extension: Bunny answers an extensionless path with a 301 before a rule. */ +export const PLAUSIBLE_SCRIPT_PATH = "/r/s.js"; +export const PLAUSIBLE_EVENT_PATH = "/r/e.json"; +/** The personal script from Plausible's site settings, "Site installation". */ +export const PLAUSIBLE_SCRIPT = "pa-dijXYLvcgUmFUPtPr7i0G.js"; + +/** The window event an island dispatches after it pushed a new URL and swapped the page in place. */ +export const SOFT_NAVIGATION = "letsauth:navigate"; + +/** + * The inline snippet that queues calls until the script has loaded and configures it, as in + * https://plausible.io/docs/proxy/guides/nginx. Plausible's default download extensions miss the + * JSON exports, and the option replaces that list. + */ +export function plausibleInit(): string { + const options = { + endpoint: PLAUSIBLE_EVENT_PATH, + autoCapturePageviews: false, + fileDownloads: { fileExtensions: ["json", "csv", "pdf", "zip"] }, + }; + return `window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init(${JSON.stringify(options)});`; +} + +export interface AnalyticsConfig { + /** The production hostname; local, preview and CI builds never report. */ + host: string; + notFound: boolean; +} + +export type Props = Record; +/** `window.plausible`. */ +export type Track = (name: string, options?: { props?: Props }) => void; + +/** Read from the data attributes `Reach.astro` writes. */ +export function readConfig(el: HTMLElement | null): AnalyticsConfig | null { + const d = el?.dataset; + if (!d?.host) return null; + return { host: d.host, notFound: "notFound" in d }; +} + +/** The site answers on both letsauth.eu and www.letsauth.eu; either is production. */ +const bare = (host: string) => host.replace(/^www\./, ""); + +type Win = Pick; + +/** Sends the pageviews; returns false when this page must not report. */ +export function startAnalytics(win: Win, config: AnalyticsConfig, track: Track): boolean { + if (bare(win.location.hostname) !== bare(config.host) || win.navigator.webdriver) return false; + const doc = win.document as Document & { prerendering?: boolean }; + const pageview = () => { + track("pageview"); + if (config.notFound) track("404", { props: { path: win.location.pathname } }); + }; + if (doc.prerendering) doc.addEventListener("prerenderingchange", pageview, { once: true }); + else pageview(); + win.addEventListener("pageshow", (event) => { + if ((event as PageTransitionEvent).persisted) track("pageview"); + }); + win.addEventListener(SOFT_NAVIGATION, () => track("pageview")); + return true; +} diff --git a/src/lib/standards-export.ts b/src/lib/standards-export.ts index cfada09..75bafb4 100644 --- a/src/lib/standards-export.ts +++ b/src/lib/standards-export.ts @@ -20,7 +20,7 @@ export function buildStandardsExport(data: Dataset, generatedAt = new Date().toI version: STANDARDS_EXPORT_VERSION, generatedAt, license: licenseLine(), - source: "https://letsauth.eu/standards/", + source: "https://www.letsauth.eu/standards/", specs: specNodes(data), relations: data.relations, withdrawn: withdrawnSpecs(data).map((s) => s.id), diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts deleted file mode 100644 index cfd62a1..0000000 --- a/tests/unit/analytics.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { type AnalyticsConfig, linkEvent, SOFT_NAVIGATION, startAnalytics } from "@/lib/analytics"; -import { - type EdgeRule, - edgeRules, - MANAGED_PREFIX, - syncEdgeRules, -} from "../../scripts/lib/bunny-edge-rules"; - -const config: AnalyticsConfig = { - domain: "letsauth.eu", - endpoint: "/r/e.json", - host: "letsauth.eu", - notFound: false, -}; - -function fakeWindow(opts: { prerendering?: boolean; hostname?: string; webdriver?: boolean } = {}) { - const sent: { url: string; body: Record }[] = []; - const document = Object.assign(new EventTarget(), { - prerendering: opts.prerendering ?? false, - referrer: "https://example.org/", - }); - const hostname = opts.hostname ?? "letsauth.eu"; - const win = Object.assign(new EventTarget(), { - document, - location: { hostname, href: `https://${hostname}/mcp/`, pathname: "/mcp/" }, - navigator: { webdriver: opts.webdriver ?? false }, - innerWidth: 1280, - fetch: async (url: string, init: { body: string }) => { - sent.push({ url, body: JSON.parse(init.body) }); - return new Response(null, { status: 202 }); - }, - }); - // biome-ignore lint/suspicious/noExplicitAny: a minimal stand-in for Window - return { win: win as any, document, sent }; -} - -const settle = () => new Promise((r) => setTimeout(r, 0)); - -describe("analytics", () => { - it("records one pageview through the first-party endpoint", async () => { - const { win, sent } = fakeWindow(); - expect(startAnalytics(win, config)).not.toBeNull(); - await settle(); - expect(sent).toEqual([ - { - url: "/r/e.json", - body: { - n: "pageview", - u: "https://letsauth.eu/mcp/", - d: "letsauth.eu", - r: "https://example.org/", - w: 1280, - }, - }, - ]); - }); - - it("holds a prerendered page's pageview until it is shown, and drops it if never", async () => { - const { win, document, sent } = fakeWindow({ prerendering: true }); - startAnalytics(win, config); - await settle(); - expect(sent).toEqual([]); - document.prerendering = false; - document.dispatchEvent(new Event("prerenderingchange")); - await settle(); - expect(sent.map((s) => s.body.n)).toEqual(["pageview"]); - }); - - it("counts a back/forward cache restore, not an ordinary pageshow", async () => { - const { win, sent } = fakeWindow(); - startAnalytics(win, config); - win.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: false })); - win.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); - await settle(); - expect(sent.map((s) => s.body.n)).toEqual(["pageview", "pageview"]); - }); - - it("counts a page an island swapped in place", async () => { - const { win, sent } = fakeWindow(); - startAnalytics(win, config); - win.dispatchEvent(new Event(SOFT_NAVIGATION)); - await settle(); - expect(sent.map((s) => s.body.n)).toEqual(["pageview", "pageview"]); - }); - - it("records a 404 with its path", async () => { - const { win, sent } = fakeWindow(); - startAnalytics(win, { ...config, notFound: true }); - await settle(); - expect(sent[1]?.body).toMatchObject({ n: "404", p: { path: "/mcp/" } }); - }); - - it("never reports off the production host or under automation", () => { - expect(startAnalytics(fakeWindow({ hostname: "localhost" }).win, config)).toBeNull(); - expect(startAnalytics(fakeWindow({ webdriver: true }).win, config)).toBeNull(); - }); - - it("classifies link clicks", () => { - const here = "https://letsauth.eu/mcp/"; - expect(linkEvent("https://github.com/x", here)?.name).toBe("Outbound Link: Click"); - expect(linkEvent("/mcp/data.json", here)).toEqual({ - name: "File Download", - props: { url: "https://letsauth.eu/mcp/data.json" }, - }); - expect(linkEvent("/standards/", here)).toBeNull(); - expect(linkEvent("mailto:a@b.eu", here)).toBeNull(); - }); -}); - -describe("bunny edge rules", () => { - it("forwards the event path to Plausible with the visitor's IP", () => { - const [rule] = edgeRules() as [EdgeRule]; - expect(rule.ActionParameter1).toBe("https://plausible.io/api/event"); - expect(rule.Triggers[0]?.PatternMatches).toEqual(["*/r/e.json"]); - expect(rule.ExtraActions).toContainEqual( - expect.objectContaining({ - ActionParameter1: "X-Forwarded-For", - ActionParameter2: "%{User.IP}", - }), - ); - }); - - it("updates and deletes only managed rules", async () => { - const calls: [string, string, unknown?][] = []; - const api = async (method: string, path: string, body?: unknown) => { - calls.push([method, path, body]); - if (method === "GET") - return { - EdgeRules: [ - { Guid: "a", Description: `${MANAGED_PREFIX}Plausible events` }, - { Guid: "b", Description: `${MANAGED_PREFIX}Old rule` }, - { Guid: "c", Description: "Hand-made redirect" }, - ], - }; - return null; - }; - const log = await syncEdgeRules(api, "42", edgeRules()); - expect(log).toEqual([ - `updated ${MANAGED_PREFIX}Plausible events`, - `deleted ${MANAGED_PREFIX}Old rule`, - ]); - expect(calls[1]).toEqual([ - "POST", - "/pullzone/42/edgerules/addOrUpdate", - expect.objectContaining({ Guid: "a" }), - ]); - expect(calls[2]?.slice(0, 2)).toEqual(["DELETE", "/pullzone/42/edgerules/b"]); - }); -}); diff --git a/tests/unit/explorer.test.ts b/tests/unit/explorer.test.ts index 0161c71..90f21d7 100644 --- a/tests/unit/explorer.test.ts +++ b/tests/unit/explorer.test.ts @@ -183,7 +183,7 @@ describe("scenarioList, featureSupport and the export (FR-006, FR-008, FR-009)", version: 1, generatedAt: "2026-10-15T00:00:00.000Z", license: "CC BY 4.0", - source: "https://letsauth.eu/explorer/", + source: "https://www.letsauth.eu/explorer/", }); expect(out.scenarios).toEqual([scenario]); }); diff --git a/tests/unit/links.test.ts b/tests/unit/links.test.ts index 64514f1..b6b93a0 100644 --- a/tests/unit/links.test.ts +++ b/tests/unit/links.test.ts @@ -20,11 +20,11 @@ it("checks relative and absolute local links while allowing fragments, assets an here externalmail broken revision - broken index`, + broken index`, ); expect(brokenLinks(root)).toEqual([ "/index.html: /mcp/2025-11-25/", - "/index.html: https://letsauth.eu/mcp/servers/", + "/index.html: https://www.letsauth.eu/mcp/servers/", ]); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/tests/unit/reach.test.ts b/tests/unit/reach.test.ts new file mode 100644 index 0000000..8bf155e --- /dev/null +++ b/tests/unit/reach.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + type AnalyticsConfig, + PLAUSIBLE_EVENT_PATH, + plausibleInit, + SOFT_NAVIGATION, + startAnalytics, + type Track, +} from "@/lib/reach"; +import { edgeRules, MANAGED_PREFIX, syncEdgeRules } from "../../scripts/lib/bunny-edge-rules"; + +const config: AnalyticsConfig = { host: "letsauth.eu", notFound: false }; + +function fakeWindow(opts: { prerendering?: boolean; hostname?: string; webdriver?: boolean } = {}) { + const calls: Parameters[] = []; + const track: Track = (...args) => { + calls.push(args); + }; + const document = Object.assign(new EventTarget(), { prerendering: opts.prerendering ?? false }); + const hostname = opts.hostname ?? "letsauth.eu"; + const win = Object.assign(new EventTarget(), { + document, + location: { hostname, href: `https://${hostname}/mcp/`, pathname: "/mcp/" }, + navigator: { webdriver: opts.webdriver ?? false }, + }); + // biome-ignore lint/suspicious/noExplicitAny: a minimal stand-in for Window + return { win: win as any, document, track, names: () => calls.map((c) => c[0]), calls }; +} + +describe("analytics", () => { + it("configures Plausible to post to the first-party endpoint and leave pageviews to us", () => { + const init = plausibleInit(); + expect(init).toContain(`"endpoint":"${PLAUSIBLE_EVENT_PATH}"`); + expect(init).toContain('"autoCapturePageviews":false'); + expect(init).toContain('"json"'); + }); + + it("records one pageview", () => { + const { win, track, names } = fakeWindow(); + expect(startAnalytics(win, config, track)).toBe(true); + expect(names()).toEqual(["pageview"]); + }); + + it("holds a prerendered page's pageview until it is shown, and drops it if never", () => { + const { win, document, track, names } = fakeWindow({ prerendering: true }); + startAnalytics(win, config, track); + expect(names()).toEqual([]); + document.prerendering = false; + document.dispatchEvent(new Event("prerenderingchange")); + document.dispatchEvent(new Event("prerenderingchange")); + expect(names()).toEqual(["pageview"]); + }); + + it("counts a back/forward cache restore and an in-place navigation", () => { + const { win, track, names } = fakeWindow(); + startAnalytics(win, config, track); + win.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: false })); + win.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + win.dispatchEvent(new Event(SOFT_NAVIGATION)); + expect(names()).toEqual(["pageview", "pageview", "pageview"]); + }); + + it("reports on www.letsauth.eu as well", () => { + const { win, track, names } = fakeWindow({ hostname: "www.letsauth.eu" }); + expect(startAnalytics(win, config, track)).toBe(true); + expect(names()).toEqual(["pageview"]); + }); + + it("records a 404 with its path", () => { + const { win, track, calls } = fakeWindow(); + startAnalytics(win, { ...config, notFound: true }, track); + expect(calls[1]).toEqual(["404", { props: { path: "/mcp/" } }]); + }); + + it("never reports off the production host or under automation", () => { + for (const opts of [{ hostname: "localhost" }, { webdriver: true }]) { + const { win, track, names } = fakeWindow(opts); + expect(startAnalytics(win, config, track)).toBe(false); + expect(names()).toEqual([]); + } + }); +}); + +describe("bunny edge rules", () => { + it("serves the personal script and forwards events with the visitor's IP", () => { + const [script, events] = edgeRules(); + expect(script?.ActionParameter1).toMatch(/^https:\/\/plausible\.io\/js\/pa-.+\.js$/); + expect(script?.Triggers[0]?.PatternMatches).toEqual(["*/r/s.js"]); + expect(events?.ActionParameter1).toBe("https://plausible.io/api/event"); + expect(events?.Triggers[0]?.PatternMatches).toEqual(["*/r/e.json"]); + expect(events?.ExtraActions).toContainEqual( + expect.objectContaining({ + ActionParameter1: "X-Forwarded-For", + ActionParameter2: "%{User.IP}", + }), + ); + }); + + it("updates and deletes only managed rules", async () => { + const calls: [string, string, unknown?][] = []; + const api = async (method: string, path: string, body?: unknown) => { + calls.push([method, path, body]); + if (method === "GET") + return { + EdgeRules: [ + { Guid: "a", Description: `${MANAGED_PREFIX}Plausible events` }, + { Guid: "b", Description: `${MANAGED_PREFIX}Old rule` }, + { Guid: "c", Description: "Hand-made redirect" }, + ], + }; + return null; + }; + const log = await syncEdgeRules(api, "42", edgeRules()); + expect(log).toEqual([ + `created ${MANAGED_PREFIX}Plausible script`, + `updated ${MANAGED_PREFIX}Plausible events`, + `deleted ${MANAGED_PREFIX}Old rule`, + ]); + expect(calls[2]).toEqual([ + "POST", + "/pullzone/42/edgerules/addOrUpdate", + expect.objectContaining({ Guid: "a" }), + ]); + expect(calls[3]?.slice(0, 2)).toEqual(["DELETE", "/pullzone/42/edgerules/b"]); + }); +});