diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index 5d08d026a..5c97d679b 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -11,7 +11,7 @@ **Settings is the front door.** The spoken-alarm row's managed-voice link lands on `/hosted#voice`, and the Pocket playground tutorial on `/hosted#remote-control`. `#pricing` (the plan cards, which each JSON-LD `Offer` url names), `#remote-control`, and `#voice` keep resolving as section ids. -**Must give every link to the page a `?ref=` from `HOSTED_REFS`, one per link, ahead of its fragment, and strip it from the address bar on arrival**, so a copied link never carries it. Nothing counts or sends it yet; `takeVisitRef` answers it, an unlisted value as `other`, for whatever will. Source of truth: `lib/src/lib/hosted-links.ts`; `takeVisitRef` in `website/src/lib/hosted-ref.ts`. +**Must give every link to the page a `?ref=` from `HOSTED_REFS`, one per link, ahead of its fragment, and strip it from the address bar on arrival**, so a copied link never carries it. `takeVisitRef` answers it, an unlisted value as `other`, and the page's pageview carries it (`docs/specs/website-docs.md` -> "Visit analytics"). Source of truth: `lib/src/lib/hosted-links.ts`; `takeVisitRef` in `website/src/lib/hosted-ref.ts`. **Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers stay `PreOrder` while checkout is unbuilt.** diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 2fba293d3..2fb96b639 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -146,10 +146,12 @@ Source of truth: `startCapabilityViewer` in `dor-tools-builtin/src/viewer-server | Guarantee | Feature | Pinned by | | --- | --- | --- | | **Under Settings → Network → Nowhere, a new install's level, Dormouse opens no connection on its own**: no relay socket, one-time link, push, managed voice, or update check. What you click, and what your terminals, browser panes, and agents reach, restored ones included, are yours. | | `scripts/outbound-lint.mjs`, `lib/src/host/remote/outbound.test.ts`, `vscode-ext/test/outbound.test.ts`, `lib/src/host/remote/service.test.ts`, `lib/src/host/managed-voice-host.test.ts`, `standalone/src/updater.test.ts` | +| **Dormouse sends no usage telemetry.** No analytics library ships in the app, the VS Code extension, or the phone pages; only the dormouse.sh website counts visits. | | `scripts/outbound-lint.mjs` | The exposure is traffic the user never chose, which under Nothing is none. `docs/specs/remote-network.md` -> "Policy" owns the rule these checks audit. - **FAIL IF** `S156` anything opens a connection on its own while the network policy is `nothing` or unread. `BurrowService` in `lib/src/host/remote/service.ts` must route every socket, request, and direct peer through its transport guard, which refuses at the call, and `BurrowRuntime` and `burrowFetch` must take theirs from it with no default to the global `WebSocket` or `fetch`; start no `BurrowRuntime` under `nothing`; and refuse `enroll`, `enrollOffer`, `beginHostedEnrollment`, and `oneTimeOpen` before any request. `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` must ask `networkAllowed` before every speak and voice-approval request, and `runUpdateCheck` in `standalone/src/updater.ts` must not call `check()` unless the policy it read is not `nothing` and `autoUpdate` is on, a failed read counting as `nothing`. Every other network primitive in shipped code is an `OUTBOUND_SITES` entry `scripts/outbound-lint.mjs` prints, its `FORMS` the inventory: read each `guarded:` entry back to its choke point, each `user:` entry's trigger to a click, a command, or the user's own terminal, browser pane, or agent, and each `loopback`, `local-ipc`, `phone`, or `dev-only` entry to what it claims (rationale). Pinned by the lint; by `lib/src/host/remote/outbound.test.ts` and `vscode-ext/test/outbound.test.ts`, each host's real service watched at the Node layer; and by `lib/src/host/remote/service.test.ts`, `lib/src/remote/burrow/burrow-fetch.test.ts`, `lib/src/remote/burrow/burrow-relay-socket.test.ts`, `lib/src/host/managed-voice-host.test.ts`, and `standalone/src/updater.test.ts`. +- **FAIL IF** `S280` an analytics SDK ships in Dormouse: a workspace package but `dormouse-website` depends on `posthog-js` or another analytics package, or a file under the directories `scripts/outbound-lint.mjs` scans imports one. Pinned by the lint, whose `analytics SDK` form no `OUTBOUND_SITES` class admits; the website's own counts are `docs/specs/website-docs.md` -> "Visit analytics", and Hosted's frontend is `S82`. - **FAIL IF** `S157` a webview or viewer CSP omits `default-src`, `base-uri`, or `form-action` (neither falls back), or admits a source other than its own origin, IPC, `data:`, `blob:`, a nonce or hash, VS Code's `cspSource`, or loopback in any `-src` directive, `base-uri`, `form-action`, or `frame-ancestors`: `app.security.csp` in `standalone/src-tauri/tauri.conf.json`, a header that must also carry `frame-ancestors`, the directives `getWebviewHtml` in `vscode-ext/src/webview-html.ts` builds, and every `*CSP` constant in `dor-tools-builtin/src/`. Pinned by `scripts/outbound-lint.mjs`. - **FAIL IF** `S158` the Tauri host can reach the network but through its updater: `standalone/src-tauri/src/lib.rs` registers a plugin but shell and updater, a file under `standalone/src-tauri/src/` names a Rust socket or HTTP client, `[dependencies]` in `standalone/src-tauri/Cargo.toml` gains an HTTP, WebSocket, upload, or opener plugin or an HTTP client crate, an `updater:` permission leaves `standalone/src-tauri/capabilities/main-only.json`, a capability carries a `remote` scope, or `withGlobalTauri` is on. Pinned by `scripts/outbound-lint.mjs`; the updater's own endpoint and key are `docs/specs/security-ci.md` -> "Desktop Releases". - **FAIL IF** `S159` shipped code listens beyond loopback: a `.listen(` or `serve({` with no spelled-out loopback host, a Unix socket or named pipe aside; a `0.0.0.0` or `::` host; or a UDP socket but the direct path's, which only the Burrow service's guarded factory builds (`docs/specs/remote-network.md` -> "Local networks"). Pinned by `scripts/loopback-lint.mjs` (check 4). diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index 1676f2769..62bc90b75 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -152,6 +152,18 @@ Source of truth: `VERSUS` and `COMPARISON` in `website/src/lib/comparison.tsx`; Source of truth: `HostedPolicyLayout` in `website/src/components/HostedPolicyLayout.tsx`. +## Visit analytics + +dormouse.sh alone counts visits, with PostHog US Cloud (`docs/specs/security-local.md` -> "Network policy"). + +- **Must load posthog-js only on `dormouse.sh`, only at the first event, and never under Global Privacy Control or Do Not Track.** +- **Must run `cookieless_mode: "always"` and keep nothing in cookies, `localStorage`, `sessionStorage`, or IndexedDB**; the one write is posthog-js's `localStorage` check at init, a test value it removes at once. PostHog drops every event unless the project's Settings → Project → Web analytics → Enable cookieless tracking is on. +- **Must send only** one `$pageview` per path, the `/hosted` one carrying the visit's `ref` (`docs/specs/pricing.md` -> "The Hosted page"); `hosted plan clicked` with its `plan`; and `tutorial completed` with its `tutorial`, sent only when this visit checks the last item. **Never enable autocapture, replay, heatmaps, surveys, exception capture, feature flags, `identify`, or a remote script.** +- **Must send to `dormouse.sh/ingest`**, never to a PostHog host. A Pages advanced-mode Worker limited to `/ingest/*` forwards to PostHog's US hosts without cookies or authorization, passing the visitor's address as `X-Forwarded-For`. +- **Must change Privacy's analytics paragraph in the same edit as anything above** (`website/src/pages/Privacy.tsx`). + +Source of truth: `website/src/lib/site-analytics.ts`; `website/public/_worker.js`. + ## `/dor` reference The CLI page consumes the Markdown snapshots generated by `dor/test/cli-help.test.mjs`, which proves every snapshot equals real help output. The root help snapshot owns command order and inventory. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a1a80758..4c4a93f3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -808,6 +808,9 @@ importers: dormouse-lib: specifier: workspace:* version: link:../lib + posthog-js: + specifier: ^1.438.24 + version: 1.438.24(@types/react@19.3.0)(react@19.3.1) react: specifier: ^19.2.6 version: 19.3.1 @@ -3020,6 +3023,15 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/browser-common@0.9.12': + resolution: {integrity: sha512-pKYLYgV0o4UK5PAQ3OTlZpQnKvcBpG+UOqWYa+W41Omec8TlI6FXzGMHdYMTmaymxii0vi9R4jLVi7JQSr4vnQ==} + + '@posthog/core@1.61.0': + resolution: {integrity: sha512-FxOHbuxCZCFGb/46KdcBtilTX5ROeB4Gs8ZrWESzyNlCCxLw4/5xBVh8nfmPjbztuPvxRs5tOcpQ1kNnr2nlAA==} + + '@posthog/types@1.416.1': + resolution: {integrity: sha512-PG06HE2axHNyyZ9CKSSHKSW4pWfQwEtPA1dewFqIwggcIgk1BsQ+tHSNqvZz+IvE2WnBZ8OcOkF9SWI55VxQfA==} + '@preact/signals-core@1.14.4': resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} @@ -4971,6 +4983,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js@3.51.0: + resolution: {integrity: sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -5488,6 +5503,9 @@ packages: picomatch: optional: true + fflate@0.4.9: + resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -6788,6 +6806,17 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + posthog-js@1.438.24: + resolution: {integrity: sha512-spRkm2eZjBKpJovgoMq30S1lvnwD7s8ZQcTjADf6iLHFCD1RdIjakZ8mKy+Lnt1Wj7kkRe+mXVb+lUCDKa6aQw==} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -6796,6 +6825,14 @@ packages: resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} engines: {node: '>=20'} + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -6837,6 +6874,9 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7633,6 +7673,12 @@ packages: engines: {node: '>= 16'} hasBin: true + web-vitals@6.2.1: + resolution: {integrity: sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==} + + web-vitals@6.2.3: + resolution: {integrity: sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -9964,6 +10010,20 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@posthog/browser-common@0.9.12': + dependencies: + '@posthog/core': 1.61.0 + '@posthog/types': 1.416.1 + preact: 10.29.8 + transitivePeerDependencies: + - preact-render-to-string + + '@posthog/core@1.61.0': + dependencies: + '@posthog/types': 1.416.1 + + '@posthog/types@1.416.1': {} + '@preact/signals-core@1.14.4': {} '@protobufjs/aspromise@1.1.2': {} @@ -11740,6 +11800,8 @@ snapshots: cookie@1.1.1: {} + core-js@3.51.0: {} + core-util-is@1.0.3: {} cose-base@1.0.3: @@ -12300,6 +12362,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.4.9: {} + fflate@0.8.3: {} fill-range@7.1.1: @@ -13743,10 +13807,30 @@ snapshots: dependencies: xtend: 4.0.2 + posthog-js@1.438.24(@types/react@19.3.0)(react@19.3.1): + dependencies: + '@posthog/browser-common': 0.9.12 + '@posthog/core': 1.61.0 + '@posthog/types': 1.416.1 + core-js: 3.51.0 + dompurify: 3.4.16 + fflate: 0.4.9 + preact: 10.29.8 + query-selector-shadow-dom: 1.0.1 + web-vitals: 6.2.3 + web-vitals-soft-navs: web-vitals@6.2.1 + optionalDependencies: + '@types/react': 19.3.0 + react: 19.3.1 + transitivePeerDependencies: + - preact-render-to-string + powershell-utils@0.1.0: {} powershell-utils@0.2.1: {} + preact@10.29.8: {} + prettier@3.9.6: optional: true @@ -13805,6 +13889,8 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + query-selector-shadow-dom@1.0.1: {} + queue-microtask@1.2.3: {} react-docgen-typescript@2.4.0(typescript@7.0.2): @@ -14743,6 +14829,10 @@ snapshots: transitivePeerDependencies: - supports-color + web-vitals@6.2.1: {} + + web-vitals@6.2.3: {} + webidl-conversions@8.0.1: {} webpack-virtual-modules@0.6.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3d4d907cc..c19132ae9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ packages: - vscode-ext - website allowBuilds: + core-js: false cpu-features: false protobufjs: false ssh2: false diff --git a/scripts/outbound-lint-selftest.mjs b/scripts/outbound-lint-selftest.mjs index 98347b0b6..1068ce66a 100644 --- a/scripts/outbound-lint-selftest.mjs +++ b/scripts/outbound-lint-selftest.mjs @@ -61,6 +61,8 @@ const FIXTURES = [ ['window.open', '\nexport const __selftest = (u: string) => window.open(u);\n'], ['remote Worker', '\nexport const __selftest = (u: string) => new Worker(u);\n'], ['importScripts', '\nexport const __selftest = (u: string) => importScripts(u);\n'], + ['analytics SDK', "\nexport const __selftest = () => import('posthog-js');\n"], + ['analytics SDK', "\nimport __ph from 'posthog-js/dist/module.slim.no-external.js';\nexport const __selftest = __ph;\n"], ['remote URL literal', "\nexport const __selftest = 'https://cdn.example.net/x.js';\n"], ['remote URL literal', '\nexport const __selftest = (p: string) => `wss://relay.example.net/${p}`;\n'], ]; @@ -122,6 +124,12 @@ const STRUCTURAL = [ 'constructs a Burrow runtime outside', 'new OneTimeRuntime outside the service', ], + [ + 'lib/src/remote/burrow/burrow-fetch.ts', + append("\nexport const __selftest = () => import('posthog-js');\n"), + 'spells analytics SDK, which no OUTBOUND_SITES class admits', + 'an analytics SDK in a file OUTBOUND_SITES already classifies', + ], [ 'lib/src/host/relay-origin.ts', append("\nexport const __selftest = 'https://relay.example.net';\n"), diff --git a/scripts/outbound-lint.mjs b/scripts/outbound-lint.mjs index 63029b0a8..2acfa756e 100644 --- a/scripts/outbound-lint.mjs +++ b/scripts/outbound-lint.mjs @@ -105,6 +105,9 @@ const FORMS = [ { label: 'window.open', re: /\bwindow\s*\.\s*open\s*\(/ }, { label: 'remote Worker', re: /\bnew\s+(?:Shared)?Worker\s*\(\s*(?!new\s+URL\s*\(\s*['"`]\.{1,2}\/)/ }, { label: 'importScripts', re: /\bimportScripts\s*\(/ }, + // Never classifiable: Dormouse sends no usage telemetry, and the website alone + // runs visit analytics (docs/specs/security-local.md -> "Network policy"). + { label: 'analytics SDK', re: /['"]posthog-js(?:\/[^'"]*)?['"]/, never: true }, // Matched by remoteHosts(), not by `re`; listed so the self-test requires its fixture. { label: 'remote URL literal', re: null }, ]; @@ -546,6 +549,14 @@ for (const rel of trackedFiles().filter(isShippedSource)) { if (hits.length === 0) continue; inventory.push({ rel, hits }); + const never = hits.find((h) => h.form.never); + if (never) { + problems.push( + `${rel}:${never.line}: spells ${never.form.label}, which no OUTBOUND_SITES class admits.\n` + + ' Dormouse sends no usage telemetry; visit analytics belong to website/ alone\n' + + ' (docs/specs/security-local.md -> "Network policy").', + ); + } const entry = OUTBOUND_SITES[rel]; if (!entry) { problems.push( diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index b91d5e79f..f05cd7db4 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 280, + "next": 281, "retired": [ 265 ] diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d00038f6f..f678c7c5c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -27,7 +27,7 @@ "docs/specs/security-audit.md": 5400, "docs/specs/security-ci.md": 3400, "docs/specs/security-hosted.md": 3250, - "docs/specs/security-local.md": 6300, + "docs/specs/security-local.md": 6400, "docs/specs/security-remote.md": 7200, "docs/specs/security-supply-chain.md": 1650, "docs/specs/security.md": 2900, @@ -42,5 +42,5 @@ "docs/specs/tutorial.md": 1750, "docs/specs/vscode.md": 4500, "docs/specs/webgl-text.md": 750, - "docs/specs/website-docs.md": 3100 + "docs/specs/website-docs.md": 3300 } diff --git a/website/package.json b/website/package.json index a2401d997..9a5de7d9e 100644 --- a/website/package.json +++ b/website/package.json @@ -21,6 +21,7 @@ "@phosphor-icons/react": "^2.1.10", "ascii-splash": "0.6.1", "dormouse-lib": "workspace:*", + "posthog-js": "^1.438.24", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router": "^8.0.0", diff --git a/website/public/_routes.json b/website/public/_routes.json new file mode 100644 index 000000000..3085d6af4 --- /dev/null +++ b/website/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/ingest/*"], + "exclude": [] +} diff --git a/website/public/_worker.js b/website/public/_worker.js new file mode 100644 index 000000000..094fe00b7 --- /dev/null +++ b/website/public/_worker.js @@ -0,0 +1,46 @@ +/** + * The site's one server-side route: `/ingest/*`, the first-party path visit + * analytics send to, forwarded to PostHog's US Cloud. `_routes.json` beside it + * limits this Worker to that path, so every other request is the static site + * with `_redirects` as before (docs/specs/website-docs.md -> "Visit + * analytics"). + * + * Cloudflare Pages runs this file from the build output in advanced mode, so + * it works whatever directory the Pages project builds from; it is plain + * JavaScript because Pages reads no `_worker.ts`. The shape follows PostHog's + * Cloudflare reverse-proxy guide: assets (`/static/`, `/array/`) from the + * assets host, everything else to the ingestion host, cookies and + * authorization dropped, and the visitor's address passed on as + * `X-Forwarded-For`, without which every visitor would hash to Cloudflare's. + */ + +export const INGEST_PREFIX = "/ingest"; +const API_HOST = "us.i.posthog.com"; +const ASSET_HOST = "us-assets.i.posthog.com"; + +/** Where a request under `/ingest` goes upstream, or null for any other path. */ +export function upstreamUrl(requestUrl) { + const url = new URL(requestUrl); + if (url.pathname !== INGEST_PREFIX && !url.pathname.startsWith(`${INGEST_PREFIX}/`)) return null; + const path = url.pathname.slice(INGEST_PREFIX.length) || "/"; + const host = path.startsWith("/static/") || path.startsWith("/array/") ? ASSET_HOST : API_HOST; + return `https://${host}${path}${url.search}`; +} + +export default { + async fetch(request, env) { + const target = upstreamUrl(request.url); + if (target === null) return env.ASSETS.fetch(request); + const headers = new Headers(request.headers); + headers.delete("cookie"); + headers.delete("authorization"); + headers.set("X-Forwarded-For", request.headers.get("CF-Connecting-IP") ?? ""); + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + return fetch(target, { + method: request.method, + headers, + body: hasBody ? request.body : null, + redirect: "manual", + }); + }, +}; diff --git a/website/scripts/ingest-worker.test.js b/website/scripts/ingest-worker.test.js new file mode 100644 index 000000000..0e0701930 --- /dev/null +++ b/website/scripts/ingest-worker.test.js @@ -0,0 +1,47 @@ +// The Pages Worker in public/_worker.js: what `/ingest` forwards to PostHog, +// and that every other path stays the static site. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import worker, { upstreamUrl } from '../public/_worker.js'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('upstreamUrl', () => { + it('maps /ingest to the ingestion host and its assets to the assets host, query kept', () => { + expect(upstreamUrl('https://dormouse.sh/ingest/e/?ip=0&ver=1')).toBe('https://us.i.posthog.com/e/?ip=0&ver=1'); + expect(upstreamUrl('https://dormouse.sh/ingest/static/array.js')).toBe('https://us-assets.i.posthog.com/static/array.js'); + expect(upstreamUrl('https://dormouse.sh/ingest/array/phc_x/config')).toBe('https://us-assets.i.posthog.com/array/phc_x/config'); + expect(upstreamUrl('https://dormouse.sh/ingestion')).toBeNull(); + expect(upstreamUrl('https://dormouse.sh/hosted/')).toBeNull(); + }); +}); + +describe('the Worker', () => { + it('forwards the body with the visitor address, and drops cookies and authorization', async () => { + const upstream = vi.fn(async () => new Response('ok')); + vi.stubGlobal('fetch', upstream); + const request = new Request('https://dormouse.sh/ingest/e/', { + method: 'POST', + body: 'batch', + headers: { cookie: 'a=b', authorization: 'Bearer x', 'CF-Connecting-IP': '203.0.113.7', 'content-type': 'text/plain' }, + }); + await worker.fetch(request, { ASSETS: { fetch: vi.fn() } }); + const [url, init] = upstream.mock.calls[0]; + expect(url).toBe('https://us.i.posthog.com/e/'); + expect(init.method).toBe('POST'); + expect(await new Response(init.body).text()).toBe('batch'); + expect(init.headers.get('x-forwarded-for')).toBe('203.0.113.7'); + expect(init.headers.get('cookie')).toBeNull(); + expect(init.headers.get('authorization')).toBeNull(); + expect(init.headers.get('content-type')).toBe('text/plain'); + }); + + it('hands any other path to the static site', async () => { + const upstream = vi.fn(); + vi.stubGlobal('fetch', upstream); + const assets = vi.fn(async () => new Response('page')); + const request = new Request('https://dormouse.sh/hosted/'); + await worker.fetch(request, { ASSETS: { fetch: assets } }); + expect(assets).toHaveBeenCalledWith(request); + expect(upstream).not.toHaveBeenCalled(); + }); +}); diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index 36c679e82..a35c5ca1e 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -10,6 +10,7 @@ import { import { PlaygroundShellRegistry } from "../lib/playground-shells"; import { TutorialState } from "../lib/tutorial-state"; import { TutDetector } from "../lib/tut-detector"; +import { trackTutorialCompletion } from "../lib/site-analytics"; import { TutRunner } from "../lib/tut-runner"; import { POCKET_TUTORIAL_PROFILE } from "../lib/tut-items"; import { ChangelogRunner } from "../lib/changelog-runner"; @@ -109,6 +110,7 @@ export function PocketTerminalExperience({ useEffect(() => { let cancelled = false; + let stopCompletionTracking: (() => void) | null = null; async function loadWall() { const platform = await import("dormouse-lib/lib/platform"); @@ -132,6 +134,7 @@ export function PocketTerminalExperience({ } const tutorialState = new TutorialState(POCKET_TUTORIAL_PROFILE.sections); + stopCompletionTracking = trackTutorialCompletion(tutorialState, "pocket"); const detector = new TutDetector({ state: tutorialState, activityStore: registry, @@ -192,6 +195,7 @@ export function PocketTerminalExperience({ return () => { cancelled = true; + stopCompletionTracking?.(); spawnUnsubRef.current?.(); spawnUnsubRef.current = null; detectorRef.current?.dispose(); diff --git a/website/src/lib/hosted-ref.ts b/website/src/lib/hosted-ref.ts index 55796f0df..65186570a 100644 --- a/website/src/lib/hosted-ref.ts +++ b/website/src/lib/hosted-ref.ts @@ -1,7 +1,7 @@ /** * The `ref` a visit to the Hosted page arrived with: which Dormouse link sent - * it (`HOSTED_REFS` in `lib/src/lib/hosted-links.ts`). Nothing counts it yet, - * and nothing stores it: no cookie, no storage, no beacon. + * it (`HOSTED_REFS` in `lib/src/lib/hosted-links.ts`). The page's pageview + * carries it (`./site-analytics.ts`); nothing stores it. */ import { HOSTED_REF_PARAM, isHostedRef, type HostedRef } from "dormouse-lib/lib/hosted-links"; diff --git a/website/src/lib/site-analytics.test.ts b/website/src/lib/site-analytics.test.ts new file mode 100644 index 000000000..c717c5fb7 --- /dev/null +++ b/website/src/lib/site-analytics.test.ts @@ -0,0 +1,177 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options {"url": "https://dormouse.sh/hosted/"} + */ +import { gunzipSync } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { POCKET_TUTORIAL_PROFILE } from "./tut-items"; +import { TutorialState } from "./tutorial-state"; + +const POSTHOG = "posthog-js/dist/module.slim.no-external.js"; + +/** A fresh copy of the module, so its one client is unmade. */ +const fresh = async () => { + vi.resetModules(); + return import("./site-analytics"); +}; + +/** A posthog stand-in that records whether it was ever loaded, and what it captured. */ +function fakePosthog() { + const loaded = vi.fn(); + const capture = vi.fn(); + vi.doMock(POSTHOG, () => { + loaded(); + return { default: { init: vi.fn(), capture } }; + }); + return { loaded, capture }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function setSignals(signals: { globalPrivacyControl?: boolean; doNotTrack?: string | null }) { + for (const [key, value] of Object.entries(signals)) { + Object.defineProperty(navigator, key, { value, configurable: true }); + } +} + +afterEach(() => { + setSignals({ globalPrivacyControl: undefined, doNotTrack: null }); + vi.doUnmock(POSTHOG); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + localStorage.clear(); +}); + +describe("analyticsAllowed", () => { + it("is false under Global Privacy Control or Do Not Track, and true with neither", async () => { + const { analyticsAllowed } = await fresh(); + expect(analyticsAllowed({ globalPrivacyControl: true })).toBe(false); + expect(analyticsAllowed({ doNotTrack: "1" })).toBe(false); + expect(analyticsAllowed({ globalPrivacyControl: false, doNotTrack: "0" })).toBe(true); + expect(analyticsAllowed({})).toBe(true); + }); +}); + +describe("the loader", () => { + it.each([ + ["Global Privacy Control", { globalPrivacyControl: true }], + ["Do Not Track", { doNotTrack: "1" }], + ])("never downloads PostHog under %s", async (_name, signals) => { + setSignals(signals); + const ph = fakePosthog(); + const { trackPageview, trackEvent } = await fresh(); + trackPageview("readme"); + trackEvent({ name: "hosted plan clicked", plan: "annual" }); + await settle(); + expect(ph.loaded).not.toHaveBeenCalled(); + }); + + it("loads PostHog once, on the first event, with neither signal", async () => { + const ph = fakePosthog(); + const { trackPageview, trackEvent } = await fresh(); + expect(ph.loaded).not.toHaveBeenCalled(); + trackPageview(null); + trackEvent({ name: "hosted plan clicked", plan: "annual" }); + await settle(); + expect(ph.loaded).toHaveBeenCalledTimes(1); + expect(ph.capture.mock.calls).toEqual([ + ["$pageview", undefined], + ["hosted plan clicked", { plan: "annual" }], + ]); + }); +}); + +describe("what the real client sends and stores", () => { + it("posts the ref to /ingest, cookieless, and leaves nothing on the device", { timeout: 10_000 }, async () => { + const requests: { url: string; body: unknown }[] = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + const raw = init?.body instanceof ArrayBuffer ? Buffer.from(init.body) : Buffer.from(String(init?.body ?? "")); + requests.push({ url: String(url), body: JSON.parse((raw[0] === 0x1f ? gunzipSync(raw) : raw).toString()) }); + return new Response("{}", { status: 200 }); + })); + const cookie = Object.getOwnPropertyDescriptor(Document.prototype, "cookie")!; + const cookieWrites = vi.fn(); + Object.defineProperty(document, "cookie", { + configurable: true, + get: () => cookie.get!.call(document), + set: (value: string) => { + cookieWrites(value); + cookie.set!.call(document, value); + }, + }); + const setItem = vi.spyOn(Storage.prototype, "setItem"); + + const { trackPageview } = await fresh(); + trackPageview("readme"); + // posthog-js batches, flushing every few seconds; its timers are its own. + await vi.waitFor(() => expect(requests.length).toBeGreaterThan(0), { timeout: 8000, interval: 100 }); + delete (document as { cookie?: string }).cookie; + + for (const { url } of requests) expect(url.startsWith("/ingest/")).toBe(true); + const events = requests.flatMap(({ body }) => (body as { batch: { event: string; properties: Record }[] }).batch); + expect(events.map((e) => e.event)).toEqual(["$pageview"]); + expect(events[0].properties).toMatchObject({ + ref: "readme", + $cookieless_mode: true, + distinct_id: "$posthog_cookieless", + $process_person_profile: false, + }); + + expect(cookieWrites).not.toHaveBeenCalled(); + // posthog-js probes whether localStorage works by writing and removing one + // key; that probe is the only write, and nothing is left behind. + expect(new Set(setItem.mock.calls.map(([key]) => key))).toEqual(new Set(["__mplssupport__"])); + expect(localStorage.length).toBe(0); + expect(sessionStorage.length).toBe(0); + expect(document.cookie).toBe(""); + }); +}); + +describe("trackTutorialCompletion", () => { + const items = POCKET_TUTORIAL_PROFILE.sections.flatMap((s) => s.items.map((i) => i.id)); + + it("sends one event when this visit checks the last item, and again after a reset", async () => { + const ph = fakePosthog(); + const { trackTutorialCompletion } = await fresh(); + const state = new TutorialState(POCKET_TUTORIAL_PROFILE.sections); + const stop = trackTutorialCompletion(state, "pocket"); + for (const id of items.slice(0, -1)) state.markComplete(id); + await settle(); + expect(ph.capture).not.toHaveBeenCalled(); + state.markComplete(items[items.length - 1]); + state.resolveStarPrompt(); + await settle(); + expect(ph.capture.mock.calls).toEqual([["tutorial completed", { tutorial: "pocket" }]]); + state.reset(); + for (const id of items) state.markComplete(id); + await settle(); + expect(ph.capture).toHaveBeenCalledTimes(2); + stop(); + }); + + it("sends nothing for a tutorial finished on an earlier visit", async () => { + const ph = fakePosthog(); + const { trackTutorialCompletion } = await fresh(); + const earlier = new TutorialState(POCKET_TUTORIAL_PROFILE.sections); + for (const id of items) earlier.markComplete(id); + const restored = new TutorialState(POCKET_TUTORIAL_PROFILE.sections); + trackTutorialCompletion(restored, "pocket"); + restored.resolveStarPrompt(); + await settle(); + expect(ph.loaded).not.toHaveBeenCalled(); + }); +}); + +describe("off dormouse.sh", () => { + beforeEach(() => { + vi.stubGlobal("location", new URL("http://localhost:5173/hosted/")); + }); + + it("downloads nothing, so a dev server or a preview counts no visits", async () => { + const ph = fakePosthog(); + const { trackPageview } = await fresh(); + trackPageview(null); + await settle(); + expect(ph.loaded).not.toHaveBeenCalled(); + }); +}); diff --git a/website/src/lib/site-analytics.ts b/website/src/lib/site-analytics.ts new file mode 100644 index 000000000..ada29eb8e --- /dev/null +++ b/website/src/lib/site-analytics.ts @@ -0,0 +1,114 @@ +/** + * Visit analytics for dormouse.sh: PostHog in cookieless mode, sent through + * the site's own `/ingest` path (`public/_worker.js`), and only the events + * below. docs/specs/website-docs.md -> "Visit analytics" is the contract; + * Privacy says the same to visitors. + * + * Nothing here runs until the first event, and then only on dormouse.sh + * itself, with neither Global Privacy Control nor Do Not Track set: otherwise + * PostHog is never even downloaded. The import is the slim, no-external build, + * so autocapture, replay, surveys, and the rest are not in the bundle at all, + * and it never loads a script from anywhere. + */ +import type { PostHog, PostHogConfig } from "posthog-js/dist/module.slim.no-external.js"; +import type { HostedRef } from "dormouse-lib/lib/hosted-links"; +import type { TutorialState } from "./tutorial-state"; + +/** PostHog's public project key: public by design, it only lets a page send. */ +export const POSTHOG_PROJECT_KEY = "phc_AxHDxNuQ9uGy5AmejhxcZz3vNUPwZY2WgmCgW6WgNkTU"; + +/** The only host that counts: a dev server or a Pages preview sends nothing. */ +export const ANALYTICS_HOSTNAME = "dormouse.sh"; + +/** + * Every option chosen, not defaulted, so a posthog-js bump that changes a + * default changes nothing here. `cookieless_mode: "always"` is what keeps + * PostHog out of cookies and storage (it counts visitors with a daily hash + * on its servers, and the project setting must be on or it drops the events); + * `person_profiles: "never"` makes `identify` a no-op. + */ +export const POSTHOG_CONFIG: Partial = { + api_host: "/ingest", + ui_host: "https://us.posthog.com", + defaults: "2026-08-30", + cookieless_mode: "always", + disable_persistence: true, + persistence: "memory", + person_profiles: "never", + internal_or_test_user_hostname: null, + capture_pageview: false, + capture_pageleave: false, + autocapture: false, + rageclick: false, + capture_dead_clicks: false, + capture_heatmaps: false, + capture_exceptions: false, + capture_performance: false, + capture_webmcp: false, + disable_session_recording: true, + disable_surveys: true, + disable_product_tours: true, + disable_conversations: true, + disable_web_experiments: true, + disable_external_dependency_loading: true, + advanced_disable_flags: true, + save_campaign_params: false, + disable_scroll_properties: true, +}; + +/** What a route module may export as `handle`: `ownPageview` when it calls `trackPageview` itself. */ +export type SiteRouteHandle = { ownPageview?: boolean }; + +/** The named events, and nothing else. */ +export type SiteEvent = + | { name: "hosted plan clicked"; plan: "monthly" | "annual" | "founding" } + | { name: "tutorial completed"; tutorial: "desktop" | "pocket" }; + +type PrivacySignals = { globalPrivacyControl?: boolean; doNotTrack?: string | null }; + +/** False when the visitor's browser sends Global Privacy Control or Do Not Track. */ +export function analyticsAllowed(nav: PrivacySignals = navigator): boolean { + return nav.globalPrivacyControl !== true && nav.doNotTrack !== "1"; +} + +let client: Promise | null = null; + +function posthog(): Promise { + client ??= + typeof window === "undefined" || location.hostname !== ANALYTICS_HOSTNAME || !analyticsAllowed() + ? Promise.resolve(null) + : import("posthog-js/dist/module.slim.no-external.js").then( + ({ default: ph }) => { + ph.init(POSTHOG_PROJECT_KEY, POSTHOG_CONFIG); + return ph; + }, + () => null, + ); + return client; +} + +/** One pageview of the current address, with the Hosted page's `ref` when it has one. */ +export function trackPageview(ref?: HostedRef | "other" | null): void { + void posthog().then((ph) => ph?.capture("$pageview", ref ? { ref } : undefined)); +} + +export function trackEvent({ name, ...properties }: SiteEvent): void { + void posthog().then((ph) => ph?.capture(name, properties)); +} + +/** + * Sends `tutorial completed` when this visit checks the last item; a tutorial + * finished on an earlier visit, its progress restored, sends nothing. + */ +export function trackTutorialCompletion(state: TutorialState, tutorial: "desktop" | "pocket"): () => void { + const finished = () => { + const { done, total } = state.totalProgress(); + return total > 0 && done === total; + }; + let wasFinished = finished(); + return state.subscribe(() => { + const now = finished(); + if (now && !wasFinished) trackEvent({ name: "tutorial completed", tutorial }); + wasFinished = now; + }); +} diff --git a/website/src/pages/Hosted.test.tsx b/website/src/pages/Hosted.test.tsx index 687b7aea9..4fda66289 100644 --- a/website/src/pages/Hosted.test.tsx +++ b/website/src/pages/Hosted.test.tsx @@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../components/SiteHeader", () => ({ default: () =>
, STATIC_PAGE_HEADER_STYLE: {} })); vi.mock("dormouse-lib/site/DocsThemeControl", () => ({ default: () => null })); vi.mock("dormouse-lib/site/docs-paint", () => ({ useDocsThemedBody: () => {}, DOCS_HEADER_STYLE: {}, DOCS_PAINT_PROFILE: {} })); +const analytics = vi.hoisted(() => ({ trackPageview: vi.fn(), trackEvent: vi.fn() })); +vi.mock("../lib/site-analytics", () => analytics); const { default: Hosted } = await import("./Hosted"); const { COHORT_ENDPOINT, MAX_SHOWN_FOUNDERS } = await import("../lib/hosted-cohorts"); @@ -47,6 +49,8 @@ afterEach(() => { root = null; container = null; vi.unstubAllGlobals(); + analytics.trackPageview.mockClear(); + analytics.trackEvent.mockClear(); }); describe("what the Hosted page prerenders", () => { @@ -152,6 +156,22 @@ describe("a visit's ref", () => { root = null; } }); + + it("goes onto the page's one pageview, an unlisted value as other", async () => { + for (const [arrived, ref] of [ + ["/hosted/?ref=settings-voice#voice", "settings-voice"], + ["/hosted/?ref=someone%40example.test", "other"], + ["/hosted/", null], + ]) { + history.replaceState(null, "", arrived); + await mount(async () => new Response("{}", { status: 404 })); + expect(analytics.trackPageview.mock.calls).toEqual([[ref]]); + analytics.trackPageview.mockClear(); + act(() => root?.unmount()); + container?.remove(); + root = null; + } + }); }); /** Mounts the page with the cohort endpoint answering `body`. */ @@ -251,6 +271,11 @@ describe("the buy buttons", () => { expect(dialog?.textContent).toContain("You have not been charged"); }); + it("count the click, naming the plan", async () => { + await openFirst(); + expect(analytics.trackEvent.mock.calls).toEqual([[{ name: "hosted plan clicked", plan: HOSTED_MONTHLY.id }]]); + }); + it("close on Escape", async () => { await openFirst(); await act(async () => { diff --git a/website/src/pages/Hosted.tsx b/website/src/pages/Hosted.tsx index bc30893aa..92535dbd6 100644 --- a/website/src/pages/Hosted.tsx +++ b/website/src/pages/Hosted.tsx @@ -56,9 +56,13 @@ import { } from "../lib/hosted-pricing"; import { canonicalUrl, siteMeta, sitePath } from "../lib/site-meta"; import { takeVisitRef } from "../lib/hosted-ref"; +import { trackEvent, trackPageview, type SiteRouteHandle } from "../lib/site-analytics"; const PAGE_PATH = "/hosted"; +/** This page sends its own pageview, so it can carry the visit's `ref`. */ +export const handle: SiteRouteHandle = { ownPageview: true }; + const SECURITY_MODEL_URL = "https://github.com/diffplug/dormouse/blob/main/docs/specs/remote-security-model.md"; @@ -442,6 +446,10 @@ export default function Hosted() { const [cohort, setCohort] = useState({ seatsLeft: null, founders: null }); const [checkoutTodo, setCheckoutTodo] = useState(null); const closeTodo = useCallback(() => setCheckoutTodo(null), []); + const buy = useCallback((tier: Tier) => { + trackEvent({ name: "hosted plan clicked", plan: tier.id }); + setCheckoutTodo(tier); + }, []); useEffect(() => { const controller = new AbortController(); @@ -449,10 +457,9 @@ export default function Hosted() { return () => controller.abort(); }, []); - // Off the address bar on arrival. What it answers goes nowhere yet: this - // is where a visit count would read it. + // Off the address bar on arrival, and onto this visit's pageview. useEffect(() => { - takeVisitRef(); + trackPageview(takeVisitRef()); }, []); return ( @@ -475,8 +482,8 @@ export default function Hosted() { in the same order on a phone. */}
- - + +

diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index 395cfb1eb..915d04187 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -9,6 +9,7 @@ import { PlaygroundShellRegistry } from "../lib/playground-shells"; import { DESKTOP_TUTORIAL_PROFILE } from "../lib/tut-items"; import { TutorialState } from "../lib/tutorial-state"; import { TutDetector } from "../lib/tut-detector"; +import { trackTutorialCompletion } from "../lib/site-analytics"; import { TutRunner } from "../lib/tut-runner"; import { startAlertProgram } from "../lib/alert-programs"; import { ChangelogRunner } from "../lib/changelog-runner"; @@ -92,6 +93,7 @@ function PlaygroundDesktopExperience() { useEffect(() => { let cancelled = false; + let stopCompletionTracking: (() => void) | null = null; async function loadWall() { // Phone hydration briefly mounts this desktop prerender before reconciling media. if (getPreferredPlayground() === "pocket") return; @@ -126,6 +128,7 @@ function PlaygroundDesktopExperience() { disposeFsRef.current = fsHost.dispose; const tutorialState = new TutorialState(DESKTOP_TUTORIAL_PROFILE.sections); + stopCompletionTracking = trackTutorialCompletion(tutorialState, "desktop"); stateRef.current = tutorialState; const detector = new TutDetector({ state: tutorialState, @@ -215,6 +218,7 @@ function PlaygroundDesktopExperience() { return () => { cancelled = true; + stopCompletionTracking?.(); detectorRef.current?.dispose(); detectorRef.current = null; shellRegistryRef.current?.disposeAll(); diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index d03bd9249..d2ce50416 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -51,7 +51,8 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

We use this information to create and recognize your account, verify sign-in, connect methods you explicitly select, send requested sign-in codes, check on each request that your subscription is active, deliver the features you pay for, apply fair-use limits, prevent abuse, troubleshoot failures, and answer support requests. We discard provider access, refresh, and identity tokens after identity verification rather than storing them in your account.

Every request to Hosted carries your IP address and connection information, which we and our infrastructure providers use to deliver the request, secure the service, and limit request rates. Rate-limit records we store hold a keyed hash of the address, not the address itself, and are kept only briefly.

DiffPlug does not sell Hosted information, use it for targeted advertising, or use it to train AI models. The Natural Voice section describes ElevenLabs’ separate processing. Signing in or subscribing does not subscribe you to a newsletter. The account site uses necessary authentication and security cookies and does not load marketing analytics.

-

To see whether Hosted is working, we keep aggregate daily counts on our own servers, with no per-person analytics: computers approved and enrolled, Pocket sign-ins, push notifications delivered, Natural Voice requests answered, over the daily limit, or failed, and accounts that reached the daily voice limit. A count row holds only the day, what was counted, a fixed label (such as “voice” or “over the limit”), and the number; it carries no account id, email address, IP address, browser information, or text. Links to the Hosted page from Dormouse or this site carry a short label naming which link it was (such as the voice setting or the tutorial). The page removes it from the address bar when it loads, and we neither count nor store it. There are no analytics scripts or tracking cookies. Because we do not track you across sites or sell your information, browser Do Not Track and Global Privacy Control signals have nothing further to turn off, and we do not change our practices in response to them.

+

To see whether Hosted is working, we keep aggregate daily counts on our own servers, with no per-person analytics: computers approved and enrolled, Pocket sign-ins, push notifications delivered, Natural Voice requests answered, over the daily limit, or failed, and accounts that reached the daily voice limit. A count row holds only the day, what was counted, a fixed label (such as “voice” or “over the limit”), and the number; it carries no account id, email address, IP address, browser information, or text.

+

This website, dormouse.sh, counts its visits with PostHog. When a page loads, your browser sends PostHog, through dormouse.sh, the page’s address and title, the page that linked you there, and your browser, device type, language, time zone, and screen and window sizes. Links to the Hosted page from Dormouse or this site carry a short label naming which link it was (such as the voice setting or the tutorial); the page removes it from the address bar and adds it to that page’s visit. Clicking a plan’s buy button sends which plan, and finishing the playground tutorial sends which tutorial. Each request also carries your IP address and browser user agent. PostHog counts unique visitors without storing anything on your device: in its words, it uses “a privacy-preserving hash, calculated on PostHog’s servers” from your IP address, user agent, and this site with a random value that changes daily and is deleted once that day’s events are processed. It removes the IP address before storing the visit and does not look up your location. Nothing is kept in cookies or browser storage: PostHog only checks that storage works, with a test value it removes at once. No profile is built, and we never tell PostHog who you are. If your browser sends Global Privacy Control or Do Not Track, the site does not load PostHog at all. The Hosted account site, the Hosted Relay and Pocket, and Dormouse itself do not use PostHog.

}, { id: "legal-bases", title: "Our legal bases", body: <>

Where the EU or UK GDPR applies, our legal basis depends on the purpose:

@@ -61,11 +62,12 @@ export const PRIVACY_SECTIONS: PolicySection[] = [
  • Accounting, required tax records, and legally required disclosures: compliance with applicable legal obligations. Where an obligation arises only under non-EU or non-UK law, we assess an applicable basis, such as legitimate interests, rather than treating that foreign law alone as an EU or UK legal obligation.
  • Displaying your name and picture in the founders row: your consent. You can withdraw it in your account at any time; withdrawal does not affect processing that was lawful before you withdrew.
  • Optional price research: our legitimate interest in understanding demand and setting prices. Participation is voluntary and does not affect your subscription.
  • +
  • Counting visits to this website: our legitimate interest in knowing which pages and links bring people to Dormouse. Global Privacy Control or Do Not Track turns it off.
  • We need account and billing information to provide the corresponding services; without it we cannot create the account or fulfill the purchase. Public founder attribution and survey answers are optional.

    }, { id: "providers", title: "Who processes the information", body: <> -

    Cloudflare runs the account, Relay, and voice services. Neon stores the database. Postmark delivers sign-in emails and processes their recipients, contents, and delivery records. GitHub stores encrypted database-backup artifacts. Stripe processes payments and manages billing. ElevenLabs generates Natural Voice speech from spoken labels. These providers process information needed to provide their services; Stripe’s and ElevenLabs’ additional processing is described above.

    +

    Cloudflare runs the account, Relay, and voice services. Neon stores the database. Postmark delivers sign-in emails and processes their recipients, contents, and delivery records. GitHub stores encrypted database-backup artifacts. Stripe processes payments and manages billing. ElevenLabs generates Natural Voice speech from spoken labels. PostHog counts visits to this website, as described above, under PostHog’s privacy policy. These providers process information needed to provide their services; Stripe’s and ElevenLabs’ additional processing is described above.

    Push notifications travel through the push service your phone’s browser uses — Apple, Google, Mozilla, or Microsoft — which sees that a sealed notification was sent, not what it says.

    A sign-in provider you choose learns that you are authenticating with Dormouse and handles that interaction under its own policy. Our email and support providers also process messages you send us. Authorized DiffPlug personnel may access information to operate the service or respond to your request.

    We may disclose information when legally required, to investigate abuse, or to protect people and the service. If a business transfer affects your information, we will notify you of material changes to how it is handled.

    diff --git a/website/src/root.tsx b/website/src/root.tsx index 7f37c186c..6d87a7f81 100644 --- a/website/src/root.tsx +++ b/website/src/root.tsx @@ -1,13 +1,16 @@ -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, + useLocation, + useMatches, type MetaArgs, } from "react-router"; import { siteMeta } from "./lib/site-meta"; +import { trackPageview, type SiteRouteHandle } from "./lib/site-analytics"; import { FONT_READY_SCRIPT, FONT_READY_STYLE } from "./lib/font-ready"; import bodyFont from "@fontsource/ubuntu-mono/files/ubuntu-mono-latin-400-normal.woff2?url"; import displayFont from "@fontsource/ubuntu-sans-mono/files/ubuntu-sans-mono-latin-400-normal.woff2?url"; @@ -54,6 +57,16 @@ export function Layout({ children }: { children: ReactNode }) { ); } +/** One pageview per path, unless the route sends its own (`SiteRouteHandle`). */ +function usePageviews() { + const { pathname } = useLocation(); + const ownPageview = useMatches().some((match) => (match.handle as SiteRouteHandle | undefined)?.ownPageview); + useEffect(() => { + if (!ownPageview) trackPageview(); + }, [pathname, ownPageview]); +} + export default function Root() { + usePageviews(); return ; }