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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**

Expand Down
2 changes: 2 additions & 0 deletions docs/specs/security-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions docs/specs/website-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 90 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ packages:
- vscode-ext
- website
allowBuilds:
core-js: false
cpu-features: false
protobufjs: false
ssh2: false
Expand Down
8 changes: 8 additions & 0 deletions scripts/outbound-lint-selftest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
Expand Down Expand Up @@ -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"),
Expand Down
11 changes: 11 additions & 0 deletions scripts/outbound-lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
];
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion scripts/security-rule-ids.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"next": 280,
"next": 281,
"retired": [
265
]
Expand Down
4 changes: 2 additions & 2 deletions scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions website/public/_routes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"version": 1,
"include": ["/ingest/*"],
"exclude": []
}
46 changes: 46 additions & 0 deletions website/public/_worker.js
Original file line number Diff line number Diff line change
@@ -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",
});
},
};
Loading
Loading