From 690aae13cb72756716424a65fb28d3866d31c206 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:50:26 +0000 Subject: [PATCH 1/2] Plausible: count visits on www.letsauth.eu, load the personal script through the proxy No pageview has arrived since the proxy went live: the site is served on www.letsauth.eu, and the script only reported when the hostname was exactly letsauth.eu (Astro's site), so it never sent anything. Both hosts count now. Pages load the site's personal Plausible script from /r/s.js, forwarded by a new managed edge rule, as on rucksackrebellen.de. Outbound links and file downloads come from the script and follow the Plausible site settings; the init adds json to the download extensions. Pageviews stay with our code (autoCapturePageviews: false), so a prerendered page still counts only once shown. The module and component take neutral names (reach), because bundles are named after them and blocklists match "analytics" in a path. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018sUALNpiVEQ4wUVUpDDY96 --- docs/architecture.md | 7 +- docs/deploy.md | 32 ++++--- scripts/lib/bunny-edge-rules.ts | 23 ++++- src/components/Analytics.astro | 26 ------ src/components/Explorer.svelte | 2 +- src/components/Reach.astro | 23 +++++ src/layouts/Base.astro | 4 +- src/lib/analytics.ts | 128 --------------------------- src/lib/reach.ts | 79 +++++++++++++++++ tests/unit/analytics.test.ts | 150 -------------------------------- tests/unit/reach.test.ts | 126 +++++++++++++++++++++++++++ 11 files changed, 275 insertions(+), 325 deletions(-) delete mode 100644 src/components/Analytics.astro create mode 100644 src/components/Reach.astro delete mode 100644 src/lib/analytics.ts create mode 100644 src/lib/reach.ts delete mode 100644 tests/unit/analytics.test.ts create mode 100644 tests/unit/reach.test.ts 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/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/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..004bad8 --- /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://letsauth.eu")).hostname; +--- + + + + diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 4fb01ea..88e6ff9 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 { @@ -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/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/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/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"]); + }); +}); From 24ec5bedfd011c0252c71c6f8f76a6d5829044e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:54:58 +0000 Subject: [PATCH 2/2] Serve bundles from /assets/ and make www.letsauth.eu the canonical host Bundled scripts, styles and fonts move from Astro's default /_astro/ to /assets/. www.letsauth.eu is the site's main address, so Astro's site (canonical links, sitemap, Open Graph), the JSON exports' source links, robots.txt, security.txt and the link checker use it. The scanner's User-Agent keeps the apex URL, which still answers. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018sUALNpiVEQ4wUVUpDDY96 --- astro.config.mjs | 5 +++-- public/.well-known/security.txt | 2 +- public/robots.txt | 2 +- scripts/lib/links.ts | 2 +- src/components/Reach.astro | 2 +- src/layouts/Base.astro | 2 +- src/lib/breaches.ts | 2 +- src/lib/explorer.ts | 2 +- src/lib/export.ts | 4 ++-- src/lib/standards-export.ts | 2 +- tests/unit/explorer.test.ts | 2 +- tests/unit/links.test.ts | 4 ++-- 12 files changed, 16 insertions(+), 15 deletions(-) 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/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/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/Reach.astro b/src/components/Reach.astro index 004bad8..38f314a 100644 --- a/src/components/Reach.astro +++ b/src/components/Reach.astro @@ -7,7 +7,7 @@ interface Props { } const { notFound = false } = Astro.props; -const host = (Astro.site ?? new URL("https://letsauth.eu")).hostname; +const host = (Astro.site ?? new URL("https://www.letsauth.eu")).hostname; --- diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 88e6ff9..6e89a2f 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -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 = 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/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/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 });