diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 889091fec..ed2d03c60 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -6,7 +6,7 @@ **Output file:** `audit-hosted.md` -This is a code-and-specs audit of Hosted's four Workers — the frame origin, the account application, the relay that serves the Hosted Relay's account-scoped routes, Pocket, and the one-time rendezvous, and managed voice (`docs/specs/hosted.md`). You need no PAT — do not use one. The two pgstencil provenance checks below do read the GitHub API, but only a public repository, which the workflow's default `GITHUB_TOKEN` and the operator's own `gh` login both reach; if that API is unreachable, report those two checks as `UNVERIFIABLE`. +This is a code-and-specs audit of Hosted's five Workers — the frame origin, the account application, the relay that serves the Hosted Relay's account-scoped routes, Pocket, and the one-time Burrow route, the try Worker that serves the one-time page and client route, and managed voice (`docs/specs/hosted.md`). You need no PAT — do not use one. The two pgstencil provenance checks below do read the GitHub API, but only a public repository, which the workflow's default `GITHUB_TOKEN` and the operator's own `gh` login both reach; if that API is unreachable, report those two checks as `UNVERIFIABLE`. Read `docs/specs/hosted.md`, `docs/specs/one-time.md` (its "Wire contract", "Hosted rendezvous", and "Phone page"), `docs/specs/relay.md` (its "HTTP API", "Setup tokens and the pairing QR", "WebAuthn without a WebAuthn library", "Routing", "Web Push", and "State files", whose semantics the Hosted Relay keeps), `hosted/server/`, `hosted/src/`, `hosted/scripts/`, `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`, `website/public/_worker.js` and `website/public/_routes.json` (the site's forward to the account's founders row), `remote-lib-common/src/remote/one-time-wire.ts`, `remote-lib-common/src/remote/relay-common.ts`, `remote-lib-common/src/remote/relay-routing.ts`, `remote-lib-common/src/remote/enroll-code.ts`, `remote-lib-common/src/remote/wire.ts`, `scripts/e2e-lint.mjs`, `lib/package.json`, `remote-lib-common/src/remote/web-push.ts` and its test `remote-lib-common/test/web-push.test.mjs` with the vectors under `remote-lib-common/test/vectors/`, the Pocket build the relay serves (`lib/vite.pocket.config.ts`, `lib/pocket/`), the phone page it serves — `lib/vite.one-time.config.ts`, `lib/one-time/`, `lib/src/remote/one-time-app/`, and `lib/scripts/assert-pocket-worker.mjs` — and `.github/workflows/hosted-preview.yml` and `.github/workflows/hosted-production.yml` — `docs/specs/security-hosted.md`'s Deployment boundary quantifies over the preview and production paths, which live in those scripts and workflows rather than in the Worker. diff --git a/.github/workflows/hosted-production.yml b/.github/workflows/hosted-production.yml index 897483748..fda12a9fe 100644 --- a/.github/workflows/hosted-production.yml +++ b/.github/workflows/hosted-production.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: inputs: promote: - description: Deploy the account, relay, and voice Workers after verification + description: Deploy the Workers after verification type: boolean default: false permissions: @@ -97,7 +97,7 @@ jobs: run: pnpm --filter dormouse-hosted db:migrate && pnpm --filter dormouse-hosted db:validate env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - - name: Deploy the relay and verify its readiness and rendezvous, then the voice and account Workers + - name: Deploy the frame and try Workers, the relay with its readiness and rendezvous verified, then the voice and account Workers run: node hosted/scripts/production.mjs deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }} diff --git a/AGENTS.md b/AGENTS.md index 4832f8df9..ac1f7099a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ pnpm build # build lib, vscode extension, Pocket, website, and Hosted - **`vscode-ext/`** — VS Code extension wrapping the lib in a webview (esbuild; node-pty via forked child process; direct-path WebRTC via node-datachannel, every platform's addon in one VSIX) - **`website/`** — Marketing site (Vite) bundling part of the lib as an interactive demo on `FakePtyAdapter` - **`relay/`** — Selfhost coordinating Relay for remote control (Hono): accounts + passkey auth in local JSON files (no database), WebSocket routing between Clients and Burrows, serves the built Pocket app -- **`hosted/`** — Hosted's three Hono Workers: account and Better Auth (`hosted.dormouse.sh`), account-scoped Relay/Pocket and one-time rendezvous (`relay.`), voice (`voice.`); Postgres. +- **`hosted/`** — Hosted's four Hono Workers: account and Better Auth (`hosted.dormouse.sh`), account-scoped Relay/Pocket and one-time Burrow route (`relay.`), one-time page and client route (`try.`), voice (`voice.`); Postgres. - **`dor/`** — The `dor` CLI (stricli) staged onto the `PATH` of every Dormouse-launched terminal; talks to its host over a private control socket - **`remote-lib-common/`** — Security primitives + remote wire contract shared by `relay`, the Burrow module in `lib`, and the Pocket app (bare ES2022 — no DOM or Node types) - **`dor-lib-common/`** — Cross-platform external-process spawning (`spawnAndCapture`) shared by `dor` and the `lib` host. Despite the parallel names, the two `*-lib-common` packages are unrelated: `remote-lib-common` is remote security/wire, `dor-lib-common` is spawn plumbing. @@ -133,7 +133,7 @@ Eight sibling lints run in `pnpm test`. Seven enforce one invariant a spec state | `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) | `docs/specs/security-remote.md` -> "Credentials at rest" and "Network posture (self-hosted)": the installer controls binding all three of `deploy/local/install-{macos,windows,linux}`. | | `scripts/workflow-lint.mjs` (`pnpm lint:workflows`) | The workflow-file `FAIL IF` lines of `docs/specs/security-ci.md`: commit-pinned actions, triggers, effective permissions, where publish and tagging credentials are read. | | `scripts/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. No job has a PowerShell, so this is the Windows installer's only syntax gate. | -| `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) | The structural half of `docs/specs/security-remote.md` -> "Remote Control": one Noise suite with no selector, one ACL writer, no JavaScript curve, no legacy relay discriminant, no Relay-side protocol-v1 type, no one-time reader in the Relay or `BurrowRuntime`, no store in the one-time phone, no presence window outside the Burrow, no checked-in service worker, no optional field on a ciphertext or transcript; and `docs/specs/security-hosted.md`: no frame read in Hosted's one-time room, or decoded or kept in its `RelayRoom`. | +| `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) | The structural half of `docs/specs/security-remote.md` -> "Remote Control": one Noise suite with no selector, one ACL writer, no JavaScript curve, no legacy relay discriminant, no Relay-side protocol-v1 type, no one-time reader in the Relay or `BurrowRuntime`, no store in the one-time phone, no one-time page or phone route in Hosted's relay Worker, no presence window outside the Burrow, no checked-in service worker, no optional field on a ciphertext or transcript; and `docs/specs/security-hosted.md`: no frame read in Hosted's one-time room, or decoded or kept in its `RelayRoom`. | `scripts/spec-lint-selftest.mjs` plants one defect per finding check in the spec lint. The `deploy`, `e2e`, `loopback`, and `outbound` lints carry self-tests that mutate each rule in whichever direction it points: a present-control rule has its control deleted (and, for exact-count rules, a copy added), a `forbidden` rule has the banned text appended. `scripts/e2e-lint-selftest.mjs` is mostly the second kind; `scripts/deploy-lint-selftest.mjs` mostly the first. Either way the lint must go red. **A rule added to one of these lints without its self-test case is not enforced** — it is a claim that something is checked. They share plumbing, and only that, through `scripts/lint-kit.mjs`, which re-runs each in a sandbox copy of the tree: **a self-test must never write the checkout**. The root `pnpm test` runs them all through `scripts/lint-selftest-isolation.test.mjs`, which fails if one does. `scripts/installer-verify-test.mjs` (also `pnpm lint:deploy`) runs the installer shell helpers lint can only read, extracted from the shipped files; `scripts/ps1-cmdlet-lint-selftest.mjs` carries the `ps1-cmdlet` lint's mutations. `scripts/workflow-lint.test.mjs` plants each workflow-lint violation in an in-memory copy of the files. `pnpm test` also runs `scripts/clamp-issue-body-selftest.mjs`, the test for `scripts/clamp-issue-body.mjs` (the helper the audit workflows use to keep an issue body postable); it lives at the repo root because its callers do. A stale seed in `.github/audit/canaries/` fails `scripts/security-audit-canary.test.mjs`. diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md index cc7ce1eb6..fa2ada38c 100644 --- a/docs/specs/deploy.md +++ b/docs/specs/deploy.md @@ -19,7 +19,7 @@ Source of truth: `create_release` in `scripts/sign-and-deploy.sh`; `publish-vsco ## Release checklist -**Must ship a desktop release that carries, or changes, the one-time link version only after Hosted production serves that version** — its rendezvous routes and its `/connect/` page (`docs/specs/one-time.md` -> "Link"): run `.github/workflows/hosted-production.yml` first and confirm its live verification passed, `oneTimeSmoke` included. **Hosted must never stop serving a link version a shipped Burrow emits.** +**Must ship a desktop release that carries, or changes, the one-time link version only after Hosted production serves that version** — its rendezvous routes and its `/connect/` page on `HOSTED_ONE_TIME_ORIGIN` (`docs/specs/one-time.md` -> "Link"): run `.github/workflows/hosted-production.yml` first and confirm its live verification passed, `oneTimeSmoke` included. **Hosted must never stop serving a link version a shipped Burrow emits**, the relay's redirect for a link at its old origin included (`docs/specs/one-time.md` -> "Links from older builds"). A release that changes remote control or Hosted runs the red team first (`docs/specs/security-audit.md` -> "Red team"). diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index f64b7dd0d..b06ba44d9 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -1,24 +1,25 @@ # Dormouse Hosted accounts > - See `docs/specs/glossary.md` for Burrow, Client, Relay, and Session vocabulary. -> - Owns the Hosted account application, the Hosted Relay's account-scoped routes and sockets, and the deployment of Hosted's four Workers. The one-time rendezvous the relay Worker serves belongs to `docs/specs/one-time.md` -> "Hosted rendezvous"; the Relay's shared route and routing semantics to `docs/specs/relay.md` -> "HTTP API" and "Routing"; remote authorization to `docs/specs/remote-security-model.md`. +> - Owns the Hosted account application, the Hosted Relay's account-scoped routes and sockets, and the deployment of Hosted's five Workers. The one-time rendezvous the relay Worker serves belongs to `docs/specs/one-time.md` -> "Hosted rendezvous"; the Relay's shared route and routing semantics to `docs/specs/relay.md` -> "HTTP API" and "Routing"; remote authorization to `docs/specs/remote-security-model.md`. ## Application boundary -**Must serve Hosted as four Workers from `hosted/`:** +**Must serve Hosted as five Workers from `hosted/`:** | Worker | Origin | Serves | Holds | |---|---|---|---| | `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, Natural Voice on or off, the Relay's account routes ("Burrow enrollment"), billing, Stripe's webhook, and the founders row ("Billing") | the login cookie, auth secrets, Hyperdrive, the approval rate limit, a binding to the relay's `RelayRoom`, the Stripe bindings | -| `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, `RelayRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | +| `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time Burrow route (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `RelayRoom`, a binding to the try Worker's `OneTimeRoom`, the mint, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `TRY_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | +| `dormouse-try` | `https://try.dormouse.sh` | the one-time phone page and client route (`docs/specs/one-time.md` -> "Phone page") | `OneTimeRoom`, the join rate limit, its assets | | `dormouse-voice` | `https://voice.dormouse.sh` | speak, a token's own delete, and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | | `dormouse-frames` | `https://*.dormouseusercontent.com` | a frame origin's own files (`docs/specs/pocket-app.md` -> "Frame origin") | its assets | -Every Worker but `dormouse-frames` answers `/api/health` and `/api/ready`, 404s anything else under its non-page prefixes (`/api`, and the relay's `/ws` too), `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. `/api/ready` answers 200 once a query its role must be granted runs through its Hyperdrive, else a bare 503; it reads no row and has no rate limit. The account falls back to its SPA assets, the relay to Pocket's. Origin and binding isolation: `docs/specs/security-hosted.md` -> "Origin boundary" (rationale). +Every Worker but `dormouse-frames` answers `/api/health` and, but `dormouse-try`, which holds no database, `/api/ready`, 404s anything else under its non-page prefixes (`/api`, and the relay's `/ws` and `/connect` too), `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. `/api/ready` answers 200 once a query its role must be granted runs through its Hyperdrive, else a bare 503; it reads no row and has no rate limit. The account falls back to its SPA assets, the relay to Pocket's, the try Worker to nothing. Origin and binding isolation: `docs/specs/security-hosted.md` -> "Origin boundary" (rationale). **Must keep serving `/account` on the account origin**: every desktop since v1.2.0 opens it by a hard-coded path. -**Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding, one per Worker. +**Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding, one per Worker that binds one. **Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user columns and `owner_access` view, the relay inserts approved voice tokens ("Managed voice"), and both add to the daily counts ("Metrics"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`). @@ -347,7 +348,7 @@ Source of truth: `hosted/server/metric-labels.ts`; `recordMetric` and `authMetri **Must run local development with `dor tool hosted` inside Dormouse.** Its loopback `http://localhost:` origin serves Vite and Node auth on a disposable development database, with the Worker's account routes (`accountRoutes`) and cron, but never the voice origin's; billing runs on StripeDev, which takes no card. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. **It also runs the relay Worker on that database, with this origin as `ACCOUNT_ORIGIN`** (`docs/specs/one-time.md` -> "Dev loop"). -**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Only test entries inject deterministic Better Auth. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. +**Must verify the production Worker bundles and run the consumer's integration suite before release.** Only test entries inject deterministic Better Auth. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. **Must isolate test stacks from development and concurrent runs, remove containers and volumes on teardown, and reap abandoned stacks only after their owner exits.** @@ -359,7 +360,7 @@ Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `host **Must deploy only verified same-repository PR merge revisions touching Hosted or its shared build inputs, on a PR labeled `hosted-preview`.** Drafts qualify; forks receive no deployment credentials. -**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive all three share, and a Neon branch from an empty dedicated preview project**, all reused until the PR closes or loses the label. That Hyperdrive connects as the branch's owner, so no preview exercises the restricted roles; `hosted/server/tests` binds the relay and voice to them (rationale). Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, or ElevenLabs. **Must give each relay preview its own Durable Object namespaces, each preview preview-only rate-limit namespaces, and wire each preview to its own PR's siblings** (the account's `RELAY_ROOM`, the relay's `ACCOUNT_ORIGIN`). **Must derive every preview secret from `PREVIEW_AUTH_SECRET` and its Worker's name**, the relay's VAPID pair included, so no production key reaches a preview and subscriptions survive the PR's redeploys. +**Must isolate each PR in four persistent workers.dev Workers (`dormouse-{hosted,relay,voice,try}-pr-N`), one uncached Hyperdrive all but the try Worker share, and a Neon branch from an empty dedicated preview project**, all reused until the PR closes or loses the label. That Hyperdrive connects as the branch's owner, so no preview exercises the restricted roles; `hosted/server/tests` binds the relay and voice to them (rationale). Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, or ElevenLabs. **Must give each relay and try preview its own Durable Object namespaces, each preview preview-only rate-limit namespaces, and wire each preview to its own PR's siblings** (the account's `RELAY_ROOM`, the relay's `ONE_TIME_ROOM`, `ACCOUNT_ORIGIN`, and `TRY_ORIGIN`). **Must derive every preview secret from `PREVIEW_AUTH_SECRET` and its Worker's name**, the relay's VAPID pair included, so no production key reaches a preview and subscriptions survive the PR's redeploys. **Must sweep before provisioning, never at close:** each preview deploy first deletes every closed or unlabeled PR's preview (rationale). @@ -369,11 +370,11 @@ Source of truth: `.github/workflows/hosted-preview.yml`; `previewConfig` / `prev ## Production releases -**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `productionConfig` holds each config to its Worker's pinned name, origin, and lone custom domain, and the relay's `ACCOUNT_ORIGIN` to the account's origin; `preflight`, before the backup, checks each Worker's Hyperdrive and its own secret names (`docs/specs/security-hosted.md` -> "Deployment boundary") — names only, as Cloudflare exposes no value. **Must back up, encrypt, decrypt, and restore-test before applying migrations**, uploading only the encrypted archive. **Must deploy relay, voice, then account, stopping at a failure**; the relay must pass its revision check, `/api/ready`, push config, and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. +**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `productionConfig` holds each config to its Worker's pinned name, origin, and lone custom domain, and the relay's `ACCOUNT_ORIGIN` and `TRY_ORIGIN` to those Workers' origins; `preflight`, before the backup, checks each Worker's Hyperdrive and its own secret names (`docs/specs/security-hosted.md` -> "Deployment boundary") — names only, as Cloudflare exposes no value. **Must back up, encrypt, decrypt, and restore-test before applying migrations**, uploading only the encrypted archive. **Must deploy frames, try, relay, voice, then account, stopping at a failure**; the try Worker must pass its revision check and `oneTimePageSmoke` before the relay deploys, and the relay its revision check, `/api/ready`, push config, and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. -**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. A deploy restarts every room, dropping links still waiting or mid-handshake, and every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks all three Workers once the account has deployed. +**Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. A try deploy restarts every room, dropping links still waiting or mid-handshake, and a relay deploy every relay socket, which its end reconnects; a session already on its direct path never touches Hosted. Live verification checks every Worker once the account has deployed. -**Never replay a smoke's POST after a transport failure.** Production repeats only the relay and voice smokes (rationale). +**Never replay a smoke's POST after a transport failure.** Production repeats only the relay, voice, and try smokes (rationale). **Must record an immutable annotated hosted/YYYY-MM-DD tag only after live verification**, naming the deployed commit and verification run and attempt; retries are idempotent and redeployments get new tags. Code rollback never reverses migrations. diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 5656df587..dd7d32349 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -19,12 +19,13 @@ History sweep (sources checked 2026-09-22): Three origins (decided 2026-09-30): -- Pocket (staged for the relay origin's root) and the `/connect/` page render untrusted terminal output. Script running on the account's origin could make any request the login cookie authorizes and read the answer, so `/connect/` moved to `relay.dormouse.sh` and the account kept its origin. +- Pocket (staged for the relay origin's root) and the `/connect/` page render untrusted terminal output. Script running on the account's origin could make any request the login cookie authorizes and read the answer, so both left the account's origin. +- A fourth origin, `try.dormouse.sh` (decided 2026-11-02): `docs/specs/one-time.rationale.md` -> "Phone page". It is a Worker rather than a second hostname on the relay so the 421 gate keeps one origin per Worker and the page's origin reaches no database or secret. `OneTimeRoom` moved with the client route, so a relay deploy no longer drops a room, the try Worker deploys first in production and previews alike, and the dev loop's rooms need no Postgres. - Sibling origins under `dormouse.sh` are same-site, not same-origin: `SameSite=Lax` does not stop a browser from attaching the account's cookie to a request a `relay.` or `voice.` page makes to `hosted.`. The exact-`Origin` check on every cookie route is what refuses those requests. ## Production releases -- Deploy order (2026-09-30): the account's `v2` deletes the `OneTimeRoom` namespace the relay's `v1` replaces, and a deployed migration is a rollback floor. Deploying the account first would make the deletion permanent before the replacement is known to deploy; with the relay first, a failed relay deploy stops the release before the account changes. +- Deploy order (2026-09-30): the account's `v2` deleted the `OneTimeRoom` namespace the relay's `v1` replaced, and a deployed migration is a rollback floor. Deploying the account first would have made the deletion permanent before the replacement was known to deploy. The relay's `v3` repeats the shape for the try Worker's `v1` (2026-11-02): the implementer deploys and smokes first, since a binding names a class that must exist, and the relay redirects to a page that must be served. - Relay smoke before the account (2026-10-01): a relay deploy can succeed while its custom domain or rendezvous does not serve, and the full smoke runs only after the account deployed, so by then `v2` had already deleted the old room. Smoking the relay right after its deploy keeps the deletion behind a proven replacement. - Relay readiness before the account (2026-10-03): the relay connects as `dormouse_relay`, and a grant the role lacks fails only the queries that need it, which the revision, push, and rendezvous checks never run. `/api/ready` runs the session lookup's shape, so a role that cannot log in, or lacks that lookup's grants, stops the release before the voice or account deploys; the rest of the grants are pinned by `hosted/server/tests/runtime-roles.test.ts`, not the release. - Smoke attempts (2026-09-30): the first release after the split attaches `relay.dormouse.sh` and `voice.dormouse.sh` as new custom domains, and a new certificate can take longer to issue than the health GET's six five-second retries. Repeating a relay or voice smoke sends only GETs and WebSockets on a fresh room, so it replays no POST; the account smoke's POSTs keep it at one attempt. diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index 138320ba9..b12883e3c 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -36,6 +36,8 @@ sequenceDiagram **One link per connection, one phone per link.** Exactly `/connect/#...`: the fragment is positional, dot-delimited, carries no field names, is exactly 79 characters, and never reaches a server. +**The link names the page's own origin, `HOSTED_ONE_TIME_ORIGIN`, never Pocket's, and the Burrow never connects to it.** A dev Hosted build names its relay origin, which redirects ("Links from older builds"), since no second origin is baked. + | Field | Encoding, exact length | Purpose | | --- | --- | --- | | `v` | literal `1`, one character | E2E wire version; any other value is rejected, never negotiated | @@ -50,11 +52,11 @@ sequenceDiagram Which desktop release may carry a link version: `docs/specs/deploy.md` -> "Release checklist". -Source of truth: `remote-lib-common/src/security/one-time-link.ts`. +Source of truth: `remote-lib-common/src/security/one-time-link.ts`; `oneTimeLinkOrigin` in `lib/src/host/relay-origin.ts`. ## Wire contract -**A separate frame family, never an `E2eKind`**, so neither the Relay nor `BurrowRuntime` has a reader for it. Two WebSocket routes on the one-time origin: `ONE_TIME_WS_ROUTES.burrow` (`/api/one-time/burrow`) mints a room, and `ONE_TIME_WS_ROUTES.client` (`/api/one-time/client?room=`) joins one. **Must send each message as one JSON frame with exact keys**, forwarded verbatim by the room: +**A separate frame family, never an `E2eKind`**, so neither the Relay nor `BurrowRuntime` has a reader for it. Two WebSocket routes: `ONE_TIME_WS_ROUTES.burrow` (`/api/one-time/burrow`) on the relay origin mints a room, and `ONE_TIME_WS_ROUTES.client` (`/api/one-time/client?room=`) on the page's origin joins one. **Must send each message as one JSON frame with exact keys**, forwarded verbatim by the room: | Frame | Direction | Shape | | --- | --- | --- | @@ -87,7 +89,7 @@ Source of truth: `remote-lib-common/src/remote/one-time-wire.ts`; `parseOneTimeF **`OneTimeRuntime` is single-use: one rendezvous socket, one one-use keypair, one phone, one guess, and at most one session.** It opens once and ends once, and nothing resumes. Its states are `OneTimeState`, its endings `OneTimeEndReason`. -- **Must open the socket through the host's factory with no `Origin` header**, on the origin's Burrow route. +- **Must open the socket through the host's factory with no `Origin` header**, on the relay origin's Burrow route. - **The first message must be one `OneTimeRoomFrame`, the keypair minted and the frame read within `ONE_TIME_OPEN_TIMEOUT_MS`**, else `unreachable`. The link's expiry is the earlier of the runtime's own `now + ONE_TIME_LINK_TTL_MS` and the room's `expiresAt`, floored to whole seconds. - **Must discard `RELAY_PONG` keepalive replies and guard and count every other later message** after the [length check](#wire-contract): past `MAX_ONE_TIME_FORWARDED` the runtime stops reading the room. - Reserving the link: `docs/specs/remote-security-model.md` -> "One-time connection". @@ -116,16 +118,16 @@ Source of truth: `OneTimeClient` in `lib/src/remote/client/one-time-client.ts`. ## Hosted rendezvous -The relay Worker (`https://relay.dormouse.sh`; `docs/specs/hosted.md` -> "Application boundary") serves both routes, and one `OneTimeRoom` Durable Object per room carries the frames. **Never parse, store, or log a forwarded frame**: the room bounds a frame by its raw length and its count alone, and forwards the string verbatim (rationale). +The relay Worker serves the Burrow route and the try Worker the client route (`docs/specs/hosted.md` -> "Application boundary"), and one `OneTimeRoom` Durable Object per room, which the try Worker implements and the relay binds, carries the frames. **Never parse, store, or log a forwarded frame**: the room bounds a frame by its raw length and its count alone, and forwards the string verbatim (rationale). | Route | Admits | Refuses | | --- | --- | --- | | `ONE_TIME_WS_ROUTES.burrow` | an upgrade with no `Origin` header | 426 without an upgrade, 403 on any `Origin`, 429 past `ONE_TIME_MINT_LIMIT` | -| `ONE_TIME_WS_ROUTES.client` | an upgrade whose `Origin` is exactly `APP_ORIGIN`, naming exactly one E2E id as `room` | 426 without an upgrade, 403 on any other or no `Origin`, 400 on a missing, repeated, or malformed room, 429 past `ONE_TIME_JOIN_LIMIT` | +| `ONE_TIME_WS_ROUTES.client` | an upgrade whose `Origin` is exactly the try Worker's `APP_ORIGIN`, naming exactly one E2E id as `room` | 426 without an upgrade, 403 on any other or no `Origin`, 400 on a missing, repeated, or malformed room, 429 past `ONE_TIME_JOIN_LIMIT` | - **Must mount after the bindings mapper and the 421 gate**, and never read a cookie, reach Hyperdrive, or call auth. The Worker mints each room id from 16 random bytes and hands the room a fresh request carrying only the upgrade and the room id, never the caller's headers. - **The `Origin` rules are abuse control, never authorization** (rationale). -- **Both limits key on `cf-connecting-ip`**: an IPv6 address by its /64, an IPv4-mapped one (`::ffff:0:0/96`, however spelled) by its IPv4, and a missing one as `local` (rationale); `hosted/wrangler.relay.jsonc` sets them. +- **Both limits key on `cf-connecting-ip`**: an IPv6 address by its /64, an IPv4-mapped one (`::ffff:0:0/96`, however spelled) by its IPv4, and a missing one as `local` (rationale); the Worker's own Wrangler config sets each. - **The room's state is the Burrow socket's hibernation attachment** — `expiresAt`, `joined`, and a count of every frame received — never memory, so a hibernated room keeps its join and its count. `RELAY_PING` is answered by the runtime's auto-response and never wakes, forwards, or counts. A room's life, each step one event on the object: @@ -138,32 +140,43 @@ A room's life, each step one event on the object: **Every ending deletes the alarm and the room's storage**, so a finished room holds nothing. -Source of truth: `oneTimeRoutes` in `hosted/server/one-time.ts`; `OneTimeRoom` in `hosted/server/one-time-room.ts`. +Source of truth: `oneTimeBurrowRoute` and `oneTimeClientRoute` in `hosted/server/one-time.ts`; `OneTimeRoom` in `hosted/server/one-time-room.ts`. ## Phone page -The relay Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): `OneTimeApp`, refusing a browser without X25519 or `RTCPeerConnection`. +The try Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): `OneTimeApp`, refusing a browser without X25519 or `RTCPeerConnection`. **Never serve it on an origin holding a paired phone's keys**: it renders what a stranger's link sends (rationale). - **Must take the fragment and erase it with `history.replaceState` before the first render**, parsed or not, and reload on `hashchange` (rationale). - **Never open a socket before the Connect tap**: the tap builds the client and runs `connectOnce`, so a link-preview crawler spends nothing. - Its ICE servers: `docs/specs/remote-network.md` -> "Anywhere". - **Must show the Surfaces Pocket shows, with the same wall**, Tools and pages included (`docs/specs/pocket-app.md` -> "Frame origin"). -- **Must be ephemeral to the person using it: never persist anything of the page's own**: no storage, IndexedDB, worker, push, or cookie, though Pocket keeps its own on the same origin. A framed page's own origin may leave a worker and what the page stored, under a salt held only in memory, so nothing can reach them again until the browser evicts them. `scripts/e2e-lint.mjs` holds the page to the client's store rule. +- **Must be ephemeral to the person using it: never persist anything of the page's own**: no storage, IndexedDB, worker, push, or cookie. A framed page's own origin may leave a worker and what the page stored, under a salt held only in memory, so nothing can reach them again until the browser evicts them. `scripts/e2e-lint.mjs` holds the page to the client's store rule. - **Must mount the wall only on `ok`**; every ending closes the client and releases the adapter. - **The label the page sends is `oneTimeDeviceLabel`, never Pocket's `deviceLabel`**, and always a member of `ONE_TIME_DEVICE_LABELS` (rationale). -**Build.** `build:one-time` in `lib/package.json` builds `lib/one-time/` into `lib/dist-one-time` under base `ONE_TIME_PAGE_PATH`, then runs `assertPocketShell --one-time`: scripts under `/connect/assets/`, links under `/connect/`, nothing inline. Hosted's `build` runs it first, and `stageRelay` empties the relay's assets directory, copies the page to `hosted/dist/relay/connect/` beside Pocket at the root, and checks the copy. +**Build.** `build:one-time` in `lib/package.json` builds `lib/one-time/` into `lib/dist-one-time` under base `ONE_TIME_PAGE_PATH`, then runs `assertPocketShell --one-time`: scripts under `/connect/assets/`, links under `/connect/`, nothing inline. Hosted's `build` runs it first, and `stageTry` empties the try Worker's assets directory, copies the page to `hosted/dist/try/connect/`, and checks the copy. -**Serving.** The relay Worker answers `/connect`, `/connect/`, and `/connect/assets/*` from its assets, with no SPA fallback; an HTML answer or an unhashed name under `/connect/assets/`, and any other path under `/connect/`, is a 404. Everything under `/connect` carries `oneTimePagePolicy` (`hosted/server/headers.ts`), built from the relay's `APP_ORIGIN`. Every other relay response carries Pocket's policy or `RUNS_NOTHING_POLICY` (`docs/specs/security-hosted.md` -> "Relay boundary"). +**Serving.** The try Worker answers `/connect`, `/connect/`, and `/connect/assets/*` from its assets, with no SPA fallback; an HTML answer or an unhashed name under `/connect/assets/`, and any other path under `/connect/`, is a 404. Everything under `/connect` carries `oneTimePagePolicy` (`hosted/server/headers.ts`), built from its `APP_ORIGIN`, and every other response `RUNS_NOTHING_POLICY`. - **An `APP_ORIGIN` that is not exactly `scheme://host[:port]` of plain host characters gets `RUNS_NOTHING_POLICY` instead**: the URL parser admits `;`, `,`, and `'` in a host. - **The sandbox keeps `allow-same-origin`**, and Chrome's warning about the pair stands (rationale). -Source of truth: `OneTimeApp` in `lib/src/remote/one-time-app/OneTimeApp.tsx`; `stageRelay` in `hosted/scripts/stage-relay.mjs`; `oneTimePageRoutes` in `hosted/server/one-time.ts`; `relayRules` in `hosted/server/headers.ts`. +Source of truth: `OneTimeApp` in `lib/src/remote/one-time-app/OneTimeApp.tsx`; `stageTry` in `hosted/scripts/stage-try.mjs`; `oneTimePageRoutes` in `hosted/server/one-time.ts`; `tryRules` in `hosted/server/headers.ts`. + +### Links from older builds + +Every release before the page's move shows `https://relay.dormouse.sh/connect/#…`, and the phone of that link joins a room minted on the relay's Burrow route, as every release's is. + +- **The relay must answer `/connect` and `/connect/` with a 308 to `TRY_ORIGIN` + `ONE_TIME_PAGE_PATH`, and nothing else under `/connect/`**: no page, no shell, and no client route, so nothing of a stranger's renders beside Pocket from the release that serves the try Worker on (rationale). +- A browser carries the fragment across the redirect, and the prologue binds no origin, so the link completes on the page's origin unchanged. +- **Must keep the redirect while a shipped Burrow may show such a link** (`docs/specs/deploy.md` -> "Release checklist"); it renders nothing, so no grace period ends it. +- A phone that loaded the old page before the deploy finds its client route gone at the Connect tap, and reads generic copy, like any lost room. + +Source of truth: `oneTimePageMoved` in `hosted/server/one-time.ts`. ## Dev loop -**`dor tool one-time` runs the rendezvous and page on loopback, without Postgres**, at `PORT` or a fixed default, since a Burrow build bakes the origin in. Its config carries no route or production secret. It prints the dev Burrow build variables (`DORMOUSE_RELAY_ORIGIN`, `DORMOUSE_RELAY_IS_HOSTED=1`; `docs/specs/relay.md` -> "Relay origin"). **Started by `dor tool hosted`, it reads that loop's development database and announces no page**, since that Tool frames the account origin (`docs/specs/hosted.md` -> "Development and release"). +**`dor tool one-time` runs the rendezvous and page on loopback, without Postgres**: the relay Worker at `PORT` or a fixed default, since a Burrow build bakes that origin in, and the try Worker on the next port, sharing its room through Wrangler's local registry. Their configs carry no route or production secret. It prints the dev Burrow build variables (`DORMOUSE_RELAY_ORIGIN`, `DORMOUSE_RELAY_IS_HOSTED=1`; `docs/specs/relay.md` -> "Relay origin"). **Started by `dor tool hosted`, it reads that loop's development database and announces no page**, since that Tool frames the account origin (`docs/specs/hosted.md` -> "Development and release"). Source of truth: `hosted/scripts/dev-one-time.mjs`. diff --git a/docs/specs/one-time.rationale.md b/docs/specs/one-time.rationale.md index 019b5ef81..225500206 100644 --- a/docs/specs/one-time.rationale.md +++ b/docs/specs/one-time.rationale.md @@ -18,12 +18,20 @@ Evidence for `docs/specs/one-time.md`, keyed by its headings. ## Phone page +**Why the page has an origin of its own.** Pocket's origin holds a paired phone's records and keys, and the page renders bytes from whoever made the link, so sharing an origin put a future XSS in shared rendering one stranger's link from those keys, with no pairing. No path to it existed when the page moved (`oneTimePagePolicy`, no HTML sink in the shared rendering); the move removes the shared origin as a condition. The page needs no Pocket storage or WebAuthn, so it gives up nothing. + **Why the sandbox keeps `allow-same-origin`.** Without it the page runs in an opaque origin, so its WebSocket handshake carries `Origin: null`, which the client route refuses. Chrome logs "An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing" for the pair even on a top-level document (measured in agent-browser's Chrome, 2026-09-28); a top-level document has no parent to escape through, and the sandbox still withholds popups, forms, modals, and downloads. It was the one console entry on the ready, expired, and invalid screens; no directive reported a violation, and a tap's socket to the client route and a WebAssembly compile both ran. **Why the page reloads on `hashchange`.** Opening a link in the tab already showing `/connect/` changes only the fragment, which a browser treats as a same-document jump: measured in agent-browser's Chrome, 2026-09-28, the page stayed on its "invalid" screen with the new link left in the address bar. **Why the page names the device rather than borrowing Pocket's label.** Pocket names its install mode, "Dormouse Pocket (browser)" or "(Home Screen)", because one phone can hold two Client identities. The one-time page holds none, and the end-to-end run (standalone harness, 2026-09) showed the laptop's approval modal calling a one-time phone "Dormouse Pocket (browser)", an app it never opened. A device the page cannot name falls to "Phone browser", which is also what the Burrow shows for any label outside the set. +## Links from older builds + +**Why a redirect rather than the old page for a grace period.** A browser applies the request's fragment to a `Location` that has none (RFC 9110 §10.2.2), and `parseOneTimeLinkUrl` reads only the URL the page arrived at, so an older build's link needs nothing from the relay's origin but the redirect: no window remains in which the old page has to render. Measured in Chrome, driving a real 308 from a local relay Worker to a local try Worker, 2026-11-02. + +**Why 308 with `no-store`.** A browser that cached the move could not be sent back if a release were rolled back past it. + ## Service and hosts **Why a service announces its one-time state as it starts.** A UI can outlive the service instance it heard from: a VS Code window that takes the broker over from one with a phone connected, or a restarted sidecar. Its panel kept showing that phone with End, on a connection gone with its window, until someone pressed End (review, 2026-09). diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index a919241f3..59a540b7f 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -211,7 +211,7 @@ A framed page (`docs/specs/remote-api.md` -> "Frames") runs at a frame origin: a - **The bootstrap page must expire every cookie it can see on the parent domain before the frame's page loads**, leaving this origin's own. -- **Must declare `capabilities.frames` only at Hosted's production origin** (`frameDomainFor`), from Pocket and the one-time page alike: the frame Worker lets no other page frame it, and a preview has no wildcard to serve one on. +- **Must declare `capabilities.frames` only at Hosted's production origins** (`frameDomainFor`), from Pocket and the one-time page alike: the frame Worker lets no other page frame it, and a preview has no wildcard to serve one on. - **Must let frames in through Pocket's and the one-time page's CSPs only for that domain**; a self-host Relay's Pocket names none. The one-time page's own CSP sandbox must grant what `IFRAME_SANDBOX` does, since a frame's is at most its parent's. - **Must sandbox a framed page as a local pane's, and its bootstrap page no less**, so it cannot navigate Pocket away, itself or through the sibling it can script. - **Must keep a separate `frameOriginFor` salt per tab, in `sessionStorage`**, since a worker cannot tell whose page asked and two tabs sharing an origin would serve each other's requests; a reload keeps it, a browser's Duplicate Tab copies it, and losing it only moves frames to new origins. The one-time page holds its salt in memory alone. diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 0444b1e14..3b216ffb9 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -266,7 +266,7 @@ Source of truth: `remote-lib-common/src/security/noise-transport.ts`. | `DORMOUSE_RELAY_ORIGIN` | Mode | Relay | One-time connection | Managed voice | Standalone auto-update | | --- | --- | --- | --- | --- | --- | -| unset, or `https://relay.dormouse.sh` | Hosted | Hosted's, enrolled by device code | at this origin | at `https://voice.dormouse.sh` | on | +| unset, or `https://relay.dormouse.sh` | Hosted | Hosted's, enrolled by device code | its socket at this origin, its link at `https://try.dormouse.sh` | at `https://voice.dormouse.sh` | on | | any other accepted origin | self-host | exactly this origin | off | off | off | - **A self-host build sends nothing to `dormouse.sh` or any host under it unless the user clicks a link** (rationale). **Every Hosted-reaching host feature takes the nullable `hostedOrigin` or `hostedVoiceOrigin` and does nothing on `null`** (`docs/specs/one-time.md` -> "Service and hosts", `docs/specs/alert.md` -> "Managed voice"); the standalone webview never checks for updates (`docs/specs/auto-update.md` -> "How it works"). @@ -274,7 +274,7 @@ Source of truth: `remote-lib-common/src/security/noise-transport.ts`. - **Accepted origins**: a bare origin as `new URL` spells it, on `https:` or on loopback `http:` (`localhost`, `127.0.0.1`, `[::1]`), of at most `MAX_RELAY_ORIGIN_LENGTH`, the longest a one-time link fits. Anything else fails the build. **The pairing QR's fixed tail leaves a self-host origin 103 characters** ("Setup tokens and the pairing QR"): an origin of 104–167 characters builds and enrolls, then every "Set up a phone" fails. - **`DORMOUSE_RELAY_IS_HOSTED=1` counts a non-default origin as Hosted in a dev build only**. **Every other build is a release build and fails when the flag is set or the origin is loopback `http:`** (rationale). Unflagged, a loopback origin is a local self-host Relay. - **The retired `DORMOUSE_REMOTE_CONNECT_SRC` set non-blank fails the build.** -- **Managed voice's origin is the constant `HOSTED_VOICE_ORIGIN`, never baked or overridden**, a loopback dev Hosted build included (rationale). **The account's origin is the constant `HOSTED_ACCOUNT_ORIGIN`, never baked and never requested**: the desktop opens it only on a user's click, in the browser. Hosted's origins: `docs/specs/hosted.md` -> "Application boundary". +- **Managed voice's origin is the constant `HOSTED_VOICE_ORIGIN`, never baked or overridden**, a loopback dev Hosted build included (rationale). **The account's origin is the constant `HOSTED_ACCOUNT_ORIGIN`, never baked and never requested**: the desktop opens it only on a user's click, in the browser. **The one-time page's origin is the constant `HOSTED_ONE_TIME_ORIGIN`, never baked and never requested**: the desktop only writes it into a link (`docs/specs/one-time.md` -> "Link"). Hosted's origins: `docs/specs/hosted.md` -> "Application boundary". **The Burrow composes every Relay URL from the baked origin and takes none as input.** `enroll` and `enrollOffer` post to it, and **a Hosted build refuses both**; device-code enrollment ("Burrow side") is Hosted's alone. Under the network policy's `nothing`: `docs/specs/remote-network.md` -> "Policy". **An enrollment whose Relay URL or `origin` names another origin reads as none** wherever one is read and stays on disk untouched, so switching back restores it (rationale). Origins compare as `new URL(...).origin`. diff --git a/docs/specs/remote-network.rationale.md b/docs/specs/remote-network.rationale.md index e21ca0138..417c49f33 100644 --- a/docs/specs/remote-network.rationale.md +++ b/docs/specs/remote-network.rationale.md @@ -56,7 +56,7 @@ Evidence for `docs/specs/remote-network.md`, keyed by its headings. **Why no ICE server outside Anywhere, and never TURN.** A STUN server learns the public address of each end that asks it, and the fact of a session, from a party neither endpoint chose. The self-host deployment is a tailnet, where host candidates reach on both ends — the Burrow's tailnet address is one the phone can route to, and the phone's mDNS-obfuscated candidate is learned peer-reflexively from its first check — so there a server buys connectivity that is already there. A STUN answer is unauthenticated, and the worst a false one does is advertise a candidate that never connects: the session's ciphers decide what rides the channel, not the address. A TURN server would learn the traffic pattern and carry the ciphertext, exactly the position the Relay already holds and the security model treats as untrusted, and a one-time session is direct-only by design, so it never needs one. -**Why Hosted-served Clients always use STUN.** Hosted runs on Cloudflare, so a phone that loaded Pocket or the one-time page from `relay.dormouse.sh` has already shown Cloudflare its address. STUN to Cloudflare discloses nothing new, and making it unconditional removes a policy signal from the wire and from version skew. The Burrow's STUN is what reveals the laptop's public address, hence the Burrow alone depends on the level. The cost falls on Local networks, whose phone gathers a srflx it cannot use: with the grace its attempt opened at 0.59 s against 0.22 s with no STUN (2026-09-30, `remote-api.rationale.md` -> "Direct path"), and on a network that drops UDP 3478 its offer waits the whole 3 s `DIRECT_GATHER_TIMEOUT_MS`. +**Why Hosted-served Clients always use STUN.** Hosted runs on Cloudflare, so a phone that loaded Pocket from `relay.dormouse.sh` or the one-time page from `try.dormouse.sh` has already shown Cloudflare its address. STUN to Cloudflare discloses nothing new, and making it unconditional removes a policy signal from the wire and from version skew. The Burrow's STUN is what reveals the laptop's public address, hence the Burrow alone depends on the level. The cost falls on Local networks, whose phone gathers a srflx it cannot use: with the grace its attempt opened at 0.59 s against 0.22 s with no STUN (2026-09-30, `remote-api.rationale.md` -> "Direct path"), and on a network that drops UDP 3478 its offer waits the whole 3 s `DIRECT_GATHER_TIMEOUT_MS`. **On a real phone** *(2026-10-01, iPhone 15 Pro, iOS Safari, against PR #865's Hosted preview; laptop on home Wi-Fi, macOS 27, innerdogfood)*. Under Anywhere, the phone on Verizon cellular with Wi-Fi and Tailscale off connected directly and felt instant from the confirmation, as did the phone on the same home Wi-Fi; typed and pasted input arrived intact. Under Local networks with only the home Wi-Fi allowed, the phone on cellular never reached a terminal: the attempt ended `direct-failed` about 5 s after the confirmation (Burrow log: the direct channel did not open in time), because both ends' candidates outside the allowed network were stripped and no pair formed, so the page's STUN did not widen the level. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index eceeea1aa..7042b7b29 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -144,7 +144,7 @@ Source of truth: `BurrowRuntime` in `lib/src/remote/burrow/burrow-runtime.ts`, ` - **One padded outcome.** `OneTimeOutcomeV1` success carries only the Burrow label — no Burrow static, no `deliveryId` — and a denial only one of `ONE_TIME_DENIAL_CODES` (`docs/specs/one-time.md` -> "Wire contract"), in the same fixed control message as [Pairing](#pairing)'s. - **Direct required: application data never crosses the rendezvous.** The outcome promotes the same session; the phone refuses protocol-v1 until both directions are direct, and an application message the Burrow decrypts off the rendezvous ends the session unread. A session with no direct path by `DIRECT_ONLY_DEADLINE_MS` ends at both ends; no relayed fallback. - **After the switch the direct channel, not the rendezvous, is the lifecycle authority** — for this ceremony alone, the carve-out from the relayed lifecycle rule (`docs/specs/security-remote.md` -> "Direct path"), since no Relay carries it. Both ends close the rendezvous at the switch and ignore its loss; channel loss or the idle deadline ends the session, and nothing resumes. -- **The page Hosted serves at `/connect/` is a third trusted endpoint for every session confirmed through it**: the channel ends inside that page, so whoever controls Hosted's deploy pipeline or Cloudflare account can serve one that reads or drives the session, which Noise cannot prevent. **The rendezvous is trusted with nothing**, as the Relay is ([Residual metadata](#residual-metadata)). +- **The page Hosted serves at `try.dormouse.sh` is a third trusted endpoint for every session confirmed through it**: the channel ends inside that page, so whoever controls Hosted's deploy pipeline or Cloudflare account can serve one that reads or drives the session, which Noise cannot prevent. **The rendezvous is trusted with nothing**, as the Relay is ([Residual metadata](#residual-metadata)). Source of truth: `OneTimeRuntime` in `lib/src/remote/burrow/one-time-runtime.ts`, `OneTimeClient` in `lib/src/remote/client/one-time-client.ts`, `oneTimeLinkPrologue` in `remote-lib-common/src/security/one-time-link.ts`, `e2eOneTimePrologue` in `remote-lib-common/src/security/noise-transport.ts`, `OneTimeRequestV1` / `OneTimeOutcomeV1` / `knownOneTimeDeviceLabel` in `remote-lib-common/src/security/e2e-ceremony.ts`. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 70604a907..7db41fb02 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -1,7 +1,7 @@ # Hosted account security > - See `docs/specs/glossary.md` for Burrow, Client, and Relay vocabulary. -> - Owns the security checks of Hosted's four Workers: account, relay, voice, and frames. +> - Owns the security checks of Hosted's five Workers: account, relay, try, voice, and frames. > - Defers identity behavior and the Worker split to `docs/specs/hosted.md` and terminal access to `docs/specs/remote-security-model.md`. > - Read `docs/specs/security.md` first; live production controls and real-provider acceptance still require verification. @@ -10,16 +10,16 @@ | Guarantee | Feature | Pinned by | | --- | --- | --- | | **Each Hosted Worker answers only its own origin and runs only its own code.** No third-party script, analytics, or framing; sign-in cookies stay on the account origin, and its session never reaches browser storage. | One-time link, Hosted phone | `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts` | -| **A framed page runs on a domain with nothing else of Hosted's on it.** That domain's Worker holds no credential and sets no cookie, and only Pocket can frame what it serves. Framed pages share the domain with one another (`docs/specs/security.md` -> "Known gaps"). | Hosted phone | `hosted/scripts/production.test.mjs`, `hosted/server/tests/frame-origin.test.ts` | +| **A framed page runs on a domain with nothing else of Hosted's on it.** That domain's Worker holds no credential and sets no cookie, and only Pocket and the one-time page can frame what it serves. Framed pages share the domain with one another (`docs/specs/security.md` -> "Known gaps"). | Hosted phone | `hosted/scripts/production.test.mjs`, `hosted/server/tests/frame-origin.test.ts` | - **FAIL IF** `S77` a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`. - **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieLogin` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `accountRoutes` in `hosted/server/account-routes.ts`, and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale). - **FAIL IF** `S79` the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET` or `RELAY_VAPID_PRIVATE_KEY`, or the relay or voice entry imports Better Auth, or `WORKERS` in `hosted/scripts/workers.mjs` provisions either an auth secret; inspect `hosted/server/bindings.ts` and each entry's import graph. - **FAIL IF** `S281` any bindings mapper but the production account's passes a `STRIPE_*` binding; `STRIPE_SECRET_KEY` or `STRIPE_WEBHOOK_SECRET` is anything but an account Worker secret, a Wrangler `vars` entry or a preview config included; or the relay or voice entry's import graph reaches `@pgstencil/stripe`, `stripe`, or `hosted/server/billing.ts`. Inspect `hosted/server/bindings.ts`, `hosted/wrangler.jsonc`, `hosted/scripts/preview.mjs`, and each entry's import graph. - **FAIL IF** `S80` authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or account (Better Auth) session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. -- **FAIL IF** `S81` the account origin's policy permits third-party scripts, framing, inline script execution, or any worker (`worker-src 'none'`); a voice response, or a relay response under a `RELAY_NON_PAGE_PREFIXES` prefix (`/api`, `/ws`), carries any policy but `RUNS_NOTHING_POLICY`; any response but a 101 WebSocket upgrade bypasses `secureHeaders`, a misconfigured deployment's error included; or a response is cached past its class: immutable only for a content-hashed file under the account's `/assets/` or the relay's `/assets/` and `/connect/assets/`, `no-cache` only on Pocket's other paths, `no-store` everywhere else, the account's SPA shell included. Inspect `secureHeaders` / `accountRules` / `relayRules` / `relayPathKind` in `hosted/server/headers.ts`, binding resolution in `hosted/server/worker-app.ts`, and asset routing in `hosted/wrangler.jsonc` and `hosted/wrangler.relay.jsonc`. -- **FAIL IF** `S296` `HOSTED_FRAME_DOMAIN` in `remote-lib-common/src/remote/pocket-deployment.ts` shares a registrable domain with any other Hosted origin, or the frame Worker's production config can deploy with a binding but `ASSETS`, a variable but `CLIENT_ORIGIN`, a `CLIENT_ORIGIN` but the relay's, or a route but that domain's wildcard; or Pocket's or the one-time page's policy lets a frame in from anywhere but that domain, or a self-host Relay's names one. Inspect `framesConfig` in `hosted/scripts/production.mjs`, `hosted/wrangler.frames.jsonc`, `pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, and its callers. -- **FAIL IF** `S297` `frameOriginResponse` answers a path but its three files with anything but a 404, reads a request header or a cookie, sets a cookie, omits `frame-ancestors` naming only `CLIENT_ORIGIN` or HSTS, or `stageFrames` can stage any other file. Inspect `hosted/server/frame-origin.ts` and `hosted/scripts/stage-frames.mjs`. +- **FAIL IF** `S81` the account origin's policy permits third-party scripts, framing, inline script execution, or any worker (`worker-src 'none'`); a voice response, or a relay response under a `RELAY_NON_PAGE_PREFIXES` prefix (`/api`, `/ws`, `/connect`), or a connect response outside `/connect`, carries any policy but `RUNS_NOTHING_POLICY`; any response but a 101 WebSocket upgrade bypasses `secureHeaders`, a misconfigured deployment's error included; or a response is cached past its class: immutable only for a content-hashed file under the account's or the relay's `/assets/` or the try Worker's `/connect/assets/`, `no-cache` only on Pocket's other paths, `no-store` everywhere else, the account's SPA shell included. Inspect `secureHeaders` / `accountRules` / `relayRules` / `tryRules` in `hosted/server/headers.ts`, binding resolution in `hosted/server/worker-app.ts`, and asset routing in `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, and `hosted/wrangler.try.jsonc`. +- **FAIL IF** `S296` `HOSTED_FRAME_DOMAIN` in `remote-lib-common/src/remote/pocket-deployment.ts` shares a registrable domain with any other Hosted origin, or the frame Worker's production config can deploy with a binding but `ASSETS`, a variable but `CLIENT_ORIGINS`, a `CLIENT_ORIGINS` but the relay's and the try Worker's, or a route but that domain's wildcard; or Pocket's or the one-time page's policy lets a frame in from anywhere but that domain, or a self-host Relay's names one. Inspect `framesConfig` in `hosted/scripts/production.mjs`, `hosted/wrangler.frames.jsonc`, `pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, and its callers. +- **FAIL IF** `S297` `frameOriginResponse` answers a path but its three files with anything but a 404, reads a request header or a cookie, sets a cookie, omits `frame-ancestors` naming only `CLIENT_ORIGINS` or HSTS, or `stageFrames` can stage any other file. Inspect `hosted/server/frame-origin.ts` and `hosted/scripts/stage-frames.mjs`. - **FAIL IF** `S82` marketing scripts, analytics, provider avatars, or remote fonts enter the Hosted frontend; inspect the frontend import graph and deployed response when available. Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts`, and `hosted/server/tests/one-time.test.ts`. @@ -53,19 +53,20 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended | Guarantee | Feature | Pinned by | | --- | --- | --- | | **The one-time rendezvous relays a handshake it cannot read, to one phone, until the link expires.** It decodes and keeps no frame, never admits a second phone, and reads no cookie or account. | One-time link | `hosted/server/tests/one-time.test.ts`, `scripts/e2e-lint.mjs` | +| **The one-time page runs on an origin of its own, apart from a paired phone's keys.** What a stranger's link sends never renders beside Pocket, and an older build's link only redirects there. | One-time link | `hosted/server/tests/one-time.test.ts`, `scripts/e2e-lint.mjs` | -**The one-time room is a counter with two sockets** on the relay Worker: `docs/specs/one-time.md` -> "Hosted rendezvous" owns its routes and lifecycle, and "Phone page" the page beside them; these are the checks on them. +**The one-time room is a counter with two sockets**, reached from the relay and try Workers: `docs/specs/one-time.md` -> "Hosted rendezvous" owns its routes and lifecycle, and "Phone page" the page beside them; these are the checks on them. - **FAIL IF** `S87` `OneTimeRoom` in `hosted/server/one-time-room.ts` parses, decodes, stores, or logs a forwarded frame; it bounds one by raw length and count alone. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** `S88` a binary frame, one longer than `MAX_ONE_TIME_FRAME_LENGTH`, or one past `MAX_ONE_TIME_FORWARDED` is forwarded rather than closing both ends with 4015, the count omits a frame the room received, or either bound is redeclared rather than imported from `remote-lib-common`. - **FAIL IF** `S89` a second phone can join: the join must read and set `joined` with no await between, in the Burrow socket's hibernation attachment rather than memory. - **FAIL IF** `S90` a room can outlive `expiresAt + ONE_TIME_EXPIRY_GRACE_MS`, or admit a phone after `expiresAt`: the alarm is set before the Burrow socket is accepted, and closes every socket. -- **FAIL IF** `S91` the Burrow route admits a request carrying any `Origin` header, or the client route an `Origin` other than exactly the relay's `APP_ORIGIN`; inspect `oneTimeRoutes` in `hosted/server/one-time.ts`. +- **FAIL IF** `S91` the Burrow route admits a request carrying any `Origin` header, or the client route an `Origin` other than exactly the try Worker's `APP_ORIGIN`; inspect `oneTimeBurrowRoute` and `oneTimeClientRoute` in `hosted/server/one-time.ts`. - **FAIL IF** `S92` a room id comes from anything but 16 fresh random bytes the Worker mints per Burrow socket, the Burrow route takes a room from the request, or a room opens twice. - **FAIL IF** `S93` either route reaches the room before its per-address rate limit (`cf-connecting-ip`, IPv6 by /64, IPv4-mapped IPv6 by its IPv4), or a production rate-limit `namespace_id` reaches `PREVIEW_RATELIMIT_OFFSET` in `hosted/scripts/preview.mjs`. - **FAIL IF** `S94` a one-time route or the room reads a cookie, reaches Hyperdrive or auth, mounts ahead of the 421 gate, or hands the room any header of the caller's but the upgrade. -- **FAIL IF** `S95` the `/connect/` page's policy admits a network source but a frame (`S296`) outside the relay `APP_ORIGIN`'s `/connect/`, a script outside `/connect/assets/`, or a connection but the client route; permits inline or off-origin script, being framed, a form submission, or a top navigation; or takes an `APP_ORIGIN` that is not exactly an origin. Inspect `relayRules` in `hosted/server/headers.ts`. -- **FAIL IF** `S96` the relay serves anything under `/connect/` but the page and its hashed assets, a missing asset gets HTML, the relay's assets hold anything but the staged page and Pocket, or a shell failing `assertPocketShell`'s one-time mode can ship; `build:one-time` in `lib/package.json` and `stageRelay` in `hosted/scripts/stage-relay.mjs` each run it. Inspect `oneTimePageRoutes` in `hosted/server/one-time.ts` and `hosted/server/relay-worker.ts`. +- **FAIL IF** `S95` the `/connect/` page's policy admits a network source but a frame (`S296`) outside the try Worker `APP_ORIGIN`'s `/connect/`, a script outside `/connect/assets/`, or a connection but the client route; permits inline or off-origin script, being framed, a form submission, or a top navigation; or takes an `APP_ORIGIN` that is not exactly an origin. Inspect `tryRules` in `hosted/server/headers.ts`. +- **FAIL IF** `S96` the one-time page is served, or a phone admitted, on any origin holding a paired phone's keys: the relay Worker's `/connect` paths must answer only the redirect to `TRY_ORIGIN`, which `productionConfig` pins, its entry must name no page or client route, its assets no page, and the try Worker must hold no Hyperdrive or secret; or the try Worker serves anything under `/connect/` but the page and its hashed assets, a missing asset gets HTML, its assets hold anything but the staged page, or a shell failing `assertPocketShell`'s one-time mode can ship (`build:one-time` in `lib/package.json` and `stageTry` in `hosted/scripts/stage-try.mjs` each run it). Inspect `oneTimePageRoutes` and `oneTimePageMoved` in `hosted/server/one-time.ts`, `hosted/server/try-worker.ts`, `stageRelay` in `hosted/scripts/stage-relay.mjs`, and `tryBindings` in `hosted/server/bindings.ts`; `scripts/e2e-lint.mjs` holds the entry textually. `scripts/e2e-lint.mjs` also holds `hosted/server/` to the Relay's absences: no protocol-v1 type, no direct-path signal or SDP, no ICE server — the page's STUN is client code (`docs/specs/security-remote.md` -> "Direct path"). Pinned by `hosted/server/tests/one-time.test.ts`, `hosted/scripts/stage-relay.test.mjs`, `lib/src/remote/pocket-app/assert-pocket-worker.test.ts`, and `hosted/scripts/production.test.mjs`. @@ -98,7 +99,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S114` the client socket upgrade admits an `Origin` other than exactly the relay's `APP_ORIGIN`, the Burrow upgrade admits any `Origin`, either reaches the object before its token resolves to a live session or Burrow of an entitled account, or the Worker hands the object any header, token, or parameter of the caller's. - **FAIL IF** `S115` the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`; a removed Burrow's live socket outlives a `closeBurrow` that reached the object, or a removed or de-entitled Burrow's outlives the first sweep whose row read succeeds; a Client socket outlives a `closeSessions` naming its session that reached the object, or, its session deleted, expired, or de-entitled, the first sweep whose row read succeeds; a socket outlives the first failed sweep `RELAY_ROOM_SWEEP_MS` plus `RELAY_ROOM_SWEEP_GRACE_MS` after its row was last read live; a sweep while any socket is held schedules the next later than `RELAY_ROOM_SWEEP_MS` after its own start, or `RELAY_ROOM_SWEEP_RETRY_MS` after it once its read failed; `RelayRoom.fetch` exposes non-upgrade routes; or a public route reaches `RelayRows` or any `RelayRoom` method but the two upgrades, except authenticated, account-scoped `closeBurrow` and `closeSessions` on account-Worker removal and closure, `nudgeBurrows` on account-Worker passkey removal, and `onlineBurrows` on relay-Worker `GET /api/burrows`. - **FAIL IF** `S301` `POST /api/burrow/passkeys/removed` in `relayApiRoutes` answers anything but `removedPasskeyIds` of the ids asked against the passkeys of the Burrow token's own owner, names an id the Burrow did not send, lists the account's passkeys, writes a row, or skips the Burrow gate; or a passkey removal in `relayAccountRoutes` commits without calling `nudgeBurrows`, whether or not it had sessions, or a `passkeys-changed` frame carries anything but its `t`, and a sweep whose read found a Burrow live does not send it one. -- **FAIL IF** `S116` a Pocket path's response lacks Pocket's policy (`pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, taken only from an `APP_ORIGIN` that is exactly an origin), any response but a Pocket path's allows the camera, `/connect/` included, or a response is classified on any path but the decoded one Hono routes on, so `/%63onnect/` would take Pocket's; inspect `relayRules` / `relayPathKind` and `secureHeaders` in `hosted/server/headers.ts`. +- **FAIL IF** `S116` a Pocket path's response lacks Pocket's policy (`pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, taken only from an `APP_ORIGIN` that is exactly an origin), any response but a Pocket path's allows the camera, or a response is classified on any path but the decoded one Hono routes on, so `/%63onnect/` would take Pocket's; inspect `relayRules` and `secureHeaders` in `hosted/server/headers.ts`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `remote-lib-common/test/web-push.test.mjs`. diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index e958aa084..0b4f25375 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -214,7 +214,7 @@ The HTTPS origin may be public. Tailnet-only Serve is the installer default and - **FAIL IF** `S246` an application message crosses the rendezvous, or a one-time session outlives a missed direct deadline: `OneTimeRuntime` must make its one session `directOnly` on `EstablishedE2eSession`, so an application message decrypted off the rendezvous ends the session unread and a decline, an abandoned attempt, or no switch by `DIRECT_ONLY_DEADLINE_MS` ends it — no relayed fallback. After the switch the direct channel is the lifecycle authority: the runtime closes the rendezvous, and channel loss or `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` idle ends the session (`docs/specs/remote-security-model.md` -> "One-time connection"). `scripts/e2e-lint.mjs` holds the flag textually. - **FAIL IF** `S247` the phone opens the room's socket outside `connectOnce`, sends protocol-v1 before both directions are direct, keeps the rendezvous open past the switch, parses a frame before `parseOneTimeFrame` in `lib/src/remote/one-time-rendezvous.ts` has measured it against `MAX_ONE_TIME_FRAME_LENGTH`, reads a frame `isOneTimeBurrowFrame` refuses, or outlives `DIRECT_ONLY_DEADLINE_MS` without both directions direct; a decline or an abandoned attempt fails the attempt too (`docs/specs/one-time.md` -> "Phone client"; read `OneTimeClient` in `lib/src/remote/client/one-time-client.ts`). Pinned by `lib/src/remote/client/one-time-client.test.ts` and `lib/src/remote/client/one-time-e2e.test.ts`. - **FAIL IF** `S248` a one-time phone keeps anything of its own past its session; what a framed page stores at its frame origin is that page's, under a salt held only in memory. `OneTimeClient` must mint its static with `generateNoiseKeyPair`, nonextractable, for the one handshake, and it, `ClientSessionCore` in `lib/src/remote/client/session-core.ts`, and every module of the page in `lib/src/remote/one-time-app/` may name no browser store or service worker, nor import Pocket's records, key wrapping, passkeys, push, or `PocketClient`; the page's theme goes through `applyPocketTheme`, which writes nothing. `scripts/e2e-lint.mjs` holds the naming textually. -- **FAIL IF** `S249` the rendezvous origin is anything but the Burrow's `hostedOrigin` (Relay origin, above), comes from a command (`oneTimeOpen` takes no parameters), or is used by a socket opened before it is checked; or `BurrowService` in `lib/src/host/remote/service.ts` holds more than one runtime, stops ending the one a new link replaces, or opens a new link while a phone is connecting or connected; or the socket carries an `Origin` header. Pinned by `lib/src/host/remote/service.test.ts`. +- **FAIL IF** `S249` the rendezvous origin is anything but the Burrow's `hostedOrigin` (Relay origin, above), the link's anything but `oneTimeLinkOrigin`, either comes from a command (`oneTimeOpen` takes no parameters), or the rendezvous origin is used by a socket opened before it is checked; or `BurrowService` in `lib/src/host/remote/service.ts` holds more than one runtime, stops ending the one a new link replaces, or opens a new link while a phone is connecting or connected; or the socket carries an `Origin` header. Pinned by `lib/src/host/remote/service.test.ts`. - **FAIL IF** `S250` under Local networks a one-time session's channel can report open, or stay open past a state change or `DIRECT_PATH_RECHECK_MS`, on a selected pair whose two ends are not both IP literals inside the allowed networks, or on one the stack could not read; the Burrow's answer carries, or the offer it applies keeps, a candidate outside them; or the attempt's socket is not bound to the one allowed address where exactly one is present (`docs/specs/remote-network.md` -> "Local networks"). Read `localNetworksPath` in `lib/src/host/remote/local-networks.ts` and its consumers, `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` and `createNativeDirectPeerFactory` in `lib/src/host/remote/native-direct-peer.ts`. Pinned by `lib/src/host/remote/local-networks.test.ts` and `lib/src/remote/direct/direct-peer.test.ts`. - **FAIL IF** `S251` the one-time runtime relies on the rendezvous for any bound or deadline (`docs/specs/one-time.md` -> "Burrow runtime"). It must read every message through `parseOneTimeFrame`, which measures it against `MAX_ONE_TIME_FRAME_LENGTH` before `JSON.parse`, shape-guard every frame, stop reading a room past `MAX_ONE_TIME_FORWARDED` messages, gate each `init`'s WebCrypto on its own `TokenBucket` of `E2E_INIT_BURST`, and end on its own clock — a link not yet promoted at its expiry, claimed or not, and a promoted one by `DIRECT_ONLY_DEADLINE_MS` — never after the room would. Pinned by `lib/src/remote/burrow/one-time-runtime.test.ts`. diff --git a/docs/specs/security-supply-chain.md b/docs/specs/security-supply-chain.md index 9f8b012e0..056227b72 100644 --- a/docs/specs/security-supply-chain.md +++ b/docs/specs/security-supply-chain.md @@ -22,7 +22,7 @@ Every workspace is classified from its shipping route: a product root or runtime **Must disclose each npm release once, in the first section whose roots reach it**; an earlier section never enters a later section's root. **Never call the Built-in Tools optional or unshipped**: every install ships them inside `dor` (what their frame reaches: `docs/specs/security-local.md` → Local-file viewer). **Must merge a package's releases into one row per name and license**. -**Must root the Hosted section at `dormouse-hosted`**: one package holds all three Workers and the account page, so the section lists more than any one Worker bundles (rationale). Pocket and the one-time page, which `hosted/` builds from `dormouse-lib` with no workspace edge, are disclosed through the Terminal's `dormouse-lib` root. **Never follow an optional peer edge in the Hosted section**: Hosted ships only esbuild bundles, which hold an optional peer only where code imports it (rationale). **Must fail generation when a Hosted Worker or the account page bundles a release the disclosure lacks** (`assertHostedBundlesDisclosed` in `website/scripts/hosted-bundles.js`; rationale). +**Must root the Hosted section at `dormouse-hosted`**: one package holds its application Workers and the account page, so the section lists more than any one Worker bundles (rationale). Pocket and the one-time page, which `hosted/` builds from `dormouse-lib` with no workspace edge, are disclosed through the Terminal's `dormouse-lib` root. **Never follow an optional peer edge in the Hosted section**: Hosted ships only esbuild bundles, which hold an optional peer only where code imports it (rationale). **Must fail generation when a Hosted Worker or the account page bundles a release the disclosure lacks** (`assertHostedBundlesDisclosed` in `website/scripts/hosted-bundles.js`; rationale). **External binaries are outside this graph by construction** — the user's shell, and the CLIs `dor agent-browser` and `dor playwright` forward to (`npm i -g agent-browser`, `npm i -g @playwright/cli`; dependencies of nothing here, resolved off `PATH` or an override variable). **Dormouse instead ships nothing that pulls them in silently**. diff --git a/docs/specs/security-supply-chain.rationale.md b/docs/specs/security-supply-chain.rationale.md index 869ceee0b..f2434ce8a 100644 --- a/docs/specs/security-supply-chain.rationale.md +++ b/docs/specs/security-supply-chain.rationale.md @@ -4,7 +4,7 @@ Why the test is "puts on a user's machine, or Hosted serves or runs" rather than "everything a user runs": `website` runs in a visitor's browser rather than being installed anywhere, holds no key, and carries no session, and the supply-chain page says as much about its own React and react-router. Excluding it is what makes the narrower test the operative one, and it is a judgement worth re-making if the site ever ships something a visitor installs. -Why Hosted is disclosed though none of it is installed on a computer: `relay.dormouse.sh` serves Pocket, which keeps the phone's Client statics and is one of the two endpoints `docs/specs/remote-security-model.md` -> "Trust Model" names, and its Worker routes every Hosted session and carries the one-time handshake. Hosted's code is part of a Hosted user's trust base as much as the Terminal's is. +Why Hosted is disclosed though none of it is installed on a computer: `relay.dormouse.sh` serves Pocket, which keeps the phone's Client statics and is one of the two endpoints `docs/specs/remote-security-model.md` -> "Trust Model" names, and its Worker routes every Hosted session; it and `try.dormouse.sh` carry the one-time handshake, and the latter serves the one-time page. Hosted's code is part of a Hosted user's trust base as much as the Terminal's is. Why the Hosted section discloses more than ships: the relay and voice Worker bundles hold 19 npm packages each and the account Worker's 33 (34 releases), and the account page only React's three (wrangler dry-run and Vite source maps, 2026-10) — `pgstencil`, `@pgstencil/auth`, `hono`, and `pg`'s family in all three Workers, `better-auth`'s family in the account Worker alone — while the walk reaches 39 rows past the earlier sections: the extra eight are regular dependencies of `better-auth` and `@pgstencil/auth` that no bundle holds (`@better-auth/{drizzle,mongo,prisma}-adapter`, `@standard-schema/spec`, `nanostores`, `oauth4webapi`, `openid-client`, `set-cookie-parser`). Under `pgstencil` 0.3.0 it reached 189, because that release declared its test harness (`testcontainers`, `cheerio`, `turndown`, `node-pg-migrate`) as runtime dependencies; 0.4.0 makes all four optional peers, and `hosted/` installs only `testcontainers` and `node-pg-migrate`, as devDependencies. Pocket and the one-time page bundle only packages the Terminal already lists (their source maps, the same date). A walk over the bundles' metafiles would be exact, but the disclosure gate would then need the whole Hosted build. diff --git a/docs/specs/security.md b/docs/specs/security.md index 528cdadbb..bc723a9f7 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -22,7 +22,7 @@ Every feature is opt-in: one you do not use adds none of its risk. With none on, | Feature | What Dormouse opens on its own | What you trust beyond this computer | | --- | --- | --- | -| **One-time link.** A Hosted build, the one Dormouse distributes, at Local networks only or Anywhere. You open a link in Settings for one phone; no account. | While a link is open, a socket to `relay.dormouse.sh` for its handshake ([Hosted rendezvous](./one-time.md#hosted-rendezvous)). Terminal traffic goes only directly between the two devices. | The phone, and the page Hosted serves it, so Hosted's deployment pipeline and Cloudflare account ([One-time connection](./remote-security-model.md#one-time-connection)). | +| **One-time link.** A Hosted build, the one Dormouse distributes, at Local networks only or Anywhere. You open a link in Settings for one phone; no account. | While a link is open, a socket to `relay.dormouse.sh` for its handshake ([Hosted rendezvous](./one-time.md#hosted-rendezvous)); the phone opens the page at `try.dormouse.sh`. Terminal traffic goes only directly between the two devices. | The phone, and the page Hosted serves it, so Hosted's deployment pipeline and Cloudflare account ([One-time connection](./remote-security-model.md#one-time-connection)). | | **Hosted phone.** A Hosted build at Local networks only or Anywhere, enrolled with your Hosted account ([Burrow enrollment](./hosted.md#burrow-enrollment)), with Pocket paired. | A socket to Hosted's Relay at `relay.dormouse.sh`, for handshakes, pairing, and any push you turn on. Terminal traffic goes directly when it can, otherwise through Hosted under Anywhere; under Local networks only, only directly. | The phone, and the Pocket app Hosted serves it, so Hosted's deployment pipeline and Cloudflare account ([Trust Model](./remote-security-model.md#trust-model)). | | **Self-hosted Relay.** A self-host build, compiled from source and pointed at your own Relay ([Relay origin](./relay.md#relay-origin)), at Anywhere, through your Relay. The default installer keeps that Relay on your tailnet. | A socket to your Relay, for handshakes, pairing, and any push you turn on. Terminal traffic goes directly when it can, with no STUN server, otherwise through your Relay. | The phone, and the Pocket app your Relay serves it. | diff --git a/dormouse.yml b/dormouse.yml index 360f0b1a0..ba6fbd389 100644 --- a/dormouse.yml +++ b/dormouse.yml @@ -37,7 +37,7 @@ tools: port: auto prespawn_dedupe: [hosted, $PROJECT_ROOT] - # One-time connection rendezvous + phone page on localhost:8787 (Worker reloads on save, page rebuilds on save, no HMR); fixed port, so one checkout at a time + # One-time connection rendezvous on localhost:8787 + phone page on localhost:8788 (Workers reload on save, page rebuilds on save, no HMR); fixed ports, so one checkout at a time one-time: run: pnpm dev:one-time render: agent-browser-screencast diff --git a/hosted/README.md b/hosted/README.md index a03166b28..918247112 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -1,6 +1,6 @@ # Dormouse Hosted -Three Hono/Cloudflare Workers and the frame Worker `dormouse-frames` on `*.dormouseusercontent.com` are built from this package: the account frontend and auth at `https://hosted.dormouse.sh` (`dormouse-hosted`), the Hosted Relay's account-scoped routes with Pocket at the root, and the one-time connection's rendezvous and `/connect/` phone page at `https://relay.dormouse.sh` (`dormouse-relay`; [the one-time spec](../docs/specs/one-time.md)), and managed-voice speech at `https://voice.dormouse.sh` (`dormouse-voice`). The marketing website is a separate application. Managed voice and the Relay admit only the admin account; device-code Burrow enrollment and account-scoped encrypted terminal routing are implemented. Real-provider and live production acceptance remain separate release gates. See [the spec](../docs/specs/hosted.md), whose "Application boundary" owns what each Worker serves. +Four Hono/Cloudflare Workers and the frame Worker `dormouse-frames` on `*.dormouseusercontent.com` are built from this package: the account frontend and auth at `https://hosted.dormouse.sh` (`dormouse-hosted`), the Hosted Relay's account-scoped routes with Pocket at the root and the one-time connection's Burrow route at `https://relay.dormouse.sh` (`dormouse-relay`), the one-time `/connect/` phone page and its client route at `https://try.dormouse.sh` (`dormouse-try`; [the one-time spec](../docs/specs/one-time.md)), and managed-voice speech at `https://voice.dormouse.sh` (`dormouse-voice`). The marketing website is a separate application. Managed voice and the Relay admit only the admin account; device-code Burrow enrollment and account-scoped encrypted terminal routing are implemented. Real-provider and live production acceptance remain separate release gates. See [the spec](../docs/specs/hosted.md), whose "Application boundary" owns what each Worker serves. This file is the whole operator runbook, in the order an operator works: run locally, update packages, set up GitHub, provision previews, provision production, release, accept, recover. Marketing, desktop releases, Hosted production, and PR previews have separate deployment credentials. A passing local test implies no cloud resource and no real-provider acceptance. @@ -24,13 +24,13 @@ pnpm build:hosted Tests run each production Worker in real workerd, the account's with disposable Postgres clones and a local OAuth simulator. `pnpm --filter dormouse-hosted test:miniflare` runs the rendezvous, Pocket, boundary, and push-egress suites alone, without Docker. The build writes each Worker's static files under `dist//` (Pocket at `dist/relay/`, `/connect/` at `dist/relay/connect/`) and dry-runs all three Workers; it does not deploy. Production deploys only through the release workflow. -The relay Worker's one-time rendezvous and `/connect/` page run on their own, without Docker or Postgres (its Relay routes answer 503 there): +The one-time rendezvous and `/connect/` page run on their own, without Docker or Postgres (the relay Worker's Relay routes answer 503 there): ```sh dor tool one-time # outside Dormouse: pnpm dev:one-time ``` -It serves `http://localhost:8787` (or `PORT`) and prints the `DORMOUSE_RELAY_ORIGIN` and `DORMOUSE_RELAY_IS_HOSTED=1` values that point a local dev Burrow build at it; `docs/specs/one-time.md` -> "Dev loop" owns what it runs. A phone cannot reach a loopback origin, so a real phone tests against a PR preview. +It serves the relay at `http://localhost:8787` (or `PORT`) and the try Worker on the next port, and prints the `DORMOUSE_RELAY_ORIGIN` and `DORMOUSE_RELAY_IS_HOSTED=1` values that point a local dev Burrow build at it; `docs/specs/one-time.md` -> "Dev loop" owns what it runs. A phone cannot reach a loopback origin, so a real phone tests against a PR preview. ## Update pgstencil @@ -43,8 +43,8 @@ Run `pnpm install` and re-run Hosted's integration tests after an update. The pr | Boundary | Resources | | --- | --- | | Preview | Dedicated test Cloudflare account with a registered workers.dev subdomain; dedicated empty Neon project and parent branch; GitHub `hosted-preview` environment | -| Each PR | `dormouse-hosted-pr-N`, `dormouse-relay-pr-N` (with its own `OneTimeRoom` and `RelayRoom` Durable Object namespaces), and `dormouse-voice-pr-N` Workers, one uncached Hyperdrive all three share as the branch's owner role, and a Neon branch, all reused until close; rate-limit namespaces `1001`–`1007` shared by every relay and account preview | -| Production | Dedicated Dormouse Postgres database, a migration role and one runtime role per Worker, three uncached Hyperdrives, one per Worker connecting as its role; Workers `dormouse-hosted` (`hosted.dormouse.sh`, with rate-limit namespace `7`), `dormouse-relay` (`relay.dormouse.sh`, with its `OneTimeRoom` and `RelayRoom` Durable Object namespaces and rate-limit namespaces `1`–`6`), and `dormouse-voice` (`voice.dormouse.sh`, with the history-sweep and idle-token Cron Triggers), each on its custom domain; GitHub `hosted-production` environment | +| Each PR | `dormouse-hosted-pr-N`, `dormouse-relay-pr-N` (with its own `RelayRoom` Durable Object namespace), `dormouse-try-pr-N` (with its own `OneTimeRoom` one), and `dormouse-voice-pr-N` Workers, one uncached Hyperdrive all three share as the branch's owner role, and a Neon branch, all reused until close; rate-limit namespaces `1001`–`1007` shared by every relay and account preview | +| Production | Dedicated Dormouse Postgres database, a migration role and one runtime role per Worker, three uncached Hyperdrives, one per Worker connecting as its role; Workers `dormouse-hosted` (`hosted.dormouse.sh`, with rate-limit namespace `7`), `dormouse-relay` (`relay.dormouse.sh`, with its `RelayRoom` Durable Object namespace and rate-limit namespaces `1` and `3`–`6`), `dormouse-try` (`try.dormouse.sh`, with its `OneTimeRoom` Durable Object namespace and rate-limit namespace `2`, and no Hyperdrive), and `dormouse-voice` (`voice.dormouse.sh`, with the history-sweep and idle-token Cron Triggers), each on its custom domain; GitHub `hosted-production` environment | | Email | Dedicated Postmark server, verified `signin@dormouse.sh`, SPF/DKIM/DMARC, Apple Private Email Relay registration | | OAuth | Separate Dormouse GitHub, Google, Microsoft, and Apple registrations; exact callbacks below | | Release history | `hosted-release-tag` GitHub environment, the `dormouse-hosted-tagger` GitHub App, the `Hosted tag creation` and `Hosted tag history` tag rulesets, immutable annotated `hosted/` tags | @@ -105,9 +105,9 @@ gh variable set HOSTED_PREVIEWS_ENABLED --repo diffplug/dormouse --body true A PR touching Hosted runs its tests and builds; adding the `hosted-preview` label provisions a preview, which still waits for an environment reviewer's approval; `docs/specs/hosted.md` -> "PR previews" owns which PRs qualify and what each one gets. The URL is in the deployment environment link and job summary; no PR comment bot or write-scoped workflow token is needed. -Open `https://dormouse-hosted-pr-N.SUBDOMAIN.workers.dev/`, request a code for a disposable address, and read it at `/dev/emails`. The PR's one-time page is at `https://dormouse-relay-pr-N.SUBDOMAIN.workers.dev/connect/`; its voice Worker has no ElevenLabs key, so speak never reaches ElevenLabs. This inbox is public to anyone with the URL. No real email or OAuth provider is contacted. A preview's Hyperdrive connects as its branch's owner role, so a preview never exercises the relay's and voice's restricted roles: a missing grant passes there, and `pnpm test:hosted`, which binds those Workers to their roles, is what catches it. The database stores messages across Worker restarts. +Open `https://dormouse-hosted-pr-N.SUBDOMAIN.workers.dev/`, request a code for a disposable address, and read it at `/dev/emails`. The PR's one-time page is at `https://dormouse-try-pr-N.SUBDOMAIN.workers.dev/connect/`; its voice Worker has no ElevenLabs key, so speak never reaches ElevenLabs. This inbox is public to anyone with the URL. No real email or OAuth provider is contacted. A preview's Hyperdrive connects as its branch's owner role, so a preview never exercises the relay's and voice's restricted roles: a missing grant passes there, and `pnpm test:hosted`, which binds those Workers to their roles, is what catches it. The database stores messages across Worker restarts. -New commits retain the URL and test accounts. Once a PR closes or loses the label, the next approved preview deploy of any PR deletes its three Workers, Hyperdrive, and Neon branch first. Migrations are append-only; to change an already-applied migration, delete the preview with `node hosted/scripts/preview.mjs cleanup`, given the preview environment's credentials and `PR_NUMBER`, then redeploy. Keep previews enabled until all live previews are removed. These credentials cannot be downloaded back from GitHub; retain independent copies in your password manager. +New commits retain the URL and test accounts. Once a PR closes or loses the label, the next approved preview deploy of any PR deletes its four Workers, Hyperdrive, and Neon branch first. Migrations are append-only; to change an already-applied migration, delete the preview with `node hosted/scripts/preview.mjs cleanup`, given the preview environment's credentials and `PR_NUMBER`, then redeploy. Keep previews enabled until all live previews are removed. These credentials cannot be downloaded back from GitHub; retain independent copies in your password manager. ## Provision the production boundary @@ -117,7 +117,7 @@ Use dedicated Dormouse resources in the existing Cloudflare, Neon, and Postmark 2. Create three Cloudflare Hyperdrive configurations for that database, each with **query caching disabled** and its connection host, port, and database identical to the direct migration URL: the account's as `dormouse_app`, the relay's as `dormouse_relay`, and the voice's as `dormouse_voice` (step 3 makes each role). Enter connection credentials directly in Cloudflare; set the resulting public IDs in `wrangler.jsonc`, `wrangler.relay.jsonc`, and `wrangler.voice.jsonc` respectively for local operator deployment. CI overrides them with the `HYPERDRIVE_ID`, `RELAY_HYPERDRIVE_ID`, and `VOICE_HYPERDRIVE_ID` variables, and the release's preflight checks each one's caching, database, and role before it backs up anything. 3. Create the runtime role with SQL (`CREATE ROLE ... LOGIN PASSWORD ...`), not Neon’s Console/API role creation, which grants `neon_superuser`. Neon requires the password over the encrypted connection and rejects a precomputed password hash. Keep it out of command arguments and SQL-editor history. Give the runtime role only the auth-table and schema DML permissions plus sequence access the shipped tables need, including defaults for future migration-created tables and sequences. Keep a separate migration role that owns migrations and can dump the database. Supply its `DATABASE_URL` through a secret manager, never in a command argument, then run `pnpm --filter dormouse-hosted db:migrate` and `db:validate`. These commands never reset or drop an existing database. `db:migrate` then applies `hosted/server/runtime-roles.sql` (alone: `db:roles`), which creates `dormouse_relay` and `dormouse_voice` NOLOGIN and resets their grants; its header states what it never changes. Before creating their Hyperdrives, run `db:roles` once if no release has applied the file yet, then give each a password with SQL as the migration role over the encrypted connection (`ALTER ROLE dormouse_relay LOGIN PASSWORD ...`, likewise `dormouse_voice`), never Neon's Console, whose roles the file refuses. 4. Set up a dedicated Postmark server/token and verify the sending address `signin@dormouse.sh` (or update `EMAIL_FROM`). Configure SPF/DKIM and DMARC. Register the sender with Apple Private Email Relay for relay-address delivery. -5. Each Worker's config claims its own custom domain — `hosted.dormouse.sh`, `relay.dormouse.sh`, `voice.dormouse.sh` — and the first deploy of each creates it. Exclude all three hostnames from Cloudflare Web Analytics, Zaraz, and other script injection or rewriting rules. Disable account API caching. Keep `workers_dev` and public preview URLs disabled. +5. Each Worker's config claims its own custom domain — `hosted.dormouse.sh`, `relay.dormouse.sh`, `try.dormouse.sh`, `voice.dormouse.sh` — and the first deploy of each creates it, refusing a hostname that already has a DNS record. Exclude all four hostnames from Cloudflare Web Analytics, Zaraz, and other script injection or rewriting rules. Disable account API caching. Keep `workers_dev` and public preview URLs disabled. 6. The frame Worker's route needs a proxied wildcard record on the `dormouseusercontent.com` zone before its first deploy: `*`, type `AAAA`, content `100::`, Proxied. Universal SSL covers one label, which is all a frame origin uses. Keep nothing else on this zone. Authenticate Wrangler to the intended Cloudflare account before provisioning. Inside Dormouse, run `dor ensure -- pnpm exec wrangler login --browser=false --use-keyring` from `hosted/`, then open the printed authorization link with `dor agent-browser`. Review the account and requested access before granting it. Secrets remain in the OS keychain. Authenticate in your own terminal; account/provider sign-in is operator-owned. @@ -270,7 +270,7 @@ A withdrawal's acknowledgement email is retried until it is sent, and the cron f ## Acceptance -1. Check `/api/health` and `/api/ready` on all three hostnames. Inspect the actual HTML response/CSP and browser network requests for injected marketing scripts or unexpected third-party assets. +1. Check `/api/health` on all four hostnames and `/api/ready` on all but `try.dormouse.sh`. Inspect the actual HTML response/CSP and browser network requests for injected marketing scripts or unexpected third-party assets. 2. Request a real email, enter its code, reload, and log out. Enter a code in a second browser to verify that mail access is not tied to the first browser. 3. For each enabled provider, test first login, consent cancellation, repeat login, and logout. Include GitHub private email and Apple Hide My Email; Microsoft needs personal and work/school accounts after the shared fix. 4. Start with email, attempt the same-address provider from another browser, and confirm no automatic linking. Connect it from the signed-in account, then confirm both methods return the same account ID. Connect Apple with its different relay address. Confirm an identity belonging to another account does not transfer, and that a connection started before logout fails on return. @@ -278,7 +278,7 @@ A withdrawal's acknowledgement email is retried until it is sent, and the cron f 6. Sign in as the admin address, turn on Natural Voice from Dormouse desktop, and speak one short phrase. Turn it off on the account page and confirm the next alarm uses the system voice and Settings offers to turn it on again. Turn it on again, then off from Dormouse, and confirm the account page says it is off. Confirm another account sees no Natural Voice section. 7. Confirm the voice Worker alone registers its two Cron Triggers: the deploy log lists `schedule: */5 * * * *` (the history sweep) and `schedule: 0 4 * * *` (idle tokens), the dashboard shows it under Workers & Pages -> `dormouse-voice` -> Settings -> Trigger Events, and `dormouse-hosted` lists none. After the speak in step 6, the ElevenLabs console's speech history should be empty within a few minutes. The Worker's Cron Events list each run; a failed run means the key cannot list or delete history. Recheck them for failed runs after any key change. 8. Confirm `/api/dev/emails`, `/dev/emails`, and `/__test/time` are absent, and check the live responses against the origin, caching, and cookie rules in `docs/specs/security-hosted.md` -> "Origin boundary". -9. Confirm the release smoke's one-time half passed against `relay.dormouse.sh`: `/connect/` answers the page under its own policy with its script beside it, and the rendezvous mints a room, is refused with a browser `Origin`, joins from the relay origin, crosses a frame each way, and is refused a second phone. Load `https://relay.dormouse.sh/connect/` in a browser and confirm no injected script or third-party request, and that `https://relay.dormouse.sh/` answers Pocket's shell under Pocket's policy. +9. Confirm the release smoke's one-time half passed: `https://try.dormouse.sh/connect/` answers the page under its own policy with its script beside it, `https://relay.dormouse.sh/connect/` answers only the 308 to it, and the rendezvous mints a room on the relay, is refused with a browser `Origin`, refuses a join from the relay's origin, joins from the try origin, crosses a frame each way, and is refused a second phone. Load `https://try.dormouse.sh/connect/` in a browser and confirm no injected script or third-party request, and that `https://relay.dormouse.sh/` answers Pocket's shell under Pocket's policy. 10. With a desktop build pointed at this origin, open a one-time link on a real iPhone in Safari and a real Android phone in Chrome, each on the same Wi-Fi as the laptop and each by scanning the QR code with the native camera, which must keep the fragment. Connect, type the digits, run a command, and End from the laptop. Then turn the phone's Wi-Fi off mid-session: both ends report the end. On a guest or client-isolated network the attempt ends on the same-Wi-Fi copy. Do not mark all five login methods complete until email and all four providers pass in real browsers; a simulated callback certifies nothing. No real-provider result is claimed by the initial implementation. diff --git a/hosted/package.json b/hosted/package.json index b0247d78c..85fc2e730 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "tsx server/dev.ts", "typecheck": "tsc --noEmit", - "build": "pnpm --filter dormouse-lib build:pocket && pnpm --filter dormouse-lib build:one-time && pnpm --filter dormouse-lib build:frame-origin && pnpm typecheck && vite build && node scripts/stage-relay.mjs && node scripts/stage-frames.mjs && wrangler deploy --dry-run --outdir dist-worker/account && wrangler deploy --dry-run --config wrangler.relay.jsonc --outdir dist-worker/relay && wrangler deploy --dry-run --config wrangler.voice.jsonc --outdir dist-worker/voice && wrangler deploy --dry-run --config wrangler.frames.jsonc --outdir dist-worker/frames", + "build": "pnpm --filter dormouse-lib build:pocket && pnpm --filter dormouse-lib build:one-time && pnpm --filter dormouse-lib build:frame-origin && pnpm typecheck && vite build && node scripts/stage-relay.mjs && node scripts/stage-try.mjs && node scripts/stage-frames.mjs && wrangler deploy --dry-run --outdir dist-worker/account && wrangler deploy --dry-run --config wrangler.relay.jsonc --outdir dist-worker/relay && wrangler deploy --dry-run --config wrangler.try.jsonc --outdir dist-worker/try && wrangler deploy --dry-run --config wrangler.voice.jsonc --outdir dist-worker/voice && wrangler deploy --dry-run --config wrangler.frames.jsonc --outdir dist-worker/frames", "pretest": "node ../scripts/build-workspace-deps.mjs", "test": "pnpm test:deploy && vitest run", "db:migrate": "tsx server/db.ts migrate", diff --git a/hosted/scripts/dev-one-time.mjs b/hosted/scripts/dev-one-time.mjs index 85af23b8a..e3797ca15 100644 --- a/hosted/scripts/dev-one-time.mjs +++ b/hosted/scripts/dev-one-time.mjs @@ -4,15 +4,17 @@ import { request } from "node:http"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ONE_TIME_BASE } from "../../lib/scripts/assert-pocket-worker.mjs"; -import { parseConfig } from "./workers.mjs"; +import { parseConfig, renameBoundScripts } from "./workers.mjs"; /** * The one-time rendezvous and phone page on loopback (`docs/specs/one-time.md` - * -> "Dev loop"): the relay Worker under `wrangler dev`, with its Durable - * Object and rate limits, serving the page `vite build --watch` rebuilds. Root `pnpm dev:one-time`; inside Dormouse, `dor tool - * one-time`. A loopback bind is not an access control, and this needs none of - * its own: the Worker's origin gate refuses any Host but the loopback origin, - * and the routes' Origin rules are the deployed ones. + * -> "Dev loop"): the relay and try Workers under `wrangler dev`, each on its + * own port as in production, sharing the try Worker's Durable Object through + * Wrangler's local registry, and the page `vite build --watch` rebuilds. Root + * `pnpm dev:one-time`; inside Dormouse, `dor tool one-time`. A loopback bind is + * not an access control, and this needs none of its own: each Worker's origin + * gate refuses any Host but its loopback origin, and the routes' Origin rules + * are the deployed ones. */ const hosted = fileURLToPath(new URL("../", import.meta.url)); @@ -32,15 +34,18 @@ function stop(code) { process.exitCode = code; } -/** Fixed rather than OS-assigned: the origin is baked into the Burrow build that dials it. */ +/** The relay's; fixed rather than OS-assigned: the origin is baked into the Burrow build that dials it. */ export const DEFAULT_PORT = 8787; -/** The port `PORT` names, or the default; anything else is refused before a process starts. */ +/** + * The relay's port, which `PORT` names, or the default; anything else is + * refused before a process starts. The try Worker takes the next one. + */ export function devPort(env) { if (env.PORT === undefined || env.PORT === "") return DEFAULT_PORT; const port = Number(env.PORT); - if (!/^\d+$/.test(env.PORT) || port < 1 || port > 65535) - throw new Error(`PORT must be a TCP port, not ${JSON.stringify(env.PORT)}`); + if (!/^\d+$/.test(env.PORT) || port < 1 || port >= 65535) + throw new Error(`PORT must be a TCP port with a free one after it, not ${JSON.stringify(env.PORT)}`); return port; } @@ -73,38 +78,52 @@ export function hostedAttachment(env) { /** A Hyperdrive id `wrangler dev --local` requires and never looks up. */ export const LOCAL_HYPERDRIVE_ID = "local-development"; +/** The dev script name of the Worker production calls `name`, so nothing here names a deployed one. */ +const devName = (name) => `${name}-dev`; + /** - * The Wrangler config the loop runs, written beside the staged page. - * Allowlisted from `hosted/wrangler.relay.jsonc` like the preview's: the relay - * entry, the rendezvous's Durable Object, migration, and rate limits, and the - * assets binding over the staging folder — never a route or a production - * secret, so it cannot answer for production. Its enrollment secret is - * {@link DEV_ENROLL_SECRET}, fixed and public. Attached to the Hosted dev loop - * ({@link hostedAttachment}), it also names that account origin and reaches - * its database through a local Hyperdrive, never production's. + * A Worker's Wrangler config, written beside the staged page. Allowlisted from + * its checked-in config like the preview's: its entry, its Durable Objects, + * another Worker's under its dev name, its migrations and rate limits, and the + * assets binding over `assets` — never a route or a production secret, so it + * cannot answer for production. */ -export function devConfig(base, port, attached = null) { - return { - name: `${base.name}-dev`, - main: "../../server/relay-worker.ts", - compatibility_date: base.compatibility_date, - compatibility_flags: base.compatibility_flags, - assets: { ...base.assets, directory: "./assets" }, - vars: { - APP_ORIGIN: devOrigin(port), - RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET, - ...(attached && { ACCOUNT_ORIGIN: attached.accountOrigin }), - }, - ...(attached && { - hyperdrive: [ - { binding: "HYPERDRIVE", id: LOCAL_HYPERDRIVE_ID, localConnectionString: attached.databaseUrl }, - ], - }), - durable_objects: base.durable_objects, - migrations: base.migrations, - ratelimits: base.ratelimits, - }; -} +const devWorkerConfig = (base, assets, vars) => ({ + name: devName(base.name), + main: `../../${base.main}`, + compatibility_date: base.compatibility_date, + compatibility_flags: base.compatibility_flags, + assets: { ...base.assets, directory: assets }, + vars, + durable_objects: renameBoundScripts(base.durable_objects, devName), + migrations: base.migrations, + ratelimits: base.ratelimits, +}); + +/** The try Worker's, over the folder the page rebuilds into. */ +export const devTryConfig = (base, port) => + devWorkerConfig(base, "./try-assets", { APP_ORIGIN: devOrigin(port) }); + +/** + * The relay Worker's, over assets holding nothing, since the loop builds no + * Pocket. Its enrollment secret is {@link DEV_ENROLL_SECRET}, fixed and public. + * Attached to the Hosted dev loop ({@link hostedAttachment}), it also names that + * account origin and reaches its database through a local Hyperdrive, never + * production's. + */ +export const devRelayConfig = (base, port, tryPort, attached = null) => ({ + ...devWorkerConfig(base, "./relay-assets", { + APP_ORIGIN: devOrigin(port), + TRY_ORIGIN: devOrigin(tryPort), + RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET, + ...(attached && { ACCOUNT_ORIGIN: attached.accountOrigin }), + }), + ...(attached && { + hyperdrive: [ + { binding: "HYPERDRIVE", id: LOCAL_HYPERDRIVE_ID, localConnectionString: attached.databaseUrl }, + ], + }), +}); /** One GET through the Worker, as a browser on the loopback origin sends it. */ function status(port, path) { @@ -139,15 +158,21 @@ async function waitFor(what, ready, timeoutMs) { async function main() { const port = devPort(process.env); - const origin = devOrigin(port); + const tryPort = port + 1; const attached = hostedAttachment(process.env); - const base = parseConfig(readFileSync(resolve(hosted, "wrangler.relay.jsonc"), "utf8")); - const pageDir = resolve(devDir, "assets", PAGE_PATH.slice(1)); + const read = (name) => parseConfig(readFileSync(resolve(hosted, name), "utf8")); + const pageDir = resolve(devDir, "try-assets", PAGE_PATH.slice(1)); // A page left by an earlier run would satisfy the first wait below. rmSync(devDir, { recursive: true, force: true }); mkdirSync(pageDir, { recursive: true }); - const configPath = resolve(devDir, "wrangler.json"); - writeFileSync(configPath, JSON.stringify(devConfig(base, port, attached), null, 2) + "\n"); + mkdirSync(resolve(devDir, "relay-assets"), { recursive: true }); + const writeConfig = (name, config) => { + const path = resolve(devDir, name); + writeFileSync(path, JSON.stringify(config, null, 2) + "\n"); + return path; + }; + const tryConfig = writeConfig("try.json", devTryConfig(read("wrangler.try.jsonc"), tryPort)); + const relayConfig = writeConfig("relay.json", devRelayConfig(read("wrangler.relay.jsonc"), port, tryPort, attached)); // Each package's own bin shim, which execs its tool, so a signal reaches it. const run = (name, args, cwd) => { @@ -177,28 +202,35 @@ async function main() { await waitFor("the first page build", () => existsSync(resolve(pageDir, "index.html")), 180_000); if (stopping) return; - run( - "wrangler", - [ - "dev", - "--config", configPath, - "--local", - "--ip", "127.0.0.1", - "--port", String(port), - "--local-protocol", "http", - "--show-interactive-dev-session=false", - ], - hosted, - ); - await waitFor("the Worker", async () => (await status(port, PAGE_PATH)) === 200, 120_000); + const wrangler = (config, workerPort) => + run( + "wrangler", + [ + "dev", + "--config", config, + "--local", + "--ip", "127.0.0.1", + "--port", String(workerPort), + "--local-protocol", "http", + "--show-interactive-dev-session=false", + ], + hosted, + ); + // The room's Worker first, which the relay binds through the local registry. + wrangler(tryConfig, tryPort); + await waitFor("the try Worker", async () => (await status(tryPort, PAGE_PATH)) === 200, 120_000); + if (stopping) return; + wrangler(relayConfig, port); + await waitFor("the relay Worker", async () => (await status(port, "/api/health")) === 200, 120_000); if (stopping) return; console.log( [ "", - `One-time rendezvous and phone page: ${origin}${PAGE_PATH}`, + `One-time phone page: ${devOrigin(tryPort)}${PAGE_PATH}`, + `Rendezvous Burrow route: ${devOrigin(port)}`, "Build a dev Burrow that opens its links here (e.g. `pnpm innerdogfood`) with:", - ` DORMOUSE_RELAY_ORIGIN=${origin}`, + ` DORMOUSE_RELAY_ORIGIN=${devOrigin(port)}`, // A local origin counts as Hosted only in a dev build // (docs/specs/relay.md -> "Relay origin"). " DORMOUSE_RELAY_IS_HOSTED=1", @@ -211,7 +243,7 @@ async function main() { // OSC 367 (docs/specs/dor-tool.md -> OSC 367): frame this port's page, not // Wrangler's inspector, which the same process tree also binds. process.stdout.write( - `\u001b]367;serve;${JSON.stringify({ v: 1, port, path: PAGE_PATH })}\u001b\\`, + `\u001b]367;serve;${JSON.stringify({ v: 1, port: tryPort, path: PAGE_PATH })}\u001b\\`, ); } diff --git a/hosted/scripts/dev-one-time.test.mjs b/hosted/scripts/dev-one-time.test.mjs index a281cc2bc..f2e558496 100644 --- a/hosted/scripts/dev-one-time.test.mjs +++ b/hosted/scripts/dev-one-time.test.mjs @@ -5,19 +5,21 @@ import { DEFAULT_PORT, DEV_ENROLL_SECRET, LOCAL_HYPERDRIVE_ID, - devConfig, + devTryConfig, devOrigin, devPort, + devRelayConfig, hostedAttachment, } from "./dev-one-time.mjs"; import { parseConfig } from "./workers.mjs"; -const base = parseConfig( - await readFile(new URL("../wrangler.relay.jsonc", import.meta.url), "utf8"), -); +const readBase = async (name) => + parseConfig(await readFile(new URL(`../${name}`, import.meta.url), "utf8")); +const base = await readBase("wrangler.relay.jsonc"); +const tryBase = await readBase("wrangler.try.jsonc"); -test("the dev config runs the relay entry on loopback, with the rendezvous and nothing of production's", () => { - const config = devConfig( +test("the relay's dev config runs its entry on loopback, with nothing of production's", () => { + const config = devRelayConfig( { ...base, vars: { ...base.vars, GOOGLE_CLIENT_SECRET: "do-not-copy" }, @@ -25,33 +27,56 @@ test("the dev config runs the relay entry on loopback, with the rendezvous and n d1_databases: [{ production: true }], }, 8787, + 8788, ); assert.equal(config.main, "../../server/relay-worker.ts"); assert.notEqual(config.name, base.name); assert.deepEqual(config.vars, { APP_ORIGIN: "http://localhost:8787", + TRY_ORIGIN: "http://localhost:8788", RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET, }); for (const key of ["routes", "hyperdrive", "d1_databases", "workers_dev"]) assert.equal(config[key], undefined, key); - assert.deepEqual(config.durable_objects, base.durable_objects); + // Its room is the try Worker's dev script, never a deployed one. + assert.deepEqual(config.durable_objects.bindings, [ + { name: "ONE_TIME_ROOM", class_name: "OneTimeRoom", script_name: devTryConfig(tryBase, 8788).name }, + { name: "RELAY_ROOM", class_name: "RelayRoom" }, + ]); assert.deepEqual(config.migrations, base.migrations); // The production namespaces: `wrangler dev --local` simulates them in memory. assert.deepEqual(config.ratelimits, base.ratelimits); - assert.deepEqual(config.assets, { ...base.assets, directory: "./assets" }); + assert.deepEqual(config.assets, { ...base.assets, directory: "./relay-assets" }); assert.equal(config.assets.run_worker_first, true); assert.equal(config.compatibility_date, base.compatibility_date); assert.deepEqual(config.compatibility_flags, base.compatibility_flags); }); +test("the try Worker's dev config runs its entry and the room, with nothing of production's", () => { + const config = devTryConfig( + { ...tryBase, vars: { ...tryBase.vars, GOOGLE_CLIENT_SECRET: "do-not-copy" }, d1_databases: [{ production: true }] }, + 8788, + ); + assert.equal(config.main, "../../server/try-worker.ts"); + assert.notEqual(config.name, tryBase.name); + assert.deepEqual(config.vars, { APP_ORIGIN: "http://localhost:8788" }); + for (const key of ["routes", "hyperdrive", "d1_databases", "workers_dev"]) + assert.equal(config[key], undefined, key); + assert.deepEqual(config.durable_objects, tryBase.durable_objects); + assert.deepEqual(config.migrations, tryBase.migrations); + assert.deepEqual(config.ratelimits, tryBase.ratelimits); + assert.deepEqual(config.assets, { ...tryBase.assets, directory: "./try-assets" }); +}); + test("the origin is loopback HTTP on the chosen port, which PORT names", () => { assert.equal(devPort({}), DEFAULT_PORT); assert.equal(devPort({ PORT: "" }), DEFAULT_PORT); assert.equal(devPort({ PORT: "9000" }), 9000); - for (const bad of ["0", "65536", "80a", "-1", "1e3", " 80"]) + // 65535 leaves no port for the try Worker. + for (const bad of ["0", "65535", "65536", "80a", "-1", "1e3", " 80"]) assert.throws(() => devPort({ PORT: bad }), /TCP port/, bad); assert.equal(devOrigin(9000), "http://localhost:9000"); - assert.equal(devConfig(base, 9000).vars.APP_ORIGIN, "http://localhost:9000"); + assert.equal(devRelayConfig(base, 9000, 9001).vars.APP_ORIGIN, "http://localhost:9000"); }); test("it attaches to the Hosted dev loop with both its account origin and its database, or neither", () => { @@ -70,13 +95,15 @@ test("it attaches to the Hosted dev loop with both its account origin and its da }); test("attached, it approves at that account origin and reads its database, never production's", () => { - const config = devConfig( + const config = devRelayConfig( { ...base, hyperdrive: [{ binding: "HYPERDRIVE", id: "0".repeat(32) }] }, 8787, + 8788, { accountOrigin: "http://localhost:5000", databaseUrl: "postgresql://dev@localhost:5432/dev" }, ); assert.deepEqual(config.vars, { APP_ORIGIN: "http://localhost:8787", + TRY_ORIGIN: "http://localhost:8788", RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET, ACCOUNT_ORIGIN: "http://localhost:5000", }); diff --git a/hosted/scripts/one-time-smoke.mjs b/hosted/scripts/one-time-smoke.mjs index ecdcb8ca3..dde070f53 100644 --- a/hosted/scripts/one-time-smoke.mjs +++ b/hosted/scripts/one-time-smoke.mjs @@ -18,31 +18,37 @@ const TIMEOUT_MS = 15_000; const open = (url, headers) => new WebSocket(url, { headers }); /** - * The live rendezvous: the phone page is served under its own policy with its - * script beside it, a Burrow can mint, a browser Origin cannot, a phone from - * `origin` joins and a frame crosses each way unchanged, and a second phone is - * refused as taken. `connect` and `fetchPage` stand in for the network in tests. + * The live rendezvous across its two Workers: a link an older build shows moves + * from `relay` to the page at `tryOrigin`, a Burrow can mint on `relay`, a browser + * Origin cannot, a phone from `tryOrigin` joins there and a frame crosses each way + * unchanged, a page on `relay` cannot join, and a second phone is refused as + * taken. `ws` and `fetchPage` stand in for the network in tests. */ -export async function oneTimeSmoke(origin, connect = open, fetchPage = fetch) { - await pageSmoke(origin, fetchPage); - const base = origin.replace(/^http/, "ws"); - const burrowUrl = base + ONE_TIME_WS_ROUTES.burrow; - const burrow = watch(connect(burrowUrl, {})); +export async function oneTimeSmoke(relay, tryOrigin, ws = open, fetchPage = fetch) { + await movedSmoke(relay, tryOrigin, fetchPage); + const burrowUrl = relay.replace(/^http/, "ws") + ONE_TIME_WS_ROUTES.burrow; + const burrow = watch(ws(burrowUrl, {})); try { const frame = JSON.parse(await burrow.next()); assert.equal(frame.t, "one-time-room", "The room announces itself first"); assert.match(frame.roomId, /^[A-Za-z0-9_-]{22}$/); assert.ok(frame.expiresAt > Date.now(), "A fresh room has not expired"); - const page = watch(connect(burrowUrl, { origin })); + const page = watch(ws(burrowUrl, { origin: tryOrigin })); assert.equal( await page.settled(), false, "A browser Origin must not mint a room", ); - const clientUrl = `${base}${ONE_TIME_WS_ROUTES.client}?${ONE_TIME_ROOM_PARAM}=${frame.roomId}`; - const phone = watch(connect(clientUrl, { origin })); + const clientUrl = `${tryOrigin.replace(/^http/, "ws")}${ONE_TIME_WS_ROUTES.client}?${ONE_TIME_ROOM_PARAM}=${frame.roomId}`; + const pocket = watch(ws(clientUrl, { origin: relay })); + assert.equal( + await pocket.settled(), + false, + "A page on the relay's origin must not join a room", + ); + const phone = watch(ws(clientUrl, { origin: tryOrigin })); try { assert.equal(await phone.settled(), true, "The phone joins its room"); const marker = `smoke ${crypto.randomUUID()} — not JSON`; @@ -54,7 +60,7 @@ export async function oneTimeSmoke(origin, connect = open, fetchPage = fetch) { marker.toUpperCase(), "Burrow → phone, verbatim", ); - const second = watch(connect(clientUrl, { origin })); + const second = watch(ws(clientUrl, { origin: tryOrigin })); assert.equal( (await second.closed()).code, WS_CLOSE_ONE_TIME_TAKEN, @@ -68,14 +74,25 @@ export async function oneTimeSmoke(origin, connect = open, fetchPage = fetch) { } } +/** The relay renders no page at the old path: it answers a redirect to the page's own origin. */ +async function movedSmoke(relay, tryOrigin, fetchPage) { + const moved = await fetchPage(relay + ONE_TIME_PAGE_PATH, { redirect: "manual" }); + assert.equal(moved.status, 308, "The relay moves an older build's link"); + assert.equal( + moved.headers.get("location"), + tryOrigin + ONE_TIME_PAGE_PATH, + "An older build's link moves to the page's own origin", + ); +} + /** * `/connect/` answers the page under its path-scoped policy — scripts from its * own assets, the client route its one socket — and the script the shell names - * is there, as a content-hashed asset rather than the SPA fallback. + * is there, as a content-hashed asset rather than a fallback. */ -async function pageSmoke(origin, fetchPage) { +export async function oneTimePageSmoke(origin, fetchPage = fetch) { const page = await fetchPage(origin + ONE_TIME_PAGE_PATH); - assert.equal(page.status, 200, "The relay serves the one-time page"); + assert.equal(page.status, 200, "The try Worker serves the one-time page"); assert.match(page.headers.get("content-type") ?? "", /text\/html/); const policy = page.headers.get("content-security-policy") ?? ""; for (const directive of [ diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs index 8a346e6db..dd01a651b 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { realpathSync } from "node:fs"; import { providerAuthorizationOrigins } from "../server/providers.js"; import { FRAME_WORKER_PATH } from "../../remote-lib-common/src/remote/frame-origin-paths.ts"; -import { oneTimeSmoke } from "./one-time-smoke.mjs"; +import { oneTimePageSmoke, oneTimeSmoke } from "./one-time-smoke.mjs"; /** What the account's page sends with a login read (`docs/specs/security-hosted.md` -> "Origin boundary"). */ const OWN_PAGE = { headers: { "sec-fetch-site": "same-origin" } }; @@ -310,12 +310,12 @@ async function retrying(limit, what, check, { retryMs, wait }) { /** * A frame origin at `domain`, under a label no phone mints: it serves the - * worker, framed only by `clientOrigin` and HTTPS-only, and nothing else. The + * worker, framed only by `clientOrigins` and HTTPS-only, and nothing else. The * worker's body proves the revision, since this Worker holds no `BUILD_SHA`. */ export async function frameSmoke( domain, - clientOrigin, + clientOrigins, { fetcher = fetch, attempts = 1, @@ -330,7 +330,7 @@ export async function frameSmoke( assert.equal(served.status, 200, `${origin} must serve its worker`); assert.equal(await served.text(), await worker(), `${origin} must serve this release's worker`); assert.match(served.headers.get("content-type") ?? "", /^text\/javascript/); - assert.match(served.headers.get("content-security-policy") ?? "", new RegExp(`frame-ancestors ${clientOrigin}$`)); + assert.match(served.headers.get("content-security-policy") ?? "", new RegExp(`frame-ancestors ${clientOrigins}$`)); assert.match(served.headers.get("strict-transport-security") ?? "", /includeSubDomains/); assert.equal(served.headers.get("set-cookie"), null, `${origin} must set no cookie`); const other = await fetcher(`${origin}/`); @@ -349,15 +349,32 @@ async function liveSmoke(origin, sha, fetcher, attempts, retry) { } /** - * The relay's smoke: `liveSmoke`, then its push config and its one-time - * rendezvous, each retried on its own the same way. + * The try Worker's smoke: its revision, then the phone page, each retried + * on its own. It has no readiness, holding no database, and the rendezvous is + * `relaySmoke`'s, since a room is minted on the relay. + */ +export async function trySmoke( + origin, + sha, + { fetcher = fetch, attempts = 1, retryMs = 10_000, wait = delay } = {}, +) { + const retry = { retryMs, wait }; + await retrying(attempts, origin, () => healthSmoke(origin, sha, fetcher), retry); + await retrying(attempts, `${origin} page`, () => oneTimePageSmoke(origin, fetcher), retry); +} + +/** + * The relay's smoke: `liveSmoke`, then its push config and the one-time + * rendezvous it mints for the page at `tryOrigin`, each retried on its own the + * same way. */ export async function relaySmoke( origin, + tryOrigin, sha, { fetcher = fetch, - oneTime = (origin) => oneTimeSmoke(origin), + oneTime = oneTimeSmoke, attempts = 1, retryMs = 10_000, wait = delay, @@ -366,25 +383,25 @@ export async function relaySmoke( const retry = { retryMs, wait }; await liveSmoke(origin, sha, fetcher, attempts, retry); await retrying(attempts, `${origin} push`, () => pushConfigSmoke(origin, fetcher), retry); - await retrying(attempts, `${origin} one-time`, () => oneTime(origin), retry); + await retrying(attempts, `${origin} one-time`, () => oneTime(origin, tryOrigin), retry); } /** * Every Worker's smoke: the account's auth boundary, the voice's `liveSmoke`, - * and `relaySmoke` — never waiting on the account, so an account failure hides - * no relay one. The three run concurrently, each retrying on its own up to its - * `attempts` (one count for all, or `{ account, relay, voice }`), `retryMs` - * apart. Every part settles before the smoke fails, and the failure names each - * part that failed. + * `trySmoke`, and `relaySmoke` — never waiting on the account, so an account + * failure hides no relay one. They run concurrently, each retrying on its own up + * to its `attempts` (one count for all, or `{ account, relay, voice, try }`), + * `retryMs` apart. Every part settles before the smoke fails, and the failure + * names each part that failed. */ export async function smokeAll( - { account, relay, voice }, + { account, relay, voice, try: tryOrigin }, sha, { fetcher = fetch, preview = false, providers = [], - oneTime = (origin) => oneTimeSmoke(origin), + oneTime, attempts = 1, retryMs = 10_000, wait = delay, @@ -396,8 +413,9 @@ export async function smokeAll( const results = await Promise.allSettled([ retrying(limit("account"), account, () => smoke(account, sha, fetcher, preview, providers), retry), - relaySmoke(relay, sha, { fetcher, oneTime, attempts: limit("relay"), retryMs, wait }), + relaySmoke(relay, tryOrigin, sha, { fetcher, oneTime, attempts: limit("relay"), retryMs, wait }), liveSmoke(voice, sha, fetcher, limit("voice"), retry), + trySmoke(tryOrigin, sha, { fetcher, attempts: limit("try"), retryMs, wait }), ]); const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [], @@ -413,10 +431,11 @@ if ( ) { const [origins, sha] = process.argv.slice(2); assert.match(sha ?? "", /^[a-f0-9]{40}$/); - const { account, relay, voice } = JSON.parse(origins); + const parsed = JSON.parse(origins); + const { account, voice, try: tryOrigin } = parsed; // A just-uploaded Worker may take a short time to become reachable everywhere. - await smokeAll({ account, relay, voice }, sha, { preview: true, attempts: 6 }); + await smokeAll(parsed, sha, { preview: true, attempts: 6 }); console.log( - `Preview smoke checks passed: ${account}/login, ${relay}/connect/, ${voice} (${sha})`, + `Preview smoke checks passed: ${account}/login, ${tryOrigin}/connect/, ${voice} (${sha})`, ); } diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs index 06308434a..704b97981 100644 --- a/hosted/scripts/preview.mjs +++ b/hosted/scripts/preview.mjs @@ -3,7 +3,7 @@ import { writeFile, mkdir, appendFile, rm } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { realpathSync } from "node:fs"; import { vapidKeysFrom } from "./vapid.mjs"; -import { APP_WORKERS, deployWorkers, fromStage, readConfigs } from "./workers.mjs"; +import { APP_WORKERS, deployWorkers, fromStage, readConfigs, renameBoundScripts } from "./workers.mjs"; export function required(env, name, section = "Provision PR previews") { if (!env[name]) @@ -37,7 +37,7 @@ export function previewConfig(base, env, worker, hyperdriveId) { throw new Error("BUILD_SHA must be a commit SHA"); const config = { name, - main: fromStage(APP_WORKERS[worker].previewMain), + main: fromStage(APP_WORKERS[worker].previewMain ?? base.main), compatibility_date: base.compatibility_date, compatibility_flags: base.compatibility_flags, workers_dev: true, @@ -61,13 +61,10 @@ export function previewConfig(base, env, worker, hyperdriveId) { // production's. Migrations travel only with the Durable Objects a Worker // implements: one that implements none starts with none to delete. if (base.durable_objects) { - const bindings = base.durable_objects.bindings.map((binding) => - binding.script_name - ? { ...binding, script_name: previewName(env.PR_NUMBER, binding.script_name) } - : binding, + config.durable_objects = renameBoundScripts(base.durable_objects, (script) => + previewName(env.PR_NUMBER, script), ); - config.durable_objects = { ...base.durable_objects, bindings }; - if (base.migrations && bindings.some((binding) => !binding.script_name)) + if (base.migrations && base.durable_objects.bindings.some((binding) => !binding.script_name)) config.migrations = base.migrations; } if (base.ratelimits) @@ -79,10 +76,11 @@ export function previewConfig(base, env, worker, hyperdriveId) { } /** - * Every application Worker's preview config, keyed as `APP_WORKERS` is; all three share the - * PR's one Hyperdrive, unlike production's one per Worker + * Every application Worker's preview config, keyed as `APP_WORKERS` is; those + * that bind a Hyperdrive share the PR's one, unlike production's one per Worker * (`docs/specs/hosted.md` -> "PR previews"). The relay's enrollment links name - * the account preview, never production's account. + * the account preview and an older link's redirect the try preview, never + * production's. */ export function previewConfigs(bases, env, hyperdriveId) { const configs = Object.fromEntries( @@ -92,6 +90,7 @@ export function previewConfigs(bases, env, hyperdriveId) { ]), ); configs.relay.vars.ACCOUNT_ORIGIN = configs.account.vars.APP_ORIGIN; + configs.relay.vars.TRY_ORIGIN = configs.try.vars.APP_ORIGIN; return configs; } @@ -238,7 +237,7 @@ export const originsOf = (configs) => /** * Validates every preview config, upserts the PR's Hyperdrive, writes each * Worker's secrets file, and emits the origins: `url` (the account's, for the - * environment link) and `origins` (all three as JSON, for the smoke). + * environment link) and `origins` (all of them as JSON, for the smoke). */ export async function prepare(env = process.env) { const bases = await readConfigs(); @@ -272,7 +271,7 @@ export async function prepare(env = process.env) { if (env.GITHUB_STEP_SUMMARY) await appendFile( env.GITHUB_STEP_SUMMARY, - `Preview target: ${origins.account}/login\n\nOne-time page: ${origins.relay}/connect/\n\nVoice: ${origins.voice}\n\nRevision: ${env.BUILD_SHA}\n\nCaptured email inbox: ${origins.account}/dev/emails. No real email is sent. Deployment and smoke checks must succeed below.\n`, + `Preview target: ${origins.account}/login\n\nOne-time page: ${origins.try}/connect/\n\nVoice: ${origins.voice}\n\nRevision: ${env.BUILD_SHA}\n\nCaptured email inbox: ${origins.account}/dev/emails. No real email is sent. Deployment and smoke checks must succeed below.\n`, ); console.log(`Prepared ${Object.values(origins).join(", ")}`); return configs; diff --git a/hosted/scripts/preview.test.mjs b/hosted/scripts/preview.test.mjs index 70ba7fec9..c2d698004 100644 --- a/hosted/scripts/preview.test.mjs +++ b/hosted/scripts/preview.test.mjs @@ -49,6 +49,7 @@ test("each preview configuration isolates its origin and excludes production bin account: stale(bases.account), relay: stale(bases.relay), voice: stale(bases.voice), + try: stale(bases.try), }, env, "c".repeat(32), @@ -57,6 +58,7 @@ test("each preview configuration isolates its origin and excludes production bin account: ["dormouse-hosted-pr-42", "../../server/preview-worker.ts"], relay: ["dormouse-relay-pr-42", "../../server/relay-worker.ts"], voice: ["dormouse-voice-pr-42", "../../server/voice-preview-worker.ts"], + try: ["dormouse-try-pr-42", "../../server/try-worker.ts"], }; for (const [worker, [name, main]] of Object.entries(expected)) { const config = configs[worker]; @@ -67,9 +69,11 @@ test("each preview configuration isolates its origin and excludes production bin { APP_ORIGIN: `https://${name}.hosted-tests.workers.dev`, BUILD_SHA: env.BUILD_SHA, - // The relay's enrollment links name this PR's account preview. + // The relay's enrollment links name this PR's account preview, and an + // older link's redirect this PR's page, never production's. ...(worker === "relay" && { ACCOUNT_ORIGIN: "https://dormouse-hosted-pr-42.hosted-tests.workers.dev", + TRY_ORIGIN: "https://dormouse-try-pr-42.hosted-tests.workers.dev", }), }, worker, @@ -81,7 +85,8 @@ test("each preview configuration isolates its origin and excludes production bin } // Production alone sweeps ElevenLabs history, on the voice Worker; a preview has no cron. assert.ok(bases.voice.triggers?.crons?.length); - // All three previews share one database. + // Every preview that binds one shares one database; the page's binds none. + assert.equal(configs.try.hyperdrive, undefined); assert.deepEqual(configs.account.hyperdrive, [{ binding: "HYPERDRIVE", id: "c".repeat(32) }]); assert.deepEqual(configs.voice.hyperdrive, configs.account.hyperdrive); assert.deepEqual(configs.relay.hyperdrive, configs.account.hyperdrive); @@ -98,9 +103,16 @@ test("each preview configuration isolates its origin and excludes production bin assert.deepEqual(configs.account.durable_objects, { bindings: [{ name: "RELAY_ROOM", class_name: "RelayRoom", script_name: "dormouse-relay-pr-42" }], }); - assert.deepEqual(configs.relay.durable_objects, bases.relay.durable_objects); + // The relay's room binding names this PR's try preview, which implements it. + assert.deepEqual(configs.relay.durable_objects.bindings, [ + { name: "ONE_TIME_ROOM", class_name: "OneTimeRoom", script_name: "dormouse-try-pr-42" }, + { name: "RELAY_ROOM", class_name: "RelayRoom" }, + ]); assert.deepEqual(configs.relay.migrations, bases.relay.migrations); - for (const worker of ["relay", "account"]) + assert.deepEqual(configs.try.durable_objects, bases.try.durable_objects); + assert.deepEqual(configs.try.migrations, bases.try.migrations); + assert.equal(configs.try.assets.directory, "../../dist/try"); + for (const worker of ["relay", "account", "try"]) assert.deepEqual( configs[worker].ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), bases[worker].ratelimits.map(({ name, namespace_id }) => [ @@ -130,6 +142,7 @@ test("preview configuration keeps its own Durable Objects and rate-limit namespa const config = previewConfig( { name: "dormouse-relay", + main: "server/relay-worker.ts", compatibility_date: "2026-01-01", assets: { directory: "./dist/relay" }, durable_objects, @@ -314,14 +327,14 @@ test("cleanup only deletes this PR's resources and can run twice", async (t) => await cleanup(env); existing = false; await cleanup(env); - const workers = ["dormouse-relay-pr-42", "dormouse-voice-pr-42", "dormouse-hosted-pr-42"]; + const workers = ["dormouse-try-pr-42", "dormouse-relay-pr-42", "dormouse-voice-pr-42", "dormouse-hosted-pr-42"]; assert.deepEqual( removed.map((path) => path.split("/").pop()), [...workers, "ours", "br-ours", ...workers], ); assert.deepEqual( forced, - Array(6).fill(true), + Array(8).fill(true), "a Worker implementing a Durable Object is deleted with force", ); }); @@ -407,7 +420,7 @@ test("sweep cleans up only other PRs that are closed or unlabeled", async (t) => }); await sweep({ ...env, GITHUB_REPOSITORY: "diffplug/dormouse", GH_TOKEN: "dummy-gh-token" }); const workers = (pr) => - ["dormouse-relay", "dormouse-voice", "dormouse-hosted"].map((name) => `${name}-pr-${pr}`); + ["dormouse-try", "dormouse-relay", "dormouse-voice", "dormouse-hosted"].map((name) => `${name}-pr-${pr}`); assert.deepEqual(removed, [...workers(7), "br-7", ...workers(9), "hd-9"]); }); @@ -446,6 +459,7 @@ test("the smoke runs its parts concurrently, retries each alone, and checks read account: "https://account.example.test", relay: "https://relay.example.test", voice: "https://voice.example.test", + try: "https://try.example.test", }; const events = []; // The relay's VAPID key, or null while push is off. @@ -457,6 +471,22 @@ test("the smoke runs its parts concurrently, retries each alone, and checks read // Every request the production smoke makes, answered as a healthy account would. const fetcher = async (url, init = {}) => { const { origin, pathname } = new URL(url); + if (origin === origins.try && pathname === "/connect/") + return new Response('', { + headers: { + "content-type": "text/html", + "content-security-policy": [ + `script-src ${origins.try}/connect/assets/ 'wasm-unsafe-eval'`, + "connect-src wss://try.example.test/api/one-time/client", + "frame-src https://*.dormouseusercontent.com", + "sandbox allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads allow-popups-to-escape-sandbox", + ].join("; "), + }, + }); + if (origin === origins.try && pathname.startsWith("/connect/assets/")) + return new Response("export {};", { + headers: { "content-type": "text/javascript", "cache-control": "public, max-age=31536000, immutable" }, + }); if (pathname === "/api/health") { events.push(`${origin} health`); if (unhealthy[origin] > 0) { @@ -497,8 +527,9 @@ test("the smoke runs its parts concurrently, retries each alone, and checks read return new Response(null, { status: 404 }); }; const count = (event) => events.filter((e) => e === event).length; - const oneTime = async (origin) => { + const oneTime = async (origin, page) => { assert.equal(origin, origins.relay); + assert.equal(page, origins.try); events.push("one-time"); }; const waits = []; @@ -516,11 +547,13 @@ test("the smoke runs its parts concurrently, retries each alone, and checks read assert.equal(count(`${origins.relay} health`), 2); assert.equal(count("one-time"), 1); assert.equal(count("push"), 1); - for (const origin of Object.values(origins)) assert.equal(count(`${origin} ready`), 1, origin); + // Holding no database, the page's Worker reports no readiness. + assert.equal(count(`${origins.try} ready`), 0); + for (const origin of [origins.account, origins.relay, origins.voice]) assert.equal(count(`${origin} ready`), 1, origin); assert.ok(events.indexOf(`${origins.relay} ready`) > events.lastIndexOf(`${origins.relay} health`)); assert.ok(events.indexOf("push") > events.indexOf(`${origins.relay} ready`)); assert.ok(events.indexOf(`${origins.voice} ready`) > events.indexOf(`${origins.voice} health`)); - assert.equal(events.at(-1), "one-time"); + assert.ok(events.includes("one-time")); // A relay or voice whose role lacks a grant fails the smoke; the relay's // push and rendezvous never run on it. diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs index 8e927d369..d23df05fb 100644 --- a/hosted/scripts/production.mjs +++ b/hosted/scripts/production.mjs @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { realpathSync } from "node:fs"; import { required, cloudflare, hyperdriveOrigin, originsOf } from "./preview.mjs"; -import { frameSmoke, relaySmoke, smokeAll } from "./preview-smoke.mjs"; +import { trySmoke, frameSmoke, relaySmoke, smokeAll } from "./preview-smoke.mjs"; import { HOSTED_FRAME_DOMAIN } from "../../remote-lib-common/src/remote/pocket-deployment.ts"; import { WORKERS, @@ -18,7 +18,7 @@ import { * cannot redirect production or stand in for a sibling, and its Hyperdrive: * the GitHub variable naming it and the Postgres role it must connect as — * the relay's and voice's created by `hosted/server/runtime-roles.sql`, the - * account's by hand (hosted/README.md). + * account's by hand (hosted/README.md). The try Worker has none. */ export const PRODUCTION = { account: { @@ -39,13 +39,20 @@ export const PRODUCTION = { hyperdrive: "VOICE_HYPERDRIVE_ID", role: "dormouse_voice", }, + try: { + name: "dormouse-try", + origin: "https://try.dormouse.sh", + }, }; +/** The Workers `PRODUCTION` gives a Hyperdrive and a role. */ +const hyperdriveWorkers = Object.entries(PRODUCTION).filter(([, { hyperdrive }]) => hyperdrive); + /** The frame Worker's identity, on a domain nothing else of Hosted's is under. */ export const FRAMES = { name: "dormouse-frames", domain: HOSTED_FRAME_DOMAIN, - clientOrigin: PRODUCTION.relay.origin, + clientOrigins: `${PRODUCTION.relay.origin} ${PRODUCTION.try.origin}`, }; /** Its whole config: anything more, a binding above all, it must not have. */ @@ -61,7 +68,7 @@ function framesConfig(base) { assert.equal(base.name, FRAMES.name); for (const key of Object.keys(base)) assert.ok(FRAMES_CONFIG_KEYS.has(key), `${FRAMES.name} must not carry ${key}`); - assert.deepEqual(base.vars, { CLIENT_ORIGIN: FRAMES.clientOrigin }); + assert.deepEqual(base.vars, { CLIENT_ORIGINS: FRAMES.clientOrigins }); assert.deepEqual(base.routes, [{ pattern: `*.${FRAMES.domain}/*`, zone_name: FRAMES.domain }]); assert.equal(base.assets.binding, "ASSETS"); assert.equal(base.workers_dev, false); @@ -82,9 +89,12 @@ export function productionConfig(base, env, worker) { assert.match(need(env, "CLOUDFLARE_ACCOUNT_ID"), /^[a-f0-9]{32}$/); assert.equal(base.name, identity.name); assert.equal(base.vars.APP_ORIGIN, identity.origin); - // The relay's enrollment links name production's account, and only it. - if (worker === "relay") + // The relay's enrollment links name production's account, and only it, and + // an older build's one-time link moves to production's page, and only it. + if (worker === "relay") { assert.equal(base.vars.ACCOUNT_ORIGIN, PRODUCTION.account.origin); + assert.equal(base.vars.TRY_ORIGIN, PRODUCTION.try.origin); + } // The canonical domain alone: no public alias, candidate, or preview URL. assert.deepEqual(base.routes, [ { pattern: new URL(identity.origin).host, custom_domain: true }, @@ -100,6 +110,10 @@ export function productionConfig(base, env, worker) { if (base.assets) config.assets = { ...base.assets, directory: fromStage(base.assets.directory) }; const variable = identity.hyperdrive; + if (!variable) { + assert.equal(base.hyperdrive, undefined, `${identity.name} must not bind a Hyperdrive`); + return config; + } const id = need(env, variable); assert.match(id, /^[a-f0-9]{32}$/, `${variable} must be a Hyperdrive ID`); assert.notEqual(id, "0".repeat(32), `Provision production Hyperdrive first: ${variable}`); @@ -113,7 +127,7 @@ export function productionConfig(base, env, worker) { */ function hyperdriveIds(configs) { const ids = Object.fromEntries( - Object.keys(PRODUCTION).map((worker) => { + hyperdriveWorkers.map(([worker]) => { const bindings = configs[worker].hyperdrive ?? []; assert.equal( bindings.length, @@ -123,7 +137,7 @@ function hyperdriveIds(configs) { return [worker, bindings[0].id]; }), ); - const variables = Object.values(PRODUCTION).map(({ hyperdrive }) => hyperdrive); + const variables = hyperdriveWorkers.map(([, { hyperdrive }]) => hyperdrive); assert.equal( new Set(Object.values(ids)).size, variables.length, @@ -156,16 +170,17 @@ export function requiredSecrets(configs) { ); } /** - * The relay and voice retry as a preview's parts do, since a first release - * attaches their custom domains and a new certificate can outlast the health - * retry; the account's smoke sends POSTs, so it runs once. + * The relay, voice, and try Workers retry as a preview's parts do, since a + * first release attaches their custom domains and a new certificate can outlast + * the health retry; the account's smoke sends POSTs, so it runs once. */ -const ATTEMPTS = { account: 1, relay: 6, voice: 6 }; +const ATTEMPTS = { account: 1, relay: 6, voice: 6, try: 6 }; /** - * Deploys frames, relay, voice, then account, stopping at a failure. The relay - * passes `relaySmoke` before anything after it deploys, so the account's `v2` - * deleting the old `OneTimeRoom` never runs while the replacement is unproven. + * Deploys frames, try, relay, voice, then account, stopping at a failure. + * The try Worker passes `trySmoke` before the relay deploys, so a link + * an older build shows is never redirected to a page not yet served, and the + * relay passes `relaySmoke` before anything after it deploys. * `options` stands in for the process and network in tests. */ export function deployProduction( @@ -177,9 +192,14 @@ export function deployProduction( spawn, afterDeploy: async (worker) => { if (worker === "frames") - await frameSmoke(FRAMES.domain, FRAMES.clientOrigin, { attempts: ATTEMPTS.relay, ...options }); + await frameSmoke(FRAMES.domain, FRAMES.clientOrigins, { attempts: ATTEMPTS.relay, ...options }); + if (worker === "try") + await trySmoke(configs.try.vars.APP_ORIGIN, sha, { + attempts: ATTEMPTS.try, + ...options, + }); if (worker === "relay") - await relaySmoke(configs.relay.vars.APP_ORIGIN, sha, { + await relaySmoke(configs.relay.vars.APP_ORIGIN, configs.try.vars.APP_ORIGIN, sha, { attempts: ATTEMPTS.relay, ...options, }); @@ -189,7 +209,7 @@ export function deployProduction( /** The release's live verification, of all of them once the account deployed. */ export async function productionSmoke(configs, sha, options = {}) { - await frameSmoke(FRAMES.domain, FRAMES.clientOrigin, { attempts: ATTEMPTS.relay, ...options }); + await frameSmoke(FRAMES.domain, FRAMES.clientOrigins, { attempts: ATTEMPTS.relay, ...options }); return smokeAll(originsOf(configs), sha, { providers: oauthProviders(configs.account), attempts: ATTEMPTS, @@ -236,7 +256,7 @@ export async function verifyPackages(readProvenance = installedProvenance) { export async function preflight(env, configs, api = cloudflare(env)) { const ids = hyperdriveIds(configs); const origin = hyperdriveOrigin(need(env, "DATABASE_URL")); - for (const [worker, { role }] of Object.entries(PRODUCTION)) { + for (const [worker, { role }] of hyperdriveWorkers) { const { result } = await api(`hyperdrive/configs/${ids[worker]}`); const name = `${configs[worker].name}'s Hyperdrive`; assert.equal(result.caching?.disabled, true, `${name} must disable caching`); diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs index 2889f1be6..c5c0bf9a1 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -22,12 +22,31 @@ const frameOrigin = (change = {}) => async (url) => { return new Response(change.body ?? "worker", { headers: { "content-type": "text/javascript; charset=utf-8", - "content-security-policy": `default-src 'none'; frame-ancestors ${change.ancestors ?? "https://relay.dormouse.sh"}`, + "content-security-policy": `default-src 'none'; frame-ancestors ${change.ancestors ?? "https://relay.dormouse.sh https://try.dormouse.sh"}`, ...(change.hsts === false ? {} : { "strict-transport-security": "max-age=31536000; includeSubDomains" }), ...(change.cookie ? { "set-cookie": "a=b" } : {}), }, }); }; +/** The try Worker's page, or its script, as a served release answers them. */ +const tryPage = (served, url = "") => + !served + ? new Response("", { status: 404 }) + : url.includes("/assets/") + ? new Response("export {};", { + headers: { "content-type": "text/javascript", "cache-control": "public, max-age=31536000, immutable" }, + }) + : new Response('', { + headers: { + "content-type": "text/html", + "content-security-policy": [ + "script-src https://try.dormouse.sh/connect/assets/ 'wasm-unsafe-eval'", + "connect-src wss://try.dormouse.sh/api/one-time/client", + "frame-src https://*.dormouseusercontent.com", + "sandbox allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads allow-popups-to-escape-sandbox", + ].join("; "), + }, + }); /** `fetcher`, with the frame origin answered as {@link frameOrigin}. */ const withFrames = (fetcher, change) => async (url) => new URL(url).hostname.endsWith(".dormouseusercontent.com") ? frameOrigin(change)(url) : fetcher(url); @@ -56,6 +75,7 @@ test("each production config keeps its canonical domain and production entry, an account: ["dormouse-hosted", "https://hosted.dormouse.sh", "../../server/worker.ts"], relay: ["dormouse-relay", "https://relay.dormouse.sh", "../../server/relay-worker.ts"], voice: ["dormouse-voice", "https://voice.dormouse.sh", "../../server/voice-worker.ts"], + try: ["dormouse-try", "https://try.dormouse.sh", "../../server/try-worker.ts"], }; for (const [worker, [name, origin, main]] of Object.entries(expected)) { const config = configs[worker]; @@ -91,6 +111,22 @@ test("each production config keeps its canonical domain and production entry, an "relay", ), ); + // An older build's one-time link moves to production's page, and no other. + assert.equal(configs.relay.vars.TRY_ORIGIN, "https://try.dormouse.sh"); + for (const elsewhere of [undefined, "https://evil.example.test", "https://relay.dormouse.sh"]) + assert.throws(() => + productionConfig( + { ...bases.relay, vars: { ...bases.relay.vars, TRY_ORIGIN: elsewhere } }, + env, + "relay", + ), + ); + // The page's Worker binds no database, whatever a config says. + assert.equal(configs.try.hyperdrive, undefined); + assert.throws( + () => productionConfig({ ...bases.try, hyperdrive: [{ binding: "HYPERDRIVE", id: "a".repeat(32) }] }, env, "try"), + /must not bind a Hyperdrive/, + ); // Each config is its own Worker's: none stands in for a sibling. assert.throws(() => productionConfig(bases.relay, env, "account")); assert.throws(() => productionConfig(bases.account, env, "voice")); @@ -137,7 +173,7 @@ test("the release's deploy job hands every step each Worker's Hyperdrive variabl for (const [variable] of Object.values(HYPERDRIVES)) assert.ok(deploy.includes(`\n ${variable}: \${{ vars.${variable} }}\n`), variable); }); -test("Workers deploy frames, relay, voice, then account from one config path each, and a failure stops the rest", async (t) => { +test("Workers deploy frames, try, relay, voice, then account from one config path each, and a failure stops the rest", async (t) => { t.after(() => rm(new URL("../.wrangler/deploy-test/", import.meta.url), { recursive: true, force: true }), ); @@ -152,9 +188,10 @@ test("Workers deploy frames, relay, voice, then account from one config path eac await deployWorkers("deploy-test", configs, { spawn: spawn() }); assert.deepEqual( deployed.map(([name, path]) => [name, path.split("/").slice(-3).join("/")]), - // The account last: its `v2` deletes the `OneTimeRoom` the relay replaces. + // The try Worker before the relay, whose room binding and redirect name it. [ ["dormouse-frames", ".wrangler/deploy-test/wrangler.frames.json"], + ["dormouse-try", ".wrangler/deploy-test/wrangler.try.json"], ["dormouse-relay", ".wrangler/deploy-test/wrangler.relay.json"], ["dormouse-voice", ".wrangler/deploy-test/wrangler.voice.json"], ["dormouse-hosted", ".wrangler/deploy-test/wrangler.account.json"], @@ -165,7 +202,7 @@ test("Workers deploy frames, relay, voice, then account from one config path eac deployWorkers("deploy-test", configs, { spawn: spawn("dormouse-voice") }), { message: "dormouse-voice deploy failed" }, ); - assert.deepEqual(deployed.map(([name]) => name), ["dormouse-frames", "dormouse-relay", "dormouse-voice"]); + assert.deepEqual(deployed.map(([name]) => name), ["dormouse-frames", "dormouse-try", "dormouse-relay", "dormouse-voice"]); // Each config is removed once deployed, failed or not. for (const [, path] of deployed) await assert.rejects(readFile(path), { code: "ENOENT" }); @@ -187,7 +224,18 @@ test("production smokes the relay before anything after it deploys, and a relay let unhealthy = 5; // The relay's readiness, as a role missing a grant answers it. let ready = 200; + // Whether the try Worker answers its page, as a new domain may not yet. + let pageServed = true; const fetcher = async (url) => { + if (url === "https://try.dormouse.sh/api/health") { + events.push("try health"); + return Response.json({ ok: true, revision: env.BUILD_SHA }); + } + if (url === "https://try.dormouse.sh/connect/") { + events.push("try page"); + return tryPage(pageServed); + } + if (url === "https://try.dormouse.sh/connect/assets/page-abc.js") return tryPage(pageServed, url); if (url === "https://relay.dormouse.sh/api/push/config") { events.push("relay push"); return Response.json({ applicationServerKey: "B" + "A".repeat(86) }); @@ -211,13 +259,18 @@ test("production smokes the relay before anything after it deploys, and a relay oneTime, wait: async () => {}, }); - await deploy(async (origin) => { + await deploy(async (origin, page) => { assert.equal(origin, "https://relay.dormouse.sh"); + assert.equal(page, "https://try.dormouse.sh"); events.push("relay one-time"); }); - // The relay retried up to its sixth attempt, then the rendezvous ran once. + // The page is served before the relay redirects to it; the relay retried up + // to its sixth attempt, then the rendezvous ran once. assert.deepEqual(events, [ "deploy dormouse-frames", + "deploy dormouse-try", + "try health", + "try page", "deploy dormouse-relay", ...Array(6).fill("relay health"), "relay ready", @@ -235,9 +288,10 @@ test("production smokes the relay before anything after it deploys, and a relay }), { message: "rendezvous failed" }, ); - // Six attempts, then nothing else deploys: the account's `v2` never runs. + // Six attempts, then nothing else deploys. assert.equal(rendezvous, 6); - assert.deepEqual(events, ["deploy dormouse-frames", "deploy dormouse-relay", "relay health", "relay ready", "relay push"]); + const toRelay = ["deploy dormouse-frames", "deploy dormouse-try", "try health", "try page", "deploy dormouse-relay"]; + assert.deepEqual(events, [...toRelay, "relay health", "relay ready", "relay push"]); // A relay whose role lacks a grant stops the release before anything else deploys. events.length = 0; ready = 503; @@ -245,7 +299,20 @@ test("production smokes the relay before anything after it deploys, and a relay deploy(async () => events.push("relay one-time")), failure("https://relay.dormouse.sh must reach Postgres through its Hyperdrive"), ); - assert.deepEqual(events, ["deploy dormouse-frames", "deploy dormouse-relay", "relay health", ...Array(6).fill("relay ready")]); + assert.deepEqual(events, [...toRelay, "relay health", ...Array(6).fill("relay ready")]); + // A page that is not served stops the release before the relay redirects to it. + events.length = 0; + ready = 200; + pageServed = false; + await assert.rejects(deploy(async () => events.push("relay one-time")), { + message: /serves the one-time page/, + }); + assert.deepEqual(events, [ + "deploy dormouse-frames", + "deploy dormouse-try", + "try health", + ...Array(6).fill("try page"), + ]); // A frame origin that does not answer stops the release before anything else. events.length = 0; await assert.rejects( @@ -269,20 +336,21 @@ test("the frame Worker holds nothing but its assets, on the wildcard of its own [{ hyperdrive: [{ binding: "HYPERDRIVE", id: "a".repeat(32) }] }, /must not carry hyperdrive/], [{ kv_namespaces: [] }, /must not carry kv_namespaces/], [{ routes: [{ pattern: "*.dormouse.sh/*", zone_name: "dormouse.sh" }] }, /deep/i], - [{ vars: { CLIENT_ORIGIN: "https://evil.example" } }, /deep/i], - [{ vars: { CLIENT_ORIGIN: "https://relay.dormouse.sh", SECRET: "x" } }, /deep/i], + [{ vars: { CLIENT_ORIGINS: "https://evil.example" } }, /deep/i], + [{ vars: { CLIENT_ORIGINS: "https://relay.dormouse.sh" } }, /deep/i], + [{ vars: { CLIENT_ORIGINS: "https://relay.dormouse.sh https://try.dormouse.sh", SECRET: "x" } }, /deep/i], [{ workers_dev: true }, /false/], ]) assert.throws(() => productionConfig({ ...bases.frames, ...change }, env, "frames"), { message }); }); test("the frame smoke holds a live origin to this release's worker, its policy, and nothing else", async () => { const smoke = (change) => - frameSmoke("dormouseusercontent.com", "https://relay.dormouse.sh", { + frameSmoke("dormouseusercontent.com", "https://relay.dormouse.sh https://try.dormouse.sh", { fetcher: frameOrigin(change), worker: async () => "worker", }); await smoke(); - for (const change of [{ body: "stale" }, { ancestors: "*" }, { hsts: false }, { cookie: true }, { other: 200 }]) + for (const change of [{ body: "stale" }, { ancestors: "*" }, { ancestors: "https://relay.dormouse.sh" }, { hsts: false }, { cookie: true }, { other: 200 }]) await assert.rejects(smoke(change), assert.AssertionError); }); test("live verification retries the relay and voice while their domains come up, and runs the account's POSTs once", async () => { @@ -292,10 +360,12 @@ test("live verification retries the relay and voice while their domains come up, "https://hosted.dormouse.sh": 1, "https://relay.dormouse.sh": 5, "https://voice.dormouse.sh": 5, + "https://try.dormouse.sh": 5, }; const ready = []; const fetcher = async (url) => { const { origin, pathname } = new URL(url); + if (pathname.startsWith("/connect/")) return tryPage(true, url); if (pathname === "/api/push/config") { assert.equal(origin, "https://relay.dormouse.sh"); return Response.json({ applicationServerKey: `B${"A".repeat(86)}` }); @@ -325,6 +395,7 @@ test("live verification retries the relay and voice while their domains come up, "https://hosted.dormouse.sh": 1, "https://relay.dormouse.sh": 6, "https://voice.dormouse.sh": 6, + "https://try.dormouse.sh": 6, }); // Each passed its revision, then its readiness once. assert.deepEqual(ready.sort(), ["https://relay.dormouse.sh", "https://voice.dormouse.sh"]); @@ -335,17 +406,24 @@ test("the history and idle-token sweeps' crons are the voice Worker's, the relay assert.deepEqual(configs.relay.triggers, { crons: ["0 * * * *"] }); assert.deepEqual(configs.account.triggers, { crons: ["*/10 * * * *"] }); }); -test("the Durable Objects are the relay's, each rate limit its Worker's, and Durable Object migrations are append-only", () => { +test("each Durable Object is one Worker's, each rate limit its Worker's, and Durable Object migrations are append-only", () => { assert.deepEqual(configs.relay.durable_objects, { bindings: [ - { name: "ONE_TIME_ROOM", class_name: "OneTimeRoom" }, + { name: "ONE_TIME_ROOM", class_name: "OneTimeRoom", script_name: configs.try.name }, { name: "RELAY_ROOM", class_name: "RelayRoom" }, ], }); + // The relay deployed `v1` with the room, so it keeps that tag unedited and + // appends the deletion, once the try Worker implements it. assert.deepEqual(configs.relay.migrations, [ { tag: "v1", new_sqlite_classes: ["OneTimeRoom"] }, { tag: "v2", new_sqlite_classes: ["RelayRoom"] }, + { tag: "v3", deleted_classes: ["OneTimeRoom"] }, ]); + assert.deepEqual(configs.try.durable_objects, { + bindings: [{ name: "ONE_TIME_ROOM", class_name: "OneTimeRoom" }], + }); + assert.deepEqual(configs.try.migrations, [{ tag: "v1", new_sqlite_classes: ["OneTimeRoom"] }]); // The account reaches the relay's `RelayRoom` by name, implementing nothing. assert.deepEqual(configs.account.durable_objects, { bindings: [{ name: "RELAY_ROOM", class_name: "RelayRoom", script_name: configs.relay.name }], @@ -354,13 +432,16 @@ test("the Durable Objects are the relay's, each rate limit its Worker's, and Dur configs.relay.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), [ ["ONE_TIME_MINT_LIMIT", "1"], - ["ONE_TIME_JOIN_LIMIT", "2"], ["RELAY_SIGNIN_LIMIT", "3"], ["RELAY_SETUP_LIMIT", "4"], ["RELAY_ENROLL_BEGIN_LIMIT", "5"], ["RELAY_ENROLL_POLL_LIMIT", "6"], ], ); + assert.deepEqual( + configs.try.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), + [["ONE_TIME_JOIN_LIMIT", "2"]], + ); // Approvals are limited per account, on the account. assert.deepEqual( configs.account.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), diff --git a/hosted/scripts/stage-relay.mjs b/hosted/scripts/stage-relay.mjs index a38b830b9..0be2f6c55 100644 --- a/hosted/scripts/stage-relay.mjs +++ b/hosted/scripts/stage-relay.mjs @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { assertPocketShell, assertPocketWorker, - ONE_TIME_SHELL, } from "../../lib/scripts/assert-pocket-worker.mjs"; import { HOSTED_POCKET_DEPLOYMENT, @@ -14,26 +13,15 @@ import { /** * Stage the relay Worker's assets: Pocket from `lib`'s `build:pocket` at the * root (`docs/specs/pocket-app.md` -> "Deployment: same-origin, always"), and - * the one-time phone page from `build:one-time` at its page path - * (`docs/specs/one-time.md` -> "Phone page"), and beside Pocket the - * `POCKET_DEPLOYMENT_FILE` that tells it Hosted serves it + * beside it the `POCKET_DEPLOYMENT_FILE` that tells it Hosted serves it * (`docs/specs/remote-network.md` -> "Anywhere"). The directory is emptied - * first so these are all it holds, and each copy's shell is checked against - * its own policy — what the relay serves, not only what `lib` built. Returns - * how many scripts each shell loads. + * first so these are all it holds, and the shell is checked against its policy + * — what the relay serves, not only what `lib` built. Returns how many scripts + * the shell loads. */ -export function stageRelay({ pocket, oneTime }, assets) { - for (const [built, command] of [ - [pocket, "build:pocket"], - [oneTime, "build:one-time"], - ]) - if (!existsSync(join(built, "index.html"))) - throw new Error( - `${built} holds no build; run \`pnpm --filter dormouse-lib ${command}\` first.`, - ); - const pagePath = join(assets, ONE_TIME_SHELL.base); - if (existsSync(join(pocket, ONE_TIME_SHELL.base))) - throw new Error(`the Pocket build has a ${ONE_TIME_SHELL.base} the one-time page owns`); +export function stageRelay(pocket, assets) { + if (!existsSync(join(pocket, "index.html"))) + throw new Error(`${pocket} holds no build; run \`pnpm --filter dormouse-lib build:pocket\` first.`); // A self-host Relay serves that same build, which must read as self-host. if (existsSync(join(pocket, POCKET_DEPLOYMENT_FILE))) throw new Error(`the Pocket build has a ${POCKET_DEPLOYMENT_FILE} only Hosted's staging writes`); @@ -41,13 +29,10 @@ export function stageRelay({ pocket, oneTime }, assets) { mkdirSync(assets, { recursive: true }); cpSync(pocket, assets, { recursive: true }); assertPocketWorker(assets); - const pocketScripts = assertPocketShell(assets); - if (pocketScripts === 0) throw new Error("the staged Pocket shell has no script"); + const scripts = assertPocketShell(assets); + if (scripts === 0) throw new Error("the staged Pocket shell has no script"); writeFileSync(join(assets, POCKET_DEPLOYMENT_FILE), `${JSON.stringify(HOSTED_POCKET_DEPLOYMENT)}\n`); - cpSync(oneTime, pagePath, { recursive: true }); - const oneTimeScripts = assertPocketShell(pagePath, ONE_TIME_SHELL); - if (oneTimeScripts === 0) throw new Error("the staged one-time shell has no script"); - return { pocket: pocketScripts, oneTime: oneTimeScripts }; + return scripts; } if ( @@ -56,15 +41,10 @@ if ( ) { try { const scripts = stageRelay( - { - pocket: fileURLToPath(new URL("../../lib/dist-pocket", import.meta.url)), - oneTime: fileURLToPath(new URL("../../lib/dist-one-time", import.meta.url)), - }, + fileURLToPath(new URL("../../lib/dist-pocket", import.meta.url)), fileURLToPath(new URL("../dist/relay", import.meta.url)), ); - console.log( - `relay assets staged at dist/relay: Pocket at / (${scripts.pocket} script(s)), the one-time page at ${ONE_TIME_SHELL.base} (${scripts.oneTime} script(s) under ${ONE_TIME_SHELL.scriptBase})`, - ); + console.log(`relay assets staged at dist/relay: Pocket at / (${scripts} script(s))`); } catch (error) { console.error(`relay staging failed: ${error.message}`); process.exitCode = 1; diff --git a/hosted/scripts/stage-relay.test.mjs b/hosted/scripts/stage-relay.test.mjs index 692c7dd86..47cb62765 100644 --- a/hosted/scripts/stage-relay.test.mjs +++ b/hosted/scripts/stage-relay.test.mjs @@ -3,11 +3,12 @@ import assert from "node:assert/strict"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { stageTry } from "./stage-try.mjs"; import { stageRelay } from "./stage-relay.mjs"; /** * A `lib/dist-pocket` and `lib/dist-one-time` whose shells load `pocketScript` - * and `oneTimeScript`, and the relay's assets directory from an earlier build. + * and `oneTimeScript`, and each Worker's assets directory from an earlier build. */ function fixture({ pocketScript = '', @@ -30,21 +31,21 @@ function fixture({ mkdirSync(join(assets, "connect", "assets"), { recursive: true }); writeFileSync(join(assets, "connect", "assets", "stale.js"), ""); writeFileSync(join(assets, "stray.html"), "stray"); - return { pocket, oneTime, assets, root }; + const tryAssets = join(root, "dist", "try"); + mkdirSync(join(tryAssets, "connect", "assets"), { recursive: true }); + writeFileSync(join(tryAssets, "connect", "assets", "stale.js"), ""); + writeFileSync(join(tryAssets, "stray.html"), "stray"); + return { pocket, oneTime, assets, tryAssets, root }; } -test("Pocket lands at the root and the one-time page at its path, and they are all the relay's assets hold", () => { - const { pocket, oneTime, assets } = fixture(); - assert.deepEqual(stageRelay({ pocket, oneTime }, assets), { pocket: 1, oneTime: 1 }); +test("Pocket lands at the root, and it is all the relay's assets hold: no one-time page beside it", () => { + const { pocket, assets } = fixture(); + assert.equal(stageRelay(pocket, assets), 1); assert.ok(existsSync(join(assets, "index.html"))); assert.ok(existsSync(join(assets, "sw.js"))); assert.ok(existsSync(join(assets, "diagnostics", "index.html"))); - assert.ok(existsSync(join(assets, "connect", "index.html"))); - assert.ok(existsSync(join(assets, "connect", "assets", "index-abc.js"))); - assert.ok(!existsSync(join(assets, "connect", "assets", "stale.js"))); assert.deepEqual(readdirSync(assets).sort(), [ "assets", - "connect", "deployment.json", "diagnostics", "index.html", @@ -57,10 +58,19 @@ test("Pocket lands at the root and the one-time page at its path, and they are a }); }); +test("the one-time page lands at its path, and it is all the try Worker's assets hold", () => { + const { oneTime, tryAssets } = fixture(); + assert.equal(stageTry(oneTime, tryAssets), 1); + assert.ok(existsSync(join(tryAssets, "connect", "index.html"))); + assert.ok(existsSync(join(tryAssets, "connect", "assets", "index-abc.js"))); + assert.ok(!existsSync(join(tryAssets, "connect", "assets", "stale.js"))); + assert.deepEqual(readdirSync(tryAssets), ["connect"]); +}); + test("a Pocket build carrying the deployment file is refused, since a self-host Relay serves it as it is", () => { - const { pocket, oneTime, assets } = fixture(); + const { pocket, assets } = fixture(); writeFileSync(join(pocket, "deployment.json"), '{"deployment":"hosted"}'); - assert.throws(() => stageRelay({ pocket, oneTime }, assets), /only Hosted's staging writes/); + assert.throws(() => stageRelay(pocket, assets), /only Hosted's staging writes/); }); test("a shell its policy would refuse is not staged quietly", () => { @@ -70,33 +80,21 @@ test("a shell its policy would refuse is not staged quietly", () => { '', "", ]) { - const { pocket, oneTime, assets } = fixture({ oneTimeScript }); - assert.throws(() => stageRelay({ pocket, oneTime }, assets), undefined, oneTimeScript); + const { oneTime, tryAssets } = fixture({ oneTimeScript }); + assert.throws(() => stageTry(oneTime, tryAssets), undefined, oneTimeScript); } for (const pocketScript of [ '', '', "", ]) { - const { pocket, oneTime, assets } = fixture({ pocketScript }); - assert.throws(() => stageRelay({ pocket, oneTime }, assets), undefined, pocketScript); + const { pocket, assets } = fixture({ pocketScript }); + assert.throws(() => stageRelay(pocket, assets), undefined, pocketScript); } }); -test("a Pocket build reaching into the one-time page's path is refused", () => { - const { pocket, oneTime, assets } = fixture(); - mkdirSync(join(pocket, "connect")); - assert.throws(() => stageRelay({ pocket, oneTime }, assets), /one-time page owns/); -}); - -test("staging refuses to run before either build", () => { - const { pocket, oneTime, assets } = fixture(); - assert.throws( - () => stageRelay({ pocket: join(pocket, "missing"), oneTime }, assets), - /build:pocket/, - ); - assert.throws( - () => stageRelay({ pocket, oneTime: join(oneTime, "missing") }, assets), - /build:one-time/, - ); +test("staging refuses to run before its build", () => { + const { pocket, oneTime, assets, tryAssets } = fixture(); + assert.throws(() => stageRelay(join(pocket, "missing"), assets), /build:pocket/); + assert.throws(() => stageTry(join(oneTime, "missing"), tryAssets), /build:one-time/); }); diff --git a/hosted/scripts/stage-try.mjs b/hosted/scripts/stage-try.mjs new file mode 100644 index 000000000..769eda290 --- /dev/null +++ b/hosted/scripts/stage-try.mjs @@ -0,0 +1,38 @@ +import { cpSync, existsSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { assertPocketShell, ONE_TIME_SHELL } from "../../lib/scripts/assert-pocket-worker.mjs"; + +/** + * Stage the try Worker's assets: the one-time phone page from `lib`'s + * `build:one-time` at its page path (`docs/specs/one-time.md` -> "Phone page"), + * and nothing else. The directory is emptied first, and the copy's shell is + * checked against its policy — what the Worker serves, not only what `lib` + * built. Returns how many scripts the shell loads. + */ +export function stageTry(built, assets) { + if (!existsSync(join(built, "index.html"))) + throw new Error(`${built} holds no build; run \`pnpm --filter dormouse-lib build:one-time\` first.`); + rmSync(assets, { recursive: true, force: true }); + const pagePath = join(assets, ONE_TIME_SHELL.base); + mkdirSync(pagePath, { recursive: true }); + cpSync(built, pagePath, { recursive: true }); + const scripts = assertPocketShell(pagePath, ONE_TIME_SHELL); + if (scripts === 0) throw new Error("the staged one-time shell has no script"); + return scripts; +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const scripts = stageTry( + fileURLToPath(new URL("../../lib/dist-one-time", import.meta.url)), + fileURLToPath(new URL("../dist/try", import.meta.url)), + ); + console.log( + `try assets staged at dist/try: the one-time page at ${ONE_TIME_SHELL.base} (${scripts} script(s) under ${ONE_TIME_SHELL.scriptBase})`, + ); + } catch (error) { + console.error(`try staging failed: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/hosted/scripts/workers.mjs b/hosted/scripts/workers.mjs index e9edacc34..61f8785b3 100644 --- a/hosted/scripts/workers.mjs +++ b/hosted/scripts/workers.mjs @@ -8,18 +8,21 @@ import { providerIds } from "../server/providers.js"; const hosted = new URL("../", import.meta.url); /** - * Hosted's three application Workers, each with an origin, a Hyperdrive, and a - * preview, in deploy order, with the facts their Wrangler configs do not carry. - * Everything else — script name, `main`, `APP_ORIGIN`, assets, Hyperdrive, - * Durable Objects, rate limits — is read from `config`. The account deploys - * last, so its `v2` deleting `OneTimeRoom` lands only once the relay serving the - * replacement is deployed and, in production, smoked. + * Hosted's four application Workers, each with an origin and a preview, in + * deploy order, with the facts their Wrangler configs do not carry. Everything + * else — script name, `main`, `APP_ORIGIN`, assets, Hyperdrive, Durable Objects, + * rate limits — is read from `config`. The try Worker deploys before the + * relay: the relay binds its `OneTimeRoom` and redirects to its page, so neither + * may outrun it. */ export const APP_WORKERS = { + // Without a `previewMain`, a preview runs the production entry: neither of the + // next two mappers passes anything a preview lacks. + try: { + config: "wrangler.try.jsonc", + }, relay: { config: "wrangler.relay.jsonc", - // The production entry: its mapper passes nothing a preview lacks. - previewMain: "server/relay-worker.ts", secrets: () => ["RELAY_ENROLL_SECRET", "RELAY_VAPID_PUBLIC_KEY", "RELAY_VAPID_PRIVATE_KEY"], previewSecret: "RELAY_ENROLL_SECRET", /** Its preview also gets a VAPID pair, so push works with no production credential. */ @@ -60,6 +63,17 @@ export const WORKERS = { ...APP_WORKERS, }; +/** + * `durableObjects` with each binding to another Worker's class renamed, so a + * preview or dev Worker never binds a deployed script's namespace. + */ +export const renameBoundScripts = (durableObjects, rename) => ({ + ...durableObjects, + bindings: durableObjects.bindings.map((binding) => + binding.script_name ? { ...binding, script_name: rename(binding.script_name) } : binding, + ), +}); + /** A Wrangler config: JSON, plus `//` comments on lines of their own. */ export const parseConfig = (text) => JSON.parse(text.replace(/^\s*\/\/.*$/gm, "")); diff --git a/hosted/server/bindings.ts b/hosted/server/bindings.ts index 177df237b..10b61f03a 100644 --- a/hosted/server/bindings.ts +++ b/hosted/server/bindings.ts @@ -43,15 +43,15 @@ export interface AccountEnv extends BetterAuthWorkerBindings, WorkerEnv, Billing OAUTH_PROVIDERS?: string; } -/** `relay.dormouse.sh`: the Hosted Relay and Pocket, the one-time rendezvous and its phone page. */ +/** `relay.dormouse.sh`: the Hosted Relay and Pocket, and the one-time rendezvous's Burrow route. */ export interface RelayEnv extends WorkerEnv { ASSETS: Assets; HYPERDRIVE: { connectionString: string }; + /** The try Worker's `OneTimeRoom`s, which a Burrow's socket opens. */ ONE_TIME_ROOM: DurableObjectNamespace; /** One `RelayRoom` per account, holding its relay sockets. */ RELAY_ROOM: DurableObjectNamespace; ONE_TIME_MINT_LIMIT: RateLimit; - ONE_TIME_JOIN_LIMIT: RateLimit; RELAY_SIGNIN_LIMIT: RateLimit; RELAY_SETUP_LIMIT: RateLimit; RELAY_ENROLL_BEGIN_LIMIT: RateLimit; @@ -61,6 +61,11 @@ export interface RelayEnv extends WorkerEnv { * names: exactly an origin once mapped, or absent, and then it names none. */ ACCOUNT_ORIGIN?: string; + /** + * The try Worker's origin, where a link from an older build is sent: + * exactly an origin once mapped, or absent, and then that link is a 404. + */ + TRY_ORIGIN?: string; /** The HMAC key a device code's user code is derived under. */ RELAY_ENROLL_SECRET: string; /** The Web Push signing pair; push is off without a matching pair. */ @@ -68,6 +73,13 @@ export interface RelayEnv extends WorkerEnv { RELAY_VAPID_PRIVATE_KEY?: string; } +/** `try.dormouse.sh`: the one-time phone page and the room a phone joins. No secret and no database. */ +export interface TryEnv extends WorkerEnv { + ASSETS: Assets; + ONE_TIME_ROOM: DurableObjectNamespace; + ONE_TIME_JOIN_LIMIT: RateLimit; +} + /** `voice.dormouse.sh`: managed-voice speech and its history sweep. */ export interface VoiceEnv extends WorkerEnv { HYPERDRIVE: { connectionString: string }; @@ -120,17 +132,26 @@ export const relayBindings = (env: RelayEnv): RelayEnv => ({ ONE_TIME_ROOM: env.ONE_TIME_ROOM, RELAY_ROOM: env.RELAY_ROOM, ONE_TIME_MINT_LIMIT: env.ONE_TIME_MINT_LIMIT, - ONE_TIME_JOIN_LIMIT: env.ONE_TIME_JOIN_LIMIT, RELAY_SIGNIN_LIMIT: env.RELAY_SIGNIN_LIMIT, RELAY_SETUP_LIMIT: env.RELAY_SETUP_LIMIT, RELAY_ENROLL_BEGIN_LIMIT: env.RELAY_ENROLL_BEGIN_LIMIT, RELAY_ENROLL_POLL_LIMIT: env.RELAY_ENROLL_POLL_LIMIT, ACCOUNT_ORIGIN: exactOrigin(env.ACCOUNT_ORIGIN) ?? undefined, + TRY_ORIGIN: exactOrigin(env.TRY_ORIGIN) ?? undefined, RELAY_ENROLL_SECRET: env.RELAY_ENROLL_SECRET, RELAY_VAPID_PUBLIC_KEY: env.RELAY_VAPID_PUBLIC_KEY, RELAY_VAPID_PRIVATE_KEY: env.RELAY_VAPID_PRIVATE_KEY, }); +/** The page's assets, its rooms, and their join limit: nothing a stranger's page could lead anywhere else. */ +export const tryBindings = (env: TryEnv): TryEnv => ({ + ASSETS: env.ASSETS, + APP_ORIGIN: env.APP_ORIGIN, + BUILD_SHA: env.BUILD_SHA, + ONE_TIME_ROOM: env.ONE_TIME_ROOM, + ONE_TIME_JOIN_LIMIT: env.ONE_TIME_JOIN_LIMIT, +}); + /** Hyperdrive for the token lookup and the ElevenLabs key; no auth secret. */ export const voiceBindings = (env: VoiceEnv): VoiceEnv => ({ HYPERDRIVE: env.HYPERDRIVE, diff --git a/hosted/server/frame-origin.ts b/hosted/server/frame-origin.ts index bf8b6a21c..db029380a 100644 --- a/hosted/server/frame-origin.ts +++ b/hosted/server/frame-origin.ts @@ -18,11 +18,11 @@ const FILES: Readonly> = { export interface FrameOriginEnv { ASSETS: { fetch(request: Request): Promise }; - /** The one origin that may frame this: Pocket's, and the one-time page's. */ - CLIENT_ORIGIN: string; + /** The origins that may frame this, space-separated: Pocket's and the one-time page's. */ + CLIENT_ORIGINS: string; } -const headers = (clientOrigin: string, contentType: string) => ({ +const headers = (clientOrigins: string, contentType: string) => ({ "content-type": contentType, // Revalidated, so a worker fix reaches a phone at its next load. "cache-control": "no-cache", @@ -33,11 +33,11 @@ const headers = (clientOrigin: string, contentType: string) => ({ "strict-transport-security": "max-age=31536000; includeSubDomains", // Only the bootstrap page can be framed, and only by the Client: that is what // makes the parent its messages trust the Client (`bootstrap.ts`). - "content-security-policy": `default-src 'none'; script-src 'self'; worker-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors ${clientOrigin}`, + "content-security-policy": `default-src 'none'; script-src 'self'; worker-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors ${clientOrigins}`, }); export async function frameOriginResponse(request: Request, env: FrameOriginEnv): Promise { - const notFound = () => new Response("Not found", { status: 404, headers: headers(env.CLIENT_ORIGIN, "text/plain; charset=utf-8") }); + const notFound = () => new Response("Not found", { status: 404, headers: headers(env.CLIENT_ORIGINS, "text/plain; charset=utf-8") }); const url = new URL(request.url); const contentType = Object.hasOwn(FILES, url.pathname) ? FILES[url.pathname] : undefined; if (!contentType || (request.method !== "GET" && request.method !== "HEAD")) return notFound(); @@ -46,5 +46,5 @@ export async function frameOriginResponse(request: Request, env: FrameOriginEnv) if (url.pathname === FRAME_BOOTSTRAP_PATH) url.pathname = url.pathname.slice(0, -".html".length); const file = await env.ASSETS.fetch(new Request(url, { method: request.method })); if (!file.ok) return notFound(); - return new Response(request.method === "HEAD" ? null : file.body, { status: 200, headers: headers(env.CLIENT_ORIGIN, contentType) }); + return new Response(request.method === "HEAD" ? null : file.body, { status: 200, headers: headers(env.CLIENT_ORIGINS, contentType) }); } diff --git a/hosted/server/headers.ts b/hosted/server/headers.ts index ef97afe13..bdf885156 100644 --- a/hosted/server/headers.ts +++ b/hosted/server/headers.ts @@ -27,9 +27,10 @@ export const POCKET_PERMISSIONS = "camera=(self), microphone=(), geolocation=()" /** * The relay's prefixes that serve no page: each answers its own routes and - * otherwise 404s (`workerApp`'s tails), never Pocket's shell. + * otherwise 404s (`workerApp`'s tails), never Pocket's shell. `/connect` answers + * only the move of the one-time page to its own origin. */ -export const RELAY_NON_PAGE_PREFIXES = ["/api", "/ws"] as const; +export const RELAY_NON_PAGE_PREFIXES = ["/api", "/ws", "/connect"] as const; /** * A response's caching: `immutable` for a content-hashed file (a 200 that is @@ -70,38 +71,35 @@ export const NO_PAGE_RULES: PathRules = { }; /** - * Which of the relay's three kinds a path is: the one-time page (`/connect` - * and under `/connect/`), a non-page prefix, or Pocket's - * (`docs/specs/pocket-app.md` -> "Serving the built bundle") — every other path. + * The relay's rules: the runs-nothing policy on the non-page prefixes, and + * Pocket's (with the camera) on every other path + * (`docs/specs/pocket-app.md` -> "Serving the built bundle"). Pocket needs `APP_ORIGIN`, and + * a value that is not exactly an http(s) origin could write a directive of its + * own into the header: the runs-nothing policy answers instead. */ -export function relayPathKind(path: string): "connect" | "api" | "pocket" { - if (under(path, ONE_TIME_PAGE_PATH.slice(0, -1))) return "connect"; - if (RELAY_NON_PAGE_PREFIXES.some((prefix) => under(path, prefix))) return "api"; - return "pocket"; -} +export const relayRules: RulesFor = (path, appOrigin) => { + if (RELAY_NON_PAGE_PREFIXES.some((prefix) => under(path, prefix))) return NO_PAGE_RULES; + const origin = exactOrigin(appOrigin); + return { + policy: origin ? pocketContentSecurityPolicy(origin, HOSTED_FRAME_DOMAIN) : RUNS_NOTHING_POLICY, + permissions: POCKET_PERMISSIONS, + cache: contentHashed(path, "/assets/") ? "immutable" : "no-cache", + }; +}; /** - * The relay's rules: the page's policy under `/connect`, Pocket's (with the - * camera) on Pocket's paths, and the runs-nothing policy on the non-page - * prefixes. Both pages need `APP_ORIGIN`, and a value that is not exactly an - * http(s) origin could write a directive of its own into the header: the - * runs-nothing policy answers instead. + * The try Worker's rules: the page's policy under `/connect`, and the + * runs-nothing policy everywhere else it answers, with `APP_ORIGIN` read as + * {@link relayRules} reads it. */ -export const relayRules: RulesFor = (path, appOrigin) => { - const kind = relayPathKind(path); - if (kind === "api") return NO_PAGE_RULES; +export const tryRules: RulesFor = (path, appOrigin) => { + if (!under(path, ONE_TIME_PAGE_PATH.slice(0, -1))) return NO_PAGE_RULES; const origin = exactOrigin(appOrigin); - return kind === "connect" - ? { - policy: origin ? oneTimePagePolicy(origin) : RUNS_NOTHING_POLICY, - permissions: NO_DEVICES_PERMISSIONS, - cache: contentHashed(path, `${ONE_TIME_PAGE_PATH}assets/`) ? "immutable" : "no-store", - } - : { - policy: origin ? pocketContentSecurityPolicy(origin, HOSTED_FRAME_DOMAIN) : RUNS_NOTHING_POLICY, - permissions: POCKET_PERMISSIONS, - cache: contentHashed(path, "/assets/") ? "immutable" : "no-cache", - }; + return { + policy: origin ? oneTimePagePolicy(origin) : RUNS_NOTHING_POLICY, + permissions: NO_DEVICES_PERMISSIONS, + cache: contentHashed(path, `${ONE_TIME_PAGE_PATH}assets/`) ? "immutable" : "no-store", + }; }; /** `value` when it is exactly an http(s) origin, else null. */ @@ -119,7 +117,7 @@ export function exactOrigin(value: unknown): string | null { * scripts from `/connect/assets/` alone (plus WebAssembly for xterm's image * decoder), styles from there too plus inline ones for the shell and React, * one socket — the rendezvous client route — and frames on the frame domain alone. - * Nothing names `'self'`, which would admit whatever else the relay origin serves. + * Nothing names `'self'`, which would admit whatever else this origin answers. * Sandboxed with no top navigation; `form-action` keeps the page's own forms in. */ export function oneTimePagePolicy(appOrigin: string) { diff --git a/hosted/server/one-time-room.ts b/hosted/server/one-time-room.ts index bffcc9483..ca213bd6b 100644 --- a/hosted/server/one-time-room.ts +++ b/hosted/server/one-time-room.ts @@ -64,7 +64,8 @@ export class OneTimeRoom { }; } - // Reached only through `oneTimeRoutes`, which has already checked the + // Reached only through `oneTimeBurrowRoute` on the relay Worker or + // `oneTimeClientRoute` on the try Worker: each has already checked the // upgrade, the Origin, the room id, and the rate limit. async fetch(request: Request): Promise { const url = new URL(request.url); diff --git a/hosted/server/one-time.ts b/hosted/server/one-time.ts index 1cff21d6e..83266c8f3 100644 --- a/hosted/server/one-time.ts +++ b/hosted/server/one-time.ts @@ -8,18 +8,18 @@ import { ONE_TIME_WS_ROUTES, toBase64Url, } from "remote-lib-common"; -import type { RelayEnv } from "./bindings"; +import type { TryEnv, RelayEnv } from "./bindings"; import { forwardUpgrade, isUpgrade, upgradeRequired } from "./socket-room"; -type OneTimeContext = Context<{ Bindings: RelayEnv }>; +type TryContext = Context<{ Bindings: TryEnv }>; -/** - * The one-time rendezvous routes (`docs/specs/one-time.md` -> "Hosted - * rendezvous"). Each checks the request, then hands a bare upgrade to the - * room's Durable Object; neither reads a cookie, reaches Hyperdrive, or asks - * auth anything. - */ -export function oneTimeRoutes(app: Hono<{ Bindings: RelayEnv }>) { +// The one-time rendezvous routes (`docs/specs/one-time.md` -> "Hosted +// rendezvous"). Each checks the request, then hands a bare upgrade to the +// room's Durable Object; neither reads a cookie, reaches Hyperdrive, or asks +// auth anything. + +/** The relay Worker's half: where every Burrow, of any release, mints its room. */ +export function oneTimeBurrowRoute(app: Hono<{ Bindings: RelayEnv }>) { app.get(ONE_TIME_WS_ROUTES.burrow, async (c) => { if (!isUpgrade(c)) return upgradeRequired(c, "message"); // Every browser sends Origin on a WebSocket handshake and the Burrow's @@ -31,8 +31,12 @@ export function oneTimeRoutes(app: Hono<{ Bindings: RelayEnv }>) { const room = toBase64Url( crypto.getRandomValues(new Uint8Array(E2E_ID_BYTE_LENGTH)), ); - return forward(c, ONE_TIME_WS_ROUTES.burrow, room); + return forward(c.env.ONE_TIME_ROOM, c.req.url, ONE_TIME_WS_ROUTES.burrow, room); }); +} + +/** The try Worker's half: only the page on that origin may join a room. */ +export function oneTimeClientRoute(app: Hono<{ Bindings: TryEnv }>) { app.get(ONE_TIME_WS_ROUTES.client, async (c) => { if (!isUpgrade(c)) return upgradeRequired(c, "message"); if (c.req.raw.headers.get("origin") !== c.env.APP_ORIGIN) @@ -41,19 +45,19 @@ export function oneTimeRoutes(app: Hono<{ Bindings: RelayEnv }>) { const room = rooms.length === 1 ? rooms[0] : undefined; if (!isE2eId(room)) return c.json({ message: "Unknown room." }, 400); if (!(await allowed(c, c.env.ONE_TIME_JOIN_LIMIT))) return tooMany(c); - return forward(c, ONE_TIME_WS_ROUTES.client, room); + return forward(c.env.ONE_TIME_ROOM, c.req.url, ONE_TIME_WS_ROUTES.client, room); }); } /** * The one-time phone page (`docs/specs/one-time.md` -> "Phone page"), staged - * into the relay's assets under `/connect/`: its shell and its content-hashed - * assets, and nothing else under the path. The relay's assets have no SPA - * fallback; an HTML answer to an asset path is refused all the same, as is a - * name that is not content-hashed, whatever the build staged. + * into the try Worker's assets under `/connect/`: its shell and its + * content-hashed assets, and nothing else under the path. The assets have no + * SPA fallback; an HTML answer to an asset path is refused all the same, as is + * a name that is not content-hashed, whatever the build staged. */ -export function oneTimePageRoutes(app: Hono<{ Bindings: RelayEnv }>) { - const assets = (c: OneTimeContext) => c.env.ASSETS.fetch(c.req.raw); +export function oneTimePageRoutes(app: Hono<{ Bindings: TryEnv }>) { + const assets = (c: TryContext) => c.env.ASSETS.fetch(c.req.raw); const assetDir = `${ONE_TIME_PAGE_PATH}assets/`; app.get(ONE_TIME_PAGE_PATH.slice(0, -1), assets); app.get(ONE_TIME_PAGE_PATH, assets); @@ -63,23 +67,38 @@ export function oneTimePageRoutes(app: Hono<{ Bindings: RelayEnv }>) { app.get(`${ONE_TIME_PAGE_PATH}*`, (c) => c.notFound()); } +/** + * Where a link an older build shows lands on the relay's origin: the same page + * at its own origin (`docs/specs/one-time.md` -> "Links from older builds"). A + * browser carries the fragment across the redirect and no server sees it, so + * nothing of a stranger's ever renders beside Pocket. + */ +export function oneTimePageMoved(app: Hono<{ Bindings: RelayEnv }>) { + const moved = (c: Context<{ Bindings: RelayEnv }>) => + c.env.TRY_ORIGIN + ? c.redirect(`${c.env.TRY_ORIGIN}${ONE_TIME_PAGE_PATH}`, 308) + : c.notFound(); + app.get(ONE_TIME_PAGE_PATH.slice(0, -1), moved); + app.get(ONE_TIME_PAGE_PATH, moved); +} + /** * A content-hashed asset's answer: the file itself, or a 404 — never HTML, * which would be cached as immutable under the hashed name. */ -export function assetOrNotFound(c: OneTimeContext, response: Response) { +export function assetOrNotFound(c: Context, response: Response) { return response.ok && !(response.headers.get("content-type") ?? "").includes("text/html") ? response : c.notFound(); } -function tooMany(c: OneTimeContext) { +function tooMany(c: Context) { return c.json({ message: "Too many requests." }, 429); } /** Whether `limit` admits this caller, keyed by {@link rateLimitKey}. */ -export async function allowed(c: OneTimeContext, limit: RateLimit) { +export async function allowed(c: Context, limit: RateLimit) { return ( await limit.limit({ key: rateLimitKey(c.req.raw.headers.get("cf-connecting-ip")), @@ -141,7 +160,6 @@ function ipv6Groups(ip: string): string[] { } /** The room's object, handed a bare upgrade naming the room and nothing else of the caller's. */ -function forward(c: OneTimeContext, route: string, room: string) { - const stub = c.env.ONE_TIME_ROOM.get(c.env.ONE_TIME_ROOM.idFromName(room)); - return forwardUpgrade(stub, new URL(route, c.req.url), { [ONE_TIME_ROOM_PARAM]: room }); +function forward(rooms: DurableObjectNamespace, url: string, route: string, room: string) { + return forwardUpgrade(rooms.get(rooms.idFromName(room)), new URL(route, url), { [ONE_TIME_ROOM_PARAM]: room }); } diff --git a/hosted/server/pocket.ts b/hosted/server/pocket.ts index 677558dfe..bc579cd3a 100644 --- a/hosted/server/pocket.ts +++ b/hosted/server/pocket.ts @@ -4,7 +4,7 @@ import { assetOrNotFound } from "./one-time"; /** * Pocket at the relay's root (`docs/specs/pocket-app.md` -> "Serving the built - * bundle"), staged into the relay's assets beside `/connect/`: a hashed asset + * bundle"), staged into the relay's assets: a hashed asset * only as itself, the shell straight away for a path that names no file, and * any other path as its file, or the shell when there is none. Registered as * the relay's fallback, after every route and the non-page tails. Paths are diff --git a/hosted/server/relay-worker.ts b/hosted/server/relay-worker.ts index 6eb3a79ba..19cd4a3f8 100644 --- a/hosted/server/relay-worker.ts +++ b/hosted/server/relay-worker.ts @@ -1,7 +1,7 @@ import { withClient } from "pgstencil/postgres"; import { relayBindings, type RelayEnv } from "./bindings"; import { RELAY_NON_PAGE_PREFIXES, relayRules } from "./headers"; -import { oneTimePageRoutes, oneTimeRoutes } from "./one-time"; +import { oneTimeBurrowRoute, oneTimePageMoved } from "./one-time"; import { pocketRoutes } from "./pocket"; import { OWNER_COLUMNS } from "./relay-auth"; import { relayApiRoutes, sweepExpired } from "./relay-api"; @@ -10,8 +10,10 @@ import { workerApp } from "./worker-app"; /** * The relay Worker, `dormouse-relay` on `relay.dormouse.sh`: the Hosted Relay's - * routes and Pocket at the root, the one-time rendezvous, and its `/connect/` - * page. It holds no auth secret and never asks auth; it queries only its own + * routes and Pocket at the root, and the route a Burrow mints a one-time room + * on. **Never the one-time phone page**, which renders what a stranger's link + * sends beside nothing here (`hosted/server/try-worker.ts`); its old path + * redirects there. It holds no auth secret and never asks auth; it queries only its own * tables and the entitlement's user row, and in production its Hyperdrive * connects as `dormouse_relay`, granted exactly those * (`hosted/server/runtime-roles.sql`). Its PR previews run this entry too, on @@ -29,8 +31,8 @@ export default workerApp({ routes(app) { relayApiRoutes(app); relaySocketRoutes(app); - oneTimeRoutes(app); - oneTimePageRoutes(app); + oneTimeBurrowRoute(app); + oneTimePageMoved(app); }, fallback: pocketRoutes, // The Cron Trigger sweeps every Relay table's expired rows. @@ -39,6 +41,5 @@ export default workerApp({ }, }); -export { OneTimeRoom } from "./one-time-room"; export { RelayRoom } from "./relay-room"; export { RelayRows } from "./relay-rows"; diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 57c860ace..dfa89b9fc 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -16,6 +16,7 @@ import { type AccountEnv, accountBindings, accountPreviewBindings, + tryBindings, relayBindings, voiceBindings, voicePreviewBindings, @@ -25,6 +26,7 @@ import { RUNS_NOTHING_POLICY, accountRules, relayRules, + tryRules, type RulesFor, } from "../headers"; import { ADMIN_EMAIL } from "../entitlement"; @@ -170,9 +172,12 @@ const SERVED: Record = { ...ACCOUNT_BILLING, ["GET", "/login"], ], + try: [ + ["GET", ONE_TIME_WS_ROUTES.client], + ["GET", ONE_TIME_PAGE_PATH], + ], relay: [ ["GET", ONE_TIME_WS_ROUTES.burrow], - ["GET", ONE_TIME_WS_ROUTES.client], ["GET", ONE_TIME_PAGE_PATH], ["GET", WS_ROUTES.burrow], ["GET", WS_ROUTES.client], @@ -241,7 +246,23 @@ const ABSENT: Record = { ["DELETE", "/api/voice/token"], ...RELAY_API, ], + try: [ + ["GET", "/api/auth/csrf"], + ["GET", "/api/providers"], + ["GET", "/api/ready"], + ["GET", ONE_TIME_WS_ROUTES.burrow], + ["GET", "/"], + ["GET", "/login"], + ...RELAY_API, + ...ACCOUNT_RELAY, + ...ACCOUNT_BILLING, + ["POST", "/api/voice/speak"], + ["GET", WS_ROUTES.burrow], + ["GET", WS_ROUTES.client], + ], relay: [ + // The phone's route is the try Worker's alone. + ["GET", ONE_TIME_WS_ROUTES.client], ["GET", "/api/auth/csrf"], ["GET", "/api/auth/get-session"], ["POST", "/api/auth/sign-out"], @@ -336,7 +357,9 @@ test("each Worker caches only its own hashed assets as immutable", async () => { expect(cache(await send("account", ORIGINS.account + pocket))).toBe(IMMUTABLE); expect(cache(await send("account", ORIGINS.account + phone))).toBe("no-store"); expect(cache(await send("relay", ORIGINS.relay + pocket))).toBe(IMMUTABLE); - expect(cache(await send("relay", ORIGINS.relay + phone))).toBe(IMMUTABLE); + expect(cache(await send("relay", ORIGINS.relay + phone))).toBe("no-store"); + expect(cache(await send("try", ORIGINS.try + phone))).toBe(IMMUTABLE); + expect(cache(await send("try", ORIGINS.try + pocket))).toBe("no-store"); // Each Worker's rules, checked on an app that serves a script at every path. const serving = (rules: RulesFor) => workerApp({ @@ -351,13 +374,18 @@ test("each Worker caches only its own hashed assets as immutable", async () => { }); const env = { APP_ORIGIN: ORIGINS.relay }; const relay = serving(relayRules); - for (const path of [pocket, phone, "/%61ssets/app-B39o2E6i.js", "/assets/pocket-private-key-ChSkpulJ.js"]) + for (const path of [pocket, "/%61ssets/app-B39o2E6i.js", "/assets/pocket-private-key-ChSkpulJ.js"]) expect(cache(await relay.fetch(new Request(ORIGINS.relay + path), env)), path).toBe(IMMUTABLE); // A name without the content hash can change in place, so it is cached as its path otherwise is. for (const path of ["/sw.js", "/assets/plain.js", "/assets/app-B39o2E6.js"]) expect(cache(await relay.fetch(new Request(ORIGINS.relay + path), env)), path).toBe("no-cache"); - for (const path of ["/connect/x.js", `${ONE_TIME_PAGE_PATH}assets/plain.js`, "/api/assets/x.js", "/ws/assets/x.js"]) + for (const path of [phone, "/connect/x.js", `${ONE_TIME_PAGE_PATH}assets/plain.js`, "/api/assets/x.js", "/ws/assets/x.js"]) expect(cache(await relay.fetch(new Request(ORIGINS.relay + path), env)), path).toBe("no-store"); + const tryApp = serving(tryRules); + const tryEnv = { APP_ORIGIN: ORIGINS.try }; + expect(cache(await tryApp.fetch(new Request(ORIGINS.try + phone), tryEnv))).toBe(IMMUTABLE); + for (const path of [pocket, "/connect/x.js", `${ONE_TIME_PAGE_PATH}assets/plain.js`, "/api/assets/x.js"]) + expect(cache(await tryApp.fetch(new Request(ORIGINS.try + path), tryEnv)), path).toBe("no-store"); const account = serving(accountRules); const accountEnv = { APP_ORIGIN: ORIGINS.account }; expect(cache(await account.fetch(new Request(ORIGINS.account + pocket), accountEnv))).toBe(IMMUTABLE); @@ -451,6 +479,7 @@ test("each bindings mapper passes only what its Worker uses", () => { RELAY_ENROLL_POLL_LIMIT: {} as RateLimit, RELAY_APPROVE_LIMIT: {} as RateLimit, ACCOUNT_ORIGIN: "https://account.example.test", + TRY_ORIGIN: "https://try.example.test", ...STRIPE, }; const keys = (bindings: object) => Object.keys(bindings).sort(); @@ -488,8 +517,8 @@ test("each bindings mapper passes only what its Worker uses", () => { "APP_ORIGIN", "ASSETS", "BUILD_SHA", + "TRY_ORIGIN", "HYPERDRIVE", - "ONE_TIME_JOIN_LIMIT", "ONE_TIME_MINT_LIMIT", "ONE_TIME_ROOM", "RELAY_ENROLL_BEGIN_LIMIT", @@ -513,6 +542,14 @@ test("each bindings mapper passes only what its Worker uses", () => { undefined, ]) expect(relayBindings({ ...env, ACCOUNT_ORIGIN: loose }).ACCOUNT_ORIGIN, String(loose)).toBeUndefined(); + // `TRY_ORIGIN` as well: it writes a redirect's `Location`. + expect(relayBindings(env).TRY_ORIGIN).toBe(env.TRY_ORIGIN); + for (const loose of ["https://try.example.test/", "https://try.example.test\nX: y", "javascript:alert(1)", "", undefined]) + expect(relayBindings({ ...env, TRY_ORIGIN: loose }).TRY_ORIGIN, String(loose)).toBeUndefined(); + // No secret and no database: the page's Worker holds the page, its rooms, and their limit. + expect(keys(tryBindings(env))).toEqual( + ["APP_ORIGIN", "ASSETS", "BUILD_SHA", "ONE_TIME_JOIN_LIMIT", "ONE_TIME_ROOM"].sort(), + ); // No auth secret: speak reads the token's owner, never a login. expect(keys(voiceBindings(env))).toEqual( ["APP_ORIGIN", "BUILD_SHA", "ELEVENLABS_API_KEY", "HYPERDRIVE"].sort(), diff --git a/hosted/server/tests/bundle.ts b/hosted/server/tests/bundle.ts index faf0caf19..37b606ad5 100644 --- a/hosted/server/tests/bundle.ts +++ b/hosted/server/tests/bundle.ts @@ -58,6 +58,22 @@ function sqliteClass(config: WranglerConfig, className: string, script?: string) ); } +/** `bindings` as Miniflare's `durableObjects` option, for the Worker `config`. */ +const durableObjectOptions = ( + config: WranglerConfig, + bindings: NonNullable["bindings"], +) => + Object.fromEntries( + bindings.map(({ name, class_name, script_name }) => [ + name, + { + className: class_name, + ...(script_name && { scriptName: script_name }), + useSQLite: sqliteClass(config, class_name, script_name), + }, + ]), + ); + /** * Miniflare options running `script` as Worker `name`, under its config's * script name: compatibility from that Worker's own config, and its Durable @@ -75,16 +91,7 @@ export function miniflareOptions(name: Name, script: string, extra: Partial [ - name, - { - className: class_name, - ...(script_name && { scriptName: script_name }), - useSQLite: sqliteClass(config, class_name, script_name), - }, - ]), - ), + durableObjects: durableObjectOptions(config, config.durable_objects.bindings), }), ...(config.ratelimits && { ratelimits: Object.fromEntries( @@ -101,16 +108,18 @@ export function miniflareOptions(name: Name, script: string, extra: Partial !script_name) - .map(({ class_name }) => `export class ${class_name} extends DurableObject {}`); + const own = (wrangler[name].durable_objects?.bindings ?? []).filter(({ script_name }) => !script_name); return miniflareOptions( name, [ `import { DurableObject } from "cloudflare:workers";`, - ...classes, + ...own.map(({ class_name }) => `export class ${class_name} extends DurableObject {}`), `export default { fetch: () => new Response(null, { status: 404 }) };`, ].join("\n"), + { + // Its own alone: a binding to a third Worker's class needs that Worker running. + durableObjects: durableObjectOptions(wrangler[name], own), + }, ); } diff --git a/hosted/server/tests/frame-origin.test.ts b/hosted/server/tests/frame-origin.test.ts index 0c40ab2b4..4f7f1a9ed 100644 --- a/hosted/server/tests/frame-origin.test.ts +++ b/hosted/server/tests/frame-origin.test.ts @@ -3,7 +3,7 @@ import { frameOriginResponse } from "../frame-origin"; const asked: string[] = []; const env = { - CLIENT_ORIGIN: "https://relay.dormouse.sh", + CLIENT_ORIGINS: "https://relay.dormouse.sh https://try.dormouse.sh", ASSETS: { fetch: async (request: Request) => { asked.push(new URL(request.url).pathname); @@ -24,8 +24,8 @@ describe("frameOriginResponse", () => { const response = await at(path); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain(type); - expect(response.headers.get("content-security-policy")).toContain( - "frame-ancestors https://relay.dormouse.sh", + expect(response.headers.get("content-security-policy")).toMatch( + /frame-ancestors https:\/\/relay\.dormouse\.sh https:\/\/try\.dormouse\.sh$/, ); expect(response.headers.get("x-content-type-options")).toBe("nosniff"); expect(response.headers.get("strict-transport-security")).toContain("includeSubDomains"); diff --git a/hosted/server/tests/one-time-entry.ts b/hosted/server/tests/one-time-entry.ts index 56ba20c84..dcc2c5261 100644 --- a/hosted/server/tests/one-time-entry.ts +++ b/hosted/server/tests/one-time-entry.ts @@ -1,4 +1,4 @@ -import worker, { OneTimeRoom as ProductionRoom } from "../relay-worker"; +import worker, { OneTimeRoom as ProductionRoom } from "../try-worker"; import { TEST_ROOM_LIMITS } from "./one-time-limits"; /** The production room on timings a test can wait out. */ diff --git a/hosted/server/tests/one-time.test.ts b/hosted/server/tests/one-time.test.ts index 121fe06de..80bc6cbbb 100644 --- a/hosted/server/tests/one-time.test.ts +++ b/hosted/server/tests/one-time.test.ts @@ -26,47 +26,66 @@ import { type OneTimeRoomFrame, } from "remote-lib-common"; import * as smoke from "../../scripts/one-time-smoke.mjs"; -import { HOSTED_FRAME_DOMAIN, pocketContentSecurityPolicy } from "remote-lib-common"; -import { oneTimePagePolicy, relayRules, RUNS_NOTHING_POLICY } from "../headers"; +import { HOSTED_FRAME_DOMAIN } from "remote-lib-common"; +import { tryRules, oneTimePagePolicy, RUNS_NOTHING_POLICY } from "../headers"; import { rateLimitKey } from "../one-time"; -import { ENTRIES, ORIGINS, bundleWorker, miniflareOptions, wrangler } from "./bundle"; +import { ENTRIES, ORIGINS, bundleWorker, miniflareOptions, together, wrangler } from "./bundle"; import { limitOf, untilLimited } from "./rate-limit"; import { TEST_ROOM_LIMITS } from "./one-time-limits"; import { rawUpgrade, type RawSocket } from "./raw-socket"; -// The relay Worker's rendezvous and phone page in real workerd, without -// Postgres: neither touches Hyperdrive, so this suite runs in the root -// `pnpm test`. +// The rendezvous across its two Workers and the phone page, in real workerd, +// without Postgres: none of it touches Hyperdrive, so this suite runs in the +// root `pnpm test`. -const origin = ORIGINS.relay; +/** The try Worker's origin: the page's, and the one that joins a room. */ +const origin = ORIGINS.try; +/** The relay Worker's origin: where a Burrow mints a room, and Pocket's. */ +const relay = ORIGINS.relay; /** The sibling Workers' origins, which share the account's login cookie as same-site. */ const SIBLINGS = [ORIGINS.account, ORIGINS.voice]; const PAGE_SCRIPT = `${ONE_TIME_PAGE_PATH}assets/page-B39o2E6i.js`; /** Staged beside the page but not content-hashed: refused all the same. */ const UNHASHED_SCRIPT = `${ONE_TIME_PAGE_PATH}assets/plain.js`; const PAGE_SHELL = ``; +const POCKET_SHELL = "Pocket"; -async function start(entry: string) { +const relayBundle = bundleWorker(ENTRIES.relay); + +async function start(tryEntry: string) { + const [tryBundle, relayScript] = await Promise.all([bundleWorker(tryEntry), relayBundle]); const mf = new Miniflare( - miniflareOptions("relay", (await bundleWorker(entry)).outputFiles[0].text, { - bindings: { APP_ORIGIN: origin }, - serviceBindings: { - // The staged page and its hashed script, and an HTML answer for every - // other path — an unknown one under /connect/assets/ included — as an - // SPA fallback would give, so a path the Worker hands the assets shows. - ASSETS: (request) => { - const { pathname } = new URL(request.url); - if (pathname === PAGE_SCRIPT || pathname === UNHASHED_SCRIPT) - return new WorkerResponse("export const page = 1;\n", { - headers: { "content-type": "text/javascript" }, - }); - return new WorkerResponse( - pathname === ONE_TIME_PAGE_PATH ? PAGE_SHELL : "", - { headers: { "content-type": "text/html" } }, - ); + together( + miniflareOptions("try", tryBundle.outputFiles[0].text, { + bindings: { APP_ORIGIN: origin }, + routes: [`${new URL(origin).host}/*`], + serviceBindings: { + // The staged page and its hashed script, and an HTML answer for every + // other path — an unknown one under /connect/assets/ included — as an + // SPA fallback would give, so a path the Worker hands the assets shows. + ASSETS: (request) => { + const { pathname } = new URL(request.url); + if (pathname === PAGE_SCRIPT || pathname === UNHASHED_SCRIPT) + return new WorkerResponse("export const page = 1;\n", { + headers: { "content-type": "text/javascript" }, + }); + return new WorkerResponse( + pathname === ONE_TIME_PAGE_PATH ? PAGE_SHELL : "", + { headers: { "content-type": "text/html" } }, + ); + }, + }, + }), + miniflareOptions("relay", relayScript.outputFiles[0].text, { + bindings: { APP_ORIGIN: relay, TRY_ORIGIN: origin }, + routes: [`${new URL(relay).host}/*`], + // Pocket's shell for every path, so a path the relay left to it shows. + serviceBindings: { + ASSETS: () => + new WorkerResponse(POCKET_SHELL, { headers: { "content-type": "text/html" } }), }, - }, - }), + }), + ), ); return { mf, url: await mf.ready }; } @@ -75,7 +94,7 @@ let production: Awaited>; let short: Awaited>; beforeAll(async () => { [production, short] = await Promise.all([ - start(ENTRIES.relay), + start(ENTRIES.try), start("server/tests/one-time-entry.ts"), ]); }); @@ -87,12 +106,15 @@ let addresses = 0; /** A client address of its own per caller, so no two tests share a rate limit. */ const freshIp = () => `198.51.100.${++addresses}`; -/** An upgrade through the Worker, as a request for `url`. */ +/** + * An upgrade through the Worker, as a request for `url`: by default the relay + * for the Burrow's route, and the try Worker for anything else. + */ function upgrade( path: string, headers: Record = {}, fixture = production, - url = origin + path, + url = (path === ONE_TIME_WS_ROUTES.burrow ? relay : origin) + path, ) { return rawUpgrade(fixture.url, url, { "cf-connecting-ip": freshIp(), @@ -148,18 +170,20 @@ test("a Burrow mints a fresh room and hears its room frame first", async () => { second.burrow.close(); }); -test("no Origin may mint, and only the app origin may join", async () => { - for (const value of [origin, ...SIBLINGS, "https://dormouse.sh", "null"]) +test("no Origin may mint, and only the page's origin may join", async () => { + for (const value of [origin, relay, ...SIBLINGS, "https://dormouse.sh", "null"]) expect( (await upgrade(ONE_TIME_WS_ROUTES.burrow, { origin: value })).status, ).toBe(403); const { burrow, frame } = await mint(); for (const headers of [ {} as Record, + // Pocket's origin, above all: no page beside its paired keys joins a room. + { origin: relay }, ...SIBLINGS.map((sibling) => ({ origin: sibling })), { origin: "https://dormouse.sh" }, - { origin: "https://relay.dormouse.sh.example" }, - { origin: "http://relay.dormouse.sh" }, + { origin: "https://try.dormouse.sh.example" }, + { origin: "http://try.dormouse.sh" }, ]) expect((await upgrade(joinPath(frame.roomId), headers)).status).toBe(403); // None of those spent the room's one join. @@ -184,11 +208,10 @@ test("a join names exactly one well-formed room", async () => { }); test("both routes require a WebSocket upgrade", async () => { - for (const path of [ONE_TIME_WS_ROUTES.burrow, joinPath("A".repeat(E2E_ID_LENGTH))]) + for (const url of [relay + ONE_TIME_WS_ROUTES.burrow, origin + joinPath("A".repeat(E2E_ID_LENGTH))]) expect( - (await production.mf.dispatchFetch(origin + path, { headers: { origin } })) - .status, - path, + (await production.mf.dispatchFetch(url, { headers: { origin } })).status, + url, ).toBe(426); }); @@ -337,7 +360,7 @@ test("either end leaving closes the other and ends the room", async () => { test("a hibernated room keeps its join and its count", async () => { const { burrow, frame } = await mint(); const hibernate = () => - production.mf.unsafeEvictDurableObject(wrangler.relay.name, "OneTimeRoom", { + production.mf.unsafeEvictDurableObject(wrangler.try.name, "OneTimeRoom", { name: frame.roomId, webSockets: "hibernate", }); @@ -454,10 +477,11 @@ test("rate-limit keys", () => { expect(rateLimitKey("::203.0.113.9")).toBe("0:0:0:0::/64"); }); -test("the deployment smoke passes against the relay Worker, which its previews also run", async () => { +test("the deployment smoke passes against the two Workers, which their previews also run", async () => { const local = production.url.href.replace(/^http/, "ws"); // Node's own WebSocket, as the smoke runs in CI, aimed at Miniflare. await smoke.oneTimeSmoke( + relay, origin, (url: string, headers: Record) => { const { pathname, search } = new URL(url); @@ -472,10 +496,30 @@ test("the deployment smoke passes against the relay Worker, which its previews a return new WebSocket(new URL(pathname + search, local), init); }, // Miniflare's own Response, which the smoke reads the way it reads fetch's. + ((url: string, init?: { redirect?: "manual" }) => production.mf.dispatchFetch(url, init)) as unknown as typeof fetch, + ); + await smoke.oneTimePageSmoke( + origin, ((url: string) => production.mf.dispatchFetch(url)) as unknown as typeof fetch, ); }); +test("the deployment smoke fails on a relay still answering the old page, before it opens a socket", async () => { + const opened: string[] = []; + await expect( + smoke.oneTimeSmoke( + relay, + origin, + ((url: string) => { + opened.push(url); + throw new Error("no socket should open"); + }), + (async () => new Response(PAGE_SHELL, { status: 200 })) as unknown as typeof fetch, + ), + ).rejects.toThrow(/moves an older build's link/); + expect(opened).toEqual([]); +}); + test("the smoke's copies of the contract match remote-lib-common", () => { expect(smoke.ONE_TIME_WS_ROUTES).toEqual(ONE_TIME_WS_ROUTES); expect(smoke.ONE_TIME_ROOM_PARAM).toBe(ONE_TIME_ROOM_PARAM); @@ -486,12 +530,12 @@ test("the smoke's copies of the contract match remote-lib-common", () => { /** The page's policy for production, spelled out whole so any widening shows here. */ const PAGE_POLICY = "default-src 'none'; " + - "script-src https://relay.dormouse.sh/connect/assets/ 'wasm-unsafe-eval'; " + - "style-src https://relay.dormouse.sh/connect/assets/ 'unsafe-inline'; " + - "img-src https://relay.dormouse.sh/connect/ data: blob:; " + - "font-src https://relay.dormouse.sh/connect/; " + + "script-src https://try.dormouse.sh/connect/assets/ 'wasm-unsafe-eval'; " + + "style-src https://try.dormouse.sh/connect/assets/ 'unsafe-inline'; " + + "img-src https://try.dormouse.sh/connect/ data: blob:; " + + "font-src https://try.dormouse.sh/connect/; " + "media-src blob:; " + - "connect-src wss://relay.dormouse.sh/api/one-time/client; " + + "connect-src wss://try.dormouse.sh/api/one-time/client; " + "frame-src https://*.dormouseusercontent.com; " + "worker-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'; " + "object-src 'none'; sandbox allow-scripts allow-same-origin allow-forms allow-popups " + @@ -499,7 +543,7 @@ const PAGE_POLICY = test("the page's policy admits no source outside /connect/ but its one socket and the frame domain", () => { // `docs/specs/security-hosted.md`'s FAIL IF, directive by directive: `'self'` - // would admit whatever else the relay origin serves. + // would admit whatever else this origin answers. const socket = `${origin.replace(/^http/, "ws")}${ONE_TIME_WS_ROUTES.client}`; for (const directive of oneTimePagePolicy(origin).split("; ")) { const [name, ...sources] = directive.split(" "); @@ -562,17 +606,66 @@ test("nothing else under /connect/ is served, and nothing outside it gets the pa expect(response.headers.get("content-security-policy"), path).toBe(PAGE_POLICY); } expect((await get(ONE_TIME_PAGE_PATH, { method: "POST" })).status).toBe(404); - // Every other path is Pocket's (`pocket.test.ts`), under Pocket's policy. - for (const path of ["/", "/connected", "/login", "/assets/connect/x.js"]) { + // No other path holds a page: no shell to fall back on, and no policy to run one. + for (const path of ["/", "/connected", "/login", "/assets/connect/x.js", ONE_TIME_WS_ROUTES.burrow]) { const response = await get(path); - expect(response.headers.get("content-security-policy"), path).toBe( - pocketContentSecurityPolicy(origin, HOSTED_FRAME_DOMAIN), - ); + expect(response.status, path).toBe(404); + expect(response.headers.get("content-security-policy"), path).toBe(RUNS_NOTHING_POLICY); + } +}); + +test("a link from an older build moves to the page's own origin, rendering nothing on the relay's", async () => { + for (const path of ["/connect", ONE_TIME_PAGE_PATH, `${ONE_TIME_PAGE_PATH}?x=1`, "/%63onnect/"]) { + const response = await production.mf.dispatchFetch(relay + path, { redirect: "manual" }); + expect(response.status, path).toBe(308); + expect(response.headers.get("location"), path).toBe(origin + ONE_TIME_PAGE_PATH); + expect(response.headers.get("cache-control"), path).toBe("no-store"); + expect(response.headers.get("content-security-policy"), path).toBe(RUNS_NOTHING_POLICY); + expect(await response.text(), path).toBe(""); + } + // The page's own paths and the client route are gone, not Pocket's shell. + for (const path of [ + PAGE_SCRIPT, + `${ONE_TIME_PAGE_PATH}index.html`, + `${ONE_TIME_PAGE_PATH}other`, + joinPath("A".repeat(E2E_ID_LENGTH)), + ]) { + const response = await production.mf.dispatchFetch(relay + path, { + redirect: "manual", + headers: { origin: relay }, + }); + expect(response.status, path).toBe(404); + expect(await response.text(), path).not.toContain(POCKET_SHELL); + expect(response.headers.get("content-security-policy"), path).toBe(RUNS_NOTHING_POLICY); } }); +test("an older build's room, minted on the relay as ever, is joined from the page's origin", async () => { + const { burrow, frame } = await mint(); + const phone = await join(frame.roomId); + phone.send("through the try Worker"); + expect(await burrow.next()).toBe("through the try Worker"); + burrow.send("through the relay Worker"); + expect(await phone.next()).toBe("through the relay Worker"); + burrow.close(); +}); + +test("the try Worker holds nothing but the page, its rooms, and their join limit", () => { + expect(wrangler.try.vars).toEqual({ APP_ORIGIN: origin }); + expect(wrangler.try).not.toHaveProperty("hyperdrive"); + expect(wrangler.try.ratelimits?.map(({ name }) => name)).toEqual(["ONE_TIME_JOIN_LIMIT"]); + expect(wrangler.try.durable_objects?.bindings).toEqual([ + { name: "ONE_TIME_ROOM", class_name: "OneTimeRoom" }, + ]); + expect(wrangler.relay.durable_objects?.bindings).toContainEqual({ + name: "ONE_TIME_ROOM", + class_name: "OneTimeRoom", + script_name: wrangler.try.name, + }); +}); + test("a malformed APP_ORIGIN falls back to the policy that runs nothing on the page", () => { - expect(relayRules("/connect/", "http://localhost:8787").policy).toBe( + expect(tryRules("/connect/", "http://localhost:8787").policy).toBe( oneTimePagePolicy("http://localhost:8787"), ); expect(oneTimePagePolicy("http://localhost:8787")).toContain( @@ -581,18 +674,18 @@ test("a malformed APP_ORIGIN falls back to the policy that runs nothing on the p for (const bad of [ undefined, "", - "https://relay.dormouse.sh/", - "https://relay.dormouse.sh; script-src *", - "https://relay.dormouse.sh 'unsafe-inline'", + "https://try.dormouse.sh/", + "https://try.dormouse.sh; script-src *", + "https://try.dormouse.sh 'unsafe-inline'", // Each is its own origin by the URL parser's rule, and a directive in the header. "https://evil.example;script-src", "https://evil.example,x", "https://evil.example'x", "javascript:alert(1)", - "ws://relay.dormouse.sh", + "ws://try.dormouse.sh", ]) for (const path of ["/connect/", "/"]) - expect(relayRules(path, bad).policy, `${path} ${String(bad)}`).toBe( + expect(tryRules(path, bad).policy, `${path} ${String(bad)}`).toBe( RUNS_NOTHING_POLICY, ); }); diff --git a/hosted/server/tests/pocket.test.ts b/hosted/server/tests/pocket.test.ts index f23565db4..66c11772d 100644 --- a/hosted/server/tests/pocket.test.ts +++ b/hosted/server/tests/pocket.test.ts @@ -14,7 +14,7 @@ import { toBase64Url, } from "remote-lib-common"; import { RUNS_NOTHING_POLICY, oneTimePagePolicy } from "../headers"; -import { ENTRIES, ORIGINS, TEST_ENROLL_SECRET, bundleWorker, miniflareOptions } from "./bundle"; +import { ENTRIES, ORIGINS, TEST_ENROLL_SECRET, alone, bundleWorker, miniflareOptions } from "./bundle"; import { limitOf, untilLimited } from "./rate-limit"; // Pocket at the relay's root (`docs/specs/pocket-app.md` -> "Serving the @@ -64,12 +64,12 @@ function assets(request: Request) { let relay: Miniflare; beforeAll(async () => { relay = new Miniflare( - miniflareOptions("relay", (await bundleWorker(ENTRIES.relay)).outputFiles[0].text, { + alone("relay", miniflareOptions("relay", (await bundleWorker(ENTRIES.relay)).outputFiles[0].text, { bindings: { APP_ORIGIN: origin, RELAY_ENROLL_SECRET: TEST_ENROLL_SECRET }, // Nothing listens here, so a route that reached the database would fail. hyperdrives: { HYPERDRIVE: "postgres://user:pass@127.0.0.1:9/none" }, serviceBindings: { ASSETS: assets }, - }), + })), ); await relay.ready; }); @@ -157,11 +157,11 @@ test("the API and socket routes are never Pocket's: no shell, no page policy, no expect((await get(WS_ROUTES.burrow)).status).toBe(426); }); -test("a percent-encoded /connect path is the page's route, under its policy and without the camera", async () => { +test("a percent-encoded /connect path is the moved page's, under no page's policy and without the camera", async () => { for (const path of ["/%63onnect/", "/%63onnect", `${ONE_TIME_PAGE_PATH}%61ssets/x.js`]) { const response = await get(path); expect(response.headers.get("content-type") ?? "", path).not.toContain("text/html"); - expect(response.headers.get("content-security-policy"), path).toBe(oneTimePagePolicy(origin)); + expect(response.headers.get("content-security-policy"), path).toBe(RUNS_NOTHING_POLICY); expect(response.headers.get("permissions-policy"), path).toBe( "camera=(), microphone=(), geolocation=()", ); diff --git a/hosted/server/tests/relay-room-entry.ts b/hosted/server/tests/relay-room-entry.ts index 86fca34ea..cde18549a 100644 --- a/hosted/server/tests/relay-room-entry.ts +++ b/hosted/server/tests/relay-room-entry.ts @@ -2,7 +2,6 @@ import type { ExecutionContext } from "hono"; import { WS_ROUTES } from "remote-lib-common"; import { RELAY_ROOM_PARAMS } from "../relay-room-contract"; import worker, { - OneTimeRoom, RelayRoom as ProductionRoom, RelayRows as ProductionRows, } from "../relay-worker"; @@ -114,8 +113,6 @@ export class RelayRows extends ProductionRows { } } -export { OneTimeRoom }; - type TestEnv = { RELAY_ROOM: DurableObjectNamespace unknown>> }; /** diff --git a/hosted/server/tests/relay-room.test.ts b/hosted/server/tests/relay-room.test.ts index 34bc7613c..4891502b5 100644 --- a/hosted/server/tests/relay-room.test.ts +++ b/hosted/server/tests/relay-room.test.ts @@ -51,7 +51,7 @@ import { RELAY_ROOM_SWEEP_RETRY_MS, RELAY_ROW_READ_TIMEOUT_MS, } from "../relay-room-contract"; -import { ORIGINS, TEST_ENROLL_SECRET, bundleWorker, miniflareOptions, wrangler } from "./bundle"; +import { ORIGINS, TEST_ENROLL_SECRET, alone, bundleWorker, miniflareOptions, wrangler } from "./bundle"; // The Hosted Relay's sockets and its per-account `RelayRoom` // (`docs/specs/hosted.md` -> "Relay sockets") in real workerd against real @@ -125,7 +125,7 @@ beforeAll(async () => { context = await createTestContext({ migrations }); const databases = await workerDatabases(context.database.url); relay = new Miniflare({ - ...miniflareOptions( + ...alone("relay", miniflareOptions( "relay", (await bundleWorker("server/tests/relay-room-entry.ts")).outputFiles[0].text, { @@ -140,7 +140,7 @@ beforeAll(async () => { new WorkerResponse("", { headers: { "content-type": "text/html" } }), }, }, - ), + )), upstream: origin, }); base = await relay.ready; diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index 6fca4421f..2065acd64 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -63,6 +63,7 @@ import { ORIGINS, TEST_ENROLL_SECRET, bundleWorker, + alone, miniflareOptions, testVapidKeys, } from "./bundle"; @@ -94,7 +95,7 @@ async function fixture({ bindings = {} }: { bindings?: Record WorkerResponse | Promise = () => new WorkerResponse(null, { status: 201 }); const relay = new Miniflare( - miniflareOptions("relay", (await script).outputFiles[0].text, { + alone("relay", miniflareOptions("relay", (await script).outputFiles[0].text, { bindings: Object.fromEntries( Object.entries({ APP_ORIGIN: origin, @@ -119,7 +120,7 @@ async function fixture({ bindings = {} }: { bindings?: Record new WorkerResponse("", { headers: { "content-type": "text/html" } }), outboundService, routes: [`${new URL(ORIGINS.relay).host}/*`], - }), + })), ), ); // The voice Worker on the same database, with every binding the account diff --git a/hosted/server/try-worker.ts b/hosted/server/try-worker.ts new file mode 100644 index 000000000..e390cef3e --- /dev/null +++ b/hosted/server/try-worker.ts @@ -0,0 +1,23 @@ +import { tryBindings, type TryEnv } from "./bindings"; +import { tryRules } from "./headers"; +import { oneTimeClientRoute, oneTimePageRoutes } from "./one-time"; +import { workerApp } from "./worker-app"; + +/** + * The try Worker, `dormouse-try` on `try.dormouse.sh`: the one-time + * phone page, which renders what a stranger's link sends, on an origin that + * holds no paired phone's keys, and the route that page joins a room on. It + * implements `OneTimeRoom`, which the relay's Burrow route mints in, and holds + * no secret and no database, so it has no readiness to report. + */ +export default workerApp({ + bindings: tryBindings, + rules: tryRules, + unavailable: "The one-time connection is temporarily unavailable. Please try again.", + routes(app) { + oneTimeClientRoute(app); + oneTimePageRoutes(app); + }, +}); + +export { OneTimeRoom } from "./one-time-room"; diff --git a/hosted/wrangler.frames.jsonc b/hosted/wrangler.frames.jsonc index a71c97d52..4b753377d 100644 --- a/hosted/wrangler.frames.jsonc +++ b/hosted/wrangler.frames.jsonc @@ -21,7 +21,7 @@ "run_worker_first": true }, "vars": { - "CLIENT_ORIGIN": "https://relay.dormouse.sh" + "CLIENT_ORIGINS": "https://relay.dormouse.sh https://try.dormouse.sh" }, "observability": { "enabled": false diff --git a/hosted/wrangler.relay.jsonc b/hosted/wrangler.relay.jsonc index c08346b6d..2756dfe9d 100644 --- a/hosted/wrangler.relay.jsonc +++ b/hosted/wrangler.relay.jsonc @@ -22,7 +22,8 @@ }, "vars": { "APP_ORIGIN": "https://relay.dormouse.sh", - "ACCOUNT_ORIGIN": "https://hosted.dormouse.sh" + "ACCOUNT_ORIGIN": "https://hosted.dormouse.sh", + "TRY_ORIGIN": "https://try.dormouse.sh" }, // Its own Hyperdrive, connecting as `dormouse_relay`; the release supplies the // `RELAY_HYPERDRIVE_ID` variable. All zeros until set here (hosted/README.md -> @@ -38,7 +39,8 @@ "bindings": [ { "name": "ONE_TIME_ROOM", - "class_name": "OneTimeRoom" + "class_name": "OneTimeRoom", + "script_name": "dormouse-try" }, { "name": "RELAY_ROOM", @@ -59,6 +61,12 @@ "new_sqlite_classes": [ "RelayRoom" ] + }, + { + "tag": "v3", + "deleted_classes": [ + "OneTimeRoom" + ] } ], "ratelimits": [ @@ -70,14 +78,6 @@ "period": 60 } }, - { - "name": "ONE_TIME_JOIN_LIMIT", - "namespace_id": "2", - "simple": { - "limit": 30, - "period": 60 - } - }, { "name": "RELAY_SIGNIN_LIMIT", "namespace_id": "3", diff --git a/hosted/wrangler.try.jsonc b/hosted/wrangler.try.jsonc new file mode 100644 index 000000000..91825c289 --- /dev/null +++ b/hosted/wrangler.try.jsonc @@ -0,0 +1,56 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "dormouse-try", + "main": "server/try-worker.ts", + "compatibility_date": "2026-09-08", + "compatibility_flags": [ + "nodejs_compat" + ], + "workers_dev": false, + "preview_urls": false, + "routes": [ + { + "pattern": "try.dormouse.sh", + "custom_domain": true + } + ], + "assets": { + "directory": "./dist/try", + "binding": "ASSETS", + "not_found_handling": "none", + "run_worker_first": true + }, + "vars": { + "APP_ORIGIN": "https://try.dormouse.sh" + }, + "durable_objects": { + "bindings": [ + { + "name": "ONE_TIME_ROOM", + "class_name": "OneTimeRoom" + } + ] + }, + // Append-only: a deployed tag is never edited. + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": [ + "OneTimeRoom" + ] + } + ], + "ratelimits": [ + { + "name": "ONE_TIME_JOIN_LIMIT", + "namespace_id": "2", + "simple": { + "limit": 30, + "period": 60 + } + } + ], + "observability": { + "enabled": false + } +} diff --git a/lib/src/host/relay-origin.test.ts b/lib/src/host/relay-origin.test.ts index 46eddcf85..f5cd5a650 100644 --- a/lib/src/host/relay-origin.test.ts +++ b/lib/src/host/relay-origin.test.ts @@ -17,6 +17,7 @@ import { readConfigs } from '../../../hosted/scripts/workers.mjs'; import { DEFAULT_RELAY_ORIGIN, HOSTED_ACCOUNT_ORIGIN, + HOSTED_ONE_TIME_ORIGIN, HOSTED_VOICE_ORIGIN, bakedRelay, bakedRelayMode, @@ -25,6 +26,7 @@ import { hostedVoiceOrigin, isDevHostedBuild, isRelayOrigin, + oneTimeLinkOrigin, } from './relay-origin'; /** @@ -74,8 +76,9 @@ describe('the baked relay origin', () => { expect(BUILD_DEFAULT).toBe(DEFAULT_RELAY_ORIGIN); }); - it('names the origins Hosted deploys its three Workers at', async () => { - const { account, relay, voice } = await readConfigs(); + it('names the origins Hosted deploys its Workers at', async () => { + const { account, try: tryWorker, relay, voice } = await readConfigs(); + expect(HOSTED_ONE_TIME_ORIGIN).toBe(tryWorker.vars.APP_ORIGIN); expect(DEFAULT_RELAY_ORIGIN).toBe(relay.vars.APP_ORIGIN); expect(HOSTED_VOICE_ORIGIN).toBe(voice.vars.APP_ORIGIN); expect(HOSTED_ACCOUNT_ORIGIN).toBe(account.vars.APP_ORIGIN); @@ -102,6 +105,12 @@ describe('the baked relay origin', () => { it('reaches Hosted only in a Hosted build', () => { expect(hostedOrigin({ origin: 'http://localhost:8787', mode: 'hosted' })).toBe('http://localhost:8787'); expect(hostedOrigin({ origin: 'https://relay.example.ts.net', mode: 'self-host' })).toBeNull(); + // A stock build's link leaves the relay's origin; a dev build's page is + // whichever its local or preview relay redirects to. + expect(oneTimeLinkOrigin({ origin: DEFAULT_RELAY_ORIGIN, mode: 'hosted' })).toBe(HOSTED_ONE_TIME_ORIGIN); + expect(oneTimeLinkOrigin({ origin: 'http://localhost:8787', mode: 'hosted' })).toBe('http://localhost:8787'); + expect(oneTimeLinkOrigin({ origin: 'https://relay.example.ts.net', mode: 'self-host' })).toBeNull(); + expect(HOSTED_ONE_TIME_ORIGIN).not.toBe(DEFAULT_RELAY_ORIGIN); }); it('speaks managed voice at the fixed voice origin, in a Hosted build only', () => { diff --git a/lib/src/host/relay-origin.ts b/lib/src/host/relay-origin.ts index fd81d3815..68dac000b 100644 --- a/lib/src/host/relay-origin.ts +++ b/lib/src/host/relay-origin.ts @@ -1,8 +1,9 @@ /** * The one relay origin this build was baked with, and the mode it sets * (`docs/specs/relay.md` → "Relay origin"): the Burrow's only Relay, and — in a - * Hosted build — where the one-time rendezvous goes too. Managed voice speaks - * at {@link HOSTED_VOICE_ORIGIN} instead, and enrollment is approved at + * Hosted build — where the one-time rendezvous goes too. A one-time link sends + * the phone to {@link HOSTED_ONE_TIME_ORIGIN}, managed voice speaks at + * {@link HOSTED_VOICE_ORIGIN}, and enrollment is approved at * {@link HOSTED_ACCOUNT_ORIGIN}. * * Baked by `scripts/relay-origin.mjs` into both host bundles. **Never webview @@ -34,6 +35,14 @@ export const HOSTED_VOICE_ORIGIN = 'https://voice.dormouse.sh'; */ export const HOSTED_ACCOUNT_ORIGIN = 'https://hosted.dormouse.sh'; +/** + * Where a Hosted build's one-time link sends a phone: the page's own origin, + * which holds no paired phone's keys. A fixed constant, never baked: **the + * desktop never requests it**, and only writes it into the link it shows + * (`docs/specs/one-time.md` → "Link"). + */ +export const HOSTED_ONE_TIME_ORIGIN = 'https://try.dormouse.sh'; + /** The account page that lists its enrolled computers, with Remove. */ export const ACCOUNT_PAGE_PATH = '/account'; @@ -94,6 +103,17 @@ export function hostedOrigin(relay: RelayBuild): string | null { return relay.mode === 'hosted' ? relay.origin : null; } +/** + * Where this build's one-time link sends a phone: + * {@link HOSTED_ONE_TIME_ORIGIN}, or in a dev Hosted build its own relay origin, + * whose old page path redirects to the page that local or preview Worker pairs + * with; `null` in a self-host build. + */ +export function oneTimeLinkOrigin(relay: RelayBuild): string | null { + if (relay.mode !== 'hosted') return null; + return isDevHostedBuild(relay) ? relay.origin : HOSTED_ONE_TIME_ORIGIN; +} + /** * Where this build's managed voice may speak: {@link HOSTED_VOICE_ORIGIN} in a * Hosted build, whatever its relay origin, and `null` in a self-host one. diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 62988d90f..39c00f744 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -116,7 +116,7 @@ import { type TestRendezvous, } from '../../remote/test-rendezvous'; import { createEphemeralBurrowStateStore, type BurrowStateStore } from './burrow-state-store'; -import { DEFAULT_RELAY_ORIGIN } from '../relay-origin'; +import { DEFAULT_RELAY_ORIGIN, HOSTED_ONE_TIME_ORIGIN } from '../relay-origin'; import type { BurrowDirectPeerFactory } from './native-direct-peer'; import { BurrowService, @@ -2373,7 +2373,7 @@ describe('one-time connection', () => { /** Read the link and complete IK against it, as the page does on Connect. */ async function join(url: string): Promise { - const link = await parseOneTimeLinkUrl(url, HOSTED_ORIGIN); + const link = await parseOneTimeLinkUrl(url, HOSTED_ONE_TIME_ORIGIN); if (!link) throw new Error(`a phone could not read the link: ${url}`); return joinOneTimeRoom(rendezvous, link); } @@ -2436,7 +2436,10 @@ describe('one-time connection', () => { expect(waiting.status).toBe('waiting'); expect(rendezvous.rooms).toHaveLength(1); expect(rendezvous.room().burrowUrl).toBe('wss://relay.dormouse.sh/api/one-time/burrow'); - expect((await parseOneTimeLinkUrl(waiting.url, HOSTED_ORIGIN))?.roomId).toBe( + // The socket stays on the relay's origin; the link sends the phone to the page's own. + expect(new URL(waiting.url).origin).toBe('https://try.dormouse.sh'); + expect(await parseOneTimeLinkUrl(waiting.url, HOSTED_ORIGIN)).toBeNull(); + expect((await parseOneTimeLinkUrl(waiting.url, HOSTED_ONE_TIME_ORIGIN))?.roomId).toBe( rendezvous.room().roomId, ); // No enrollment: no relay socket, and nothing sent to a Relay. diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index 2ee27a9da..32b789cf8 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -90,6 +90,7 @@ import { HOSTED_ACCOUNT_ORIGIN, hostedAccountOrigin, hostedOrigin, + oneTimeLinkOrigin, isDevHostedBuild, isRelayOrigin, type RelayBuild, @@ -1439,7 +1440,8 @@ export class BurrowService { */ async #openOneTime(): Promise { const hosted = this.#hostedOrigin; - if (hosted === null) { + const linkOrigin = oneTimeLinkOrigin(this.#relay); + if (hosted === null || linkOrigin === null) { throw new Error( 'One-time connections are unavailable in a self-host build: their links are made at ' + `Dormouse Hosted, and this build reaches only its own Relay (${this.#relay.origin}).`, @@ -1465,6 +1467,7 @@ export class BurrowService { } const runtime: OneTimeRuntime = new OneTimeRuntime({ origin: hosted, + linkOrigin, createWebSocket: this.#createWebSocket, createSession: (opts) => this.#createApiSession(opts), directPeering: directPeeringFor(policy, this.#createDirectPeer), diff --git a/lib/src/host/remote/test-burrow-link.ts b/lib/src/host/remote/test-burrow-link.ts index 28151451a..7ac98b3f6 100644 --- a/lib/src/host/remote/test-burrow-link.ts +++ b/lib/src/host/remote/test-burrow-link.ts @@ -23,7 +23,7 @@ import { fromBase64Url, } from 'remote-lib-common'; -import { DEFAULT_RELAY_ORIGIN, hostedOrigin, type RelayMode } from '../relay-origin'; +import { DEFAULT_RELAY_ORIGIN, HOSTED_ONE_TIME_ORIGIN, hostedOrigin, type RelayMode } from '../relay-origin'; import { idleOneTimeState, type InvitationEvent, @@ -137,7 +137,7 @@ export function oneTimeWaiting( const ephPubBase64Url = '7Hq2LmZx9cVb4NtRkWpYsD0aFgJ1uEiO3yTnC6hQ5Ks'; return { status: 'waiting', - url: formatOneTimeLinkUrl(DEFAULT_RELAY_ORIGIN, { + url: formatOneTimeLinkUrl(HOSTED_ONE_TIME_ORIGIN, { roomId: 'Rm4qT0vXc8LbN2kZyPaE1w', expiry, ephPub: fromBase64Url(ephPubBase64Url), diff --git a/lib/src/remote/burrow/one-time-runtime.test.ts b/lib/src/remote/burrow/one-time-runtime.test.ts index 25cc1f596..30d600d60 100644 --- a/lib/src/remote/burrow/one-time-runtime.test.ts +++ b/lib/src/remote/burrow/one-time-runtime.test.ts @@ -150,6 +150,7 @@ function makeRuntime( : options.createDirectPeer; runtime = new OneTimeRuntime({ origin: ORIGIN, + linkOrigin: ORIGIN, createWebSocket: (url) => rendezvous.createBurrowSocket(url), createSession: ({ burrowId, send, label, end }) => { const api: ServedApi = { burrowId, label, end, handled: [], disposed: false, send }; diff --git a/lib/src/remote/burrow/one-time-runtime.ts b/lib/src/remote/burrow/one-time-runtime.ts index 70a8d528e..4e222bda9 100644 --- a/lib/src/remote/burrow/one-time-runtime.ts +++ b/lib/src/remote/burrow/one-time-runtime.ts @@ -150,6 +150,12 @@ export interface OneTimeRuntimeOptions { * the service passes its baked origin. */ readonly origin: string; + /** + * The origin the link it shows names, where the phone page is; never one a + * command chose. A link printed for another Burrow's phone is all it is here: + * the runtime never connects to it. + */ + readonly linkOrigin: string; /** * Open the Burrow route. **The socket must send no `Origin` header** — the * route refuses one, so no browser page can mint a room — which is why the host @@ -223,6 +229,7 @@ const END_REASON_FOR_DENIAL: Record = { export class OneTimeRuntime { readonly #origin: string; + readonly #linkOrigin: string; readonly #createWebSocket: (url: string) => RemoteWebSocket; readonly #createSession: OneTimeRuntimeOptions['createSession']; readonly #directPeering: DirectPeering; @@ -285,6 +292,7 @@ export class OneTimeRuntime { constructor(options: OneTimeRuntimeOptions) { this.#origin = options.origin; + this.#linkOrigin = options.linkOrigin; this.#createWebSocket = options.createWebSocket; this.#createSession = options.createSession; this.#directPeering = options.directPeering; @@ -425,7 +433,7 @@ export class OneTimeRuntime { const link = linkFor(room, keyPair, this.#now()); let url: string; try { - url = formatOneTimeLinkUrl(this.#origin, link); + url = formatOneTimeLinkUrl(this.#linkOrigin, link); } catch (error) { console.warn('[one-time] could not compose the link', error); this.#end('burrow-error'); diff --git a/lib/src/remote/client/one-time-e2e.test.ts b/lib/src/remote/client/one-time-e2e.test.ts index 04134ab41..ce5ad0671 100644 --- a/lib/src/remote/client/one-time-e2e.test.ts +++ b/lib/src/remote/client/one-time-e2e.test.ts @@ -157,6 +157,7 @@ function makeRuntime( if (options.network) network = new FakeDirectNetwork(options.network); runtime = new OneTimeRuntime({ origin: ORIGIN, + linkOrigin: ORIGIN, createWebSocket: (url) => rendezvous.createBurrowSocket(url), createSession: ({ burrowId, send, label }) => new RemoteApiSession({ burrowId, send, provider, holder: { id: 'holder-1', label } }), diff --git a/lib/src/remote/one-time-app/OneTimeApp.tsx b/lib/src/remote/one-time-app/OneTimeApp.tsx index 0e31f8f70..b9c5f0679 100644 --- a/lib/src/remote/one-time-app/OneTimeApp.tsx +++ b/lib/src/remote/one-time-app/OneTimeApp.tsx @@ -2,7 +2,7 @@ * The one-time phone page (`docs/specs/one-time.md` -> "Phone page"), which * Hosted serves at `/connect/`: the link a computer showed, a Connect tap, two * digits to type on that computer, then Pocket's mobile wall over the direct - * path — and nothing kept, since this origin is Hosted's accounts' too. + * path — and nothing kept, on an origin of its own that holds no paired keys. * * The screens draw on Pocket's chrome and shared views; the session is a * {@link OneTimeClient}, whose `connectOnce` runs the whole ceremony and is the diff --git a/lib/src/remote/pocket-app/deployment.test.ts b/lib/src/remote/pocket-app/deployment.test.ts index ff2e3239a..fd56f5506 100644 --- a/lib/src/remote/pocket-app/deployment.test.ts +++ b/lib/src/remote/pocket-app/deployment.test.ts @@ -189,6 +189,9 @@ describe('Pocket’s direct-peer factory', () => { describe('frameDomainFor', () => { it('names the frame domain only to Hosted at the origin the frame Worker lets frame it', () => { expect(frameDomainFor('hosted', 'https://relay.dormouse.sh')).toBe('dormouseusercontent.com'); + expect(frameDomainFor('hosted', 'https://try.dormouse.sh')).toBe('dormouseusercontent.com'); + expect(frameDomainFor('hosted', 'https://dormouse-try-pr-7.example.workers.dev')).toBeNull(); + expect(frameDomainFor('hosted', 'http://localhost:8788')).toBeNull(); expect(frameDomainFor('hosted', 'https://dormouse-relay-pr-7.example.workers.dev')).toBeNull(); expect(frameDomainFor('self-host', 'https://relay.dormouse.sh')).toBeNull(); expect(frameDomainFor(null, 'https://relay.dormouse.sh')).toBeNull(); diff --git a/lib/src/remote/pocket-app/deployment.ts b/lib/src/remote/pocket-app/deployment.ts index 20fdf0871..44e15fd2c 100644 --- a/lib/src/remote/pocket-app/deployment.ts +++ b/lib/src/remote/pocket-app/deployment.ts @@ -11,7 +11,7 @@ import { HOSTED_FRAME_DOMAIN, HOSTED_POCKET_DEPLOYMENT, POCKET_DEPLOYMENT_FILE } from 'remote-lib-common'; -import { DEFAULT_RELAY_ORIGIN } from '../../host/relay-origin'; +import { DEFAULT_RELAY_ORIGIN, HOSTED_ONE_TIME_ORIGIN } from '../../host/relay-origin'; import type { DirectPeerFactory } from '../direct/direct-peer'; import { hostedDirectPeer, selfHostDirectPeer } from '../client/browser-direct-peer'; @@ -37,11 +37,13 @@ export function parsePocketDeployment(body: unknown): PocketDeployment { /** * The parent of this page's frame origins, or `null` where it frames nothing: - * the frame Worker lets only Hosted's production Pocket frame it, and a preview - * serves the same bundle at another origin. + * the frame Worker lets only Hosted's production Pocket and one-time page frame + * it, and a preview serves the same bundles at other origins. */ export function frameDomainFor(deployment: PocketDeployment | null, origin: string): string | null { - return deployment === 'hosted' && origin === DEFAULT_RELAY_ORIGIN ? HOSTED_FRAME_DOMAIN : null; + return deployment === 'hosted' && (origin === DEFAULT_RELAY_ORIGIN || origin === HOSTED_ONE_TIME_ORIGIN) + ? HOSTED_FRAME_DOMAIN + : null; } /** diff --git a/scripts/e2e-lint-selftest.mjs b/scripts/e2e-lint-selftest.mjs index bb0b7937e..048076711 100644 --- a/scripts/e2e-lint-selftest.mjs +++ b/scripts/e2e-lint-selftest.mjs @@ -212,6 +212,18 @@ for (const [file, violation] of [ selftest.withAppended(file, violation, `a store in ${file} stays green: ${violation.trim()}`); } +// Each name the relay Worker's no-page rule lists must redden it on its own, in +// the entry and in the staging that would copy the page beside Pocket. +for (const [file, violation] of [ + ['hosted/server/relay-worker.ts', '\nconst __selftest = oneTimePageRoutes;\n'], + ['hosted/server/relay-worker.ts', '\nconst __selftest = oneTimeClientRoute;\n'], + ['hosted/server/relay-worker.ts', "\nexport { OneTimeRoom } from './one-time-room';\n"], + ['hosted/scripts/stage-relay.mjs', "\nconst __selftest = 'dist-one-time';\n"], + ['hosted/scripts/stage-relay.mjs', '\nconst __selftest = ONE_TIME_SHELL;\n'], +]) { + selftest.withAppended(file, violation, `a one-time page in ${file} stays green: ${violation.trim()}`); +} + // The goodbye is a control message like the signals, so naming it — as its // tag or as its type — reddens the routing rule on its own, not just the SDP // the loop appends. diff --git a/scripts/e2e-lint.mjs b/scripts/e2e-lint.mjs index 964ebfa42..ebff2c594 100644 --- a/scripts/e2e-lint.mjs +++ b/scripts/e2e-lint.mjs @@ -15,7 +15,8 @@ * signaling, no ICE server but Cloudflare's STUN and no TURN at all, no * checked-in service worker shadowing the built one, no one-time frame the * Relay or `BurrowRuntime` could read, no parse in - * the Hosted room that forwards one, no grant a one-time connection could + * the Hosted room that forwards one, no one-time page or phone route in Hosted's + * relay Worker, no grant a one-time connection could * leave behind, no store a one-time phone could keep anything in, no presence * window anywhere but the Burrow, and no relayed application message a * Local-networks session would read. An @@ -153,7 +154,7 @@ const ONE_TIME_PHONE_MODULES = [ ONE_TIME_RENDEZVOUS, ]; -/** The one-time phone page, served from an origin Hosted's accounts share: every module in it. */ +/** The one-time phone page, which keeps nothing on the origin it has to itself: every module in it. */ const ONE_TIME_PAGE_TREE = 'lib/src/remote/one-time-app/'; /** @@ -528,6 +529,19 @@ export const RULES = [ violationFile: 'hosted/server/one-time-room.ts', violation: '\nconst __selftest = (frame: string) => JSON.parse(frame);\n', }, + { + rule: "Hosted's relay Worker serves no one-time page and admits no phone", + spec: HOSTED_SECURITY_SPEC, + security: 'the one-time page is served, or a phone admitted, on any origin holding a paired phone', + kind: 'forbid', + files: ['hosted/server/relay-worker.ts', 'hosted/scripts/stage-relay.mjs'], + // Pocket's origin holds a paired phone's keys, and the page renders what a + // stranger's link sends: a relay entry mounting the page's routes, or a + // relay staging that copies the page, puts the two back on one origin. + pattern: /\b(?:oneTimePageRoutes|oneTimeClientRoute|OneTimeRoom|ONE_TIME_SHELL|dist-one-time)\b/g, + violationFile: 'hosted/server/relay-worker.ts', + violation: '\nconst __selftest = oneTimeClientRoute;\n', + }, { rule: "Hosted's RelayRoom never names, parses, decodes, logs, or stores a frame", spec: HOSTED_SECURITY_SPEC, diff --git a/scripts/frame-origin-check/serve.mjs b/scripts/frame-origin-check/serve.mjs index d60f9a29c..d73ff795f 100644 --- a/scripts/frame-origin-check/serve.mjs +++ b/scripts/frame-origin-check/serve.mjs @@ -110,7 +110,7 @@ http.createServer(async (req, res) => { const { pathname } = new URL(req.url, frameOrigin); if (pathname === '/__dormouse-frame/probe.html') return send(res, 200, 'text/html; charset=utf-8', readFileSync(join(here, 'probe.html')), PROBE_HEADERS); if (pathname === '/__dormouse-frame/probe.js') return send(res, 200, 'text/javascript', readFileSync(join(here, 'probe.js')), PROBE_HEADERS); - const response = await frameOriginResponse(new Request(new URL(req.url, frameOrigin), { method: req.method }), { ASSETS: assets, CLIENT_ORIGIN: pocketOrigin }); + const response = await frameOriginResponse(new Request(new URL(req.url, frameOrigin), { method: req.method }), { ASSETS: assets, CLIENT_ORIGINS: pocketOrigin }); res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer())); }).listen(Number(values['frame-port']), '127.0.0.1'); diff --git a/scripts/outbound-lint.mjs b/scripts/outbound-lint.mjs index d94fd0128..eb53a2311 100644 --- a/scripts/outbound-lint.mjs +++ b/scripts/outbound-lint.mjs @@ -304,8 +304,8 @@ const OUTBOUND_SITES = { */ const REMOTE_LITERALS = { 'lib/src/host/relay-origin.ts': { - hosts: ['relay.dormouse.sh', 'voice.dormouse.sh', 'hosted.dormouse.sh'], - reason: 'The baked relay origin\'s default and the fixed voice and account origins.', + hosts: ['relay.dormouse.sh', 'voice.dormouse.sh', 'hosted.dormouse.sh', 'try.dormouse.sh'], + reason: 'The baked relay origin\'s default, the fixed voice and account origins, and the one-time page\'s origin, written into a link and never requested.', }, 'standalone/src/updater.ts': { hosts: ['github.com', 'dormouse.sh'], diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index ede432f2c..3c0185826 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 4300, + "AGENTS.md": 4350, "SECURITY.md": 200, "SELF_HOST.md": 4600, "docs/compatible-agents.md": 1800, @@ -12,11 +12,11 @@ "docs/specs/dor-tools-builtin.md": 1800, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 6700, + "docs/specs/hosted.md": 6750, "docs/specs/layout.md": 7400, "docs/specs/mobile-terminal-ui.md": 1800, "docs/specs/mouse-and-clipboard.md": 4450, - "docs/specs/one-time.md": 2700, + "docs/specs/one-time.md": 2950, "docs/specs/pocket-app.md": 4400, "docs/specs/pricing.md": 2750, "docs/specs/relay.md": 5850, @@ -26,7 +26,7 @@ "docs/specs/reopen.md": 1200, "docs/specs/security-audit.md": 5400, "docs/specs/security-ci.md": 3400, - "docs/specs/security-hosted.md": 4650, + "docs/specs/security-hosted.md": 4800, "docs/specs/security-local.md": 7200, "docs/specs/security-remote.md": 8250, "docs/specs/security-supply-chain.md": 1650, diff --git a/website/scripts/hosted-bundles.test.js b/website/scripts/hosted-bundles.test.js index 615ee1c8f..89a581d6a 100644 --- a/website/scripts/hosted-bundles.test.js +++ b/website/scripts/hosted-bundles.test.js @@ -51,6 +51,7 @@ describe('Hosted bundle disclosure', () => { { entry: 'server/frame-worker.ts', platform: 'worker' }, { entry: 'server/worker.ts', platform: 'worker' }, { entry: 'server/relay-worker.ts', platform: 'worker' }, + { entry: 'server/try-worker.ts', platform: 'worker' }, { entry: 'server/voice-worker.ts', platform: 'worker' }, { entry: 'src/main.tsx', platform: 'browser' }, ]); diff --git a/website/scripts/product-sections.js b/website/scripts/product-sections.js index 6d114940d..ce3783162 100644 --- a/website/scripts/product-sections.js +++ b/website/scripts/product-sections.js @@ -40,9 +40,9 @@ export const productSections = [ { id: "hosted", roots: [ - // Hosted's three Workers and the account page. One package, so the walk - // cannot tell the relay Worker's graph from the account's or voice's. - // Pocket and the one-time page, which relay.dormouse.sh serves, are built + // Hosted's application Workers and the account page. One package, so the + // walk cannot tell the relay Worker's graph from the account's or voice's. + // Pocket and the one-time page, which Hosted's Workers serve, are built // from `dormouse-lib`, so the Terminal's root discloses them. "dormouse-hosted", ], diff --git a/website/scripts/product-sections.test.js b/website/scripts/product-sections.test.js index b80c8fdb4..849d83ed8 100644 --- a/website/scripts/product-sections.test.js +++ b/website/scripts/product-sections.test.js @@ -73,7 +73,7 @@ describe('the committed npm disclosure', () => { ]) expect(disclosed).toContain(name); }); - // relay.dormouse.sh serves Pocket and the one-time page, built from + // Hosted's Workers serve Pocket and the one-time page, built from // `dormouse-lib`, whose React and xterm.js the Terminal's root discloses. it('leaves Pocket\'s packages under the Terminal', () => { for (const name of ['react', '@xterm/xterm']) { diff --git a/website/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index 8c6e7d820..bd9954a3d 100644 --- a/website/src/pages/Privacy.tsx +++ b/website/src/pages/Privacy.tsx @@ -14,7 +14,7 @@ const TRUST_MODEL_URL = "https://github.com/diffplug/dormouse/blob/main/docs/spe export const PRIVACY_SECTIONS: PolicySection[] = [ { id: "scope", title: "Who we are and what this covers", body: <> -

DiffPlug LLC operates Dormouse Hosted and is the controller of the personal information described here: the account service at hosted.dormouse.sh, the Hosted Relay and Pocket at relay.dormouse.sh, and Natural Voice at voice.dormouse.sh. This policy covers those services, the paid subscription that unlocks them, and related support. Contact us at support@dormouse.sh about privacy or your account.

+

DiffPlug LLC operates Dormouse Hosted and is the controller of the personal information described here: the account service at hosted.dormouse.sh, the Hosted Relay and Pocket at relay.dormouse.sh, the one-time connection page at try.dormouse.sh, and Natural Voice at voice.dormouse.sh. This policy covers those services, the paid subscription that unlocks them, and related support. Contact us at support@dormouse.sh about privacy or your account.

Our business address is DiffPlug LLC, 447 Sutter St Ste 405, San Francisco, CA 94108, United States.

Dormouse itself runs on your computer and sends no usage telemetry. An update check fetches a version file from dormouse.sh without sending terminal contents or a Hosted account identifier. Automatic update checks run only if enabled. "Check now" makes a request when you click it. Creating an account does not upload your terminal contents, commands, files, or audio.

This website, dormouse.sh, is served by Cloudflare, which processes visitors’ IP addresses and request information to deliver it.

diff --git a/website/src/pages/SupplyChain.test.tsx b/website/src/pages/SupplyChain.test.tsx index 7b92d3d67..e2b8540f0 100644 --- a/website/src/pages/SupplyChain.test.tsx +++ b/website/src/pages/SupplyChain.test.tsx @@ -26,7 +26,7 @@ describe("the Dormouse Hosted product", () => { }); it("names the origins it covers and that none of it installs on the computer", () => { - for (const origin of ["relay.dormouse.sh", "hosted.dormouse.sh", "voice.dormouse.sh"]) { + for (const origin of ["relay.dormouse.sh", "try.dormouse.sh", "hosted.dormouse.sh", "voice.dormouse.sh"]) { expect(markup).toContain(origin); } expect(markup).toContain("none of it is installed on your computer"); diff --git a/website/src/pages/SupplyChain.tsx b/website/src/pages/SupplyChain.tsx index 6b2ae8b4b..66e123298 100644 --- a/website/src/pages/SupplyChain.tsx +++ b/website/src/pages/SupplyChain.tsx @@ -237,7 +237,7 @@ const PRODUCTS: readonly SupplyChainProduct[] = [ id: "hosted", title: "Dormouse Hosted", description: - "Only if you use Dormouse Hosted, and none of it is installed on your computer. relay.dormouse.sh serves Pocket and the one-time connection page to your phone — Pocket, which you may install to the Home Screen, keeps the phone's pairing keys — and its Worker routes your Hosted sessions and carries the one-time handshake. hosted.dormouse.sh serves your account page, and voice.dormouse.sh runs Natural Voice. All three Workers run on Cloudflare. Pocket and the one-time page are built from the Terminal's code, so their packages are listed under the Terminal. The Workers and the account page share one package, so this lists every package its runtime dependencies reach, which is more than any one Worker bundles, except optional add-ons such as test frameworks that a dependency supports but no Hosted code loads.", + "Only if you use Dormouse Hosted, and none of it is installed on your computer. relay.dormouse.sh serves Pocket to your phone — Pocket, which you may install to the Home Screen, keeps the phone's pairing keys — and its Worker routes your Hosted sessions. try.dormouse.sh serves the one-time connection page, and with the relay carries the one-time handshake. hosted.dormouse.sh serves your account page, and voice.dormouse.sh runs Natural Voice. All four Workers run on Cloudflare. Pocket and the one-time page are built from the Terminal's code, so their packages are listed under the Terminal. The Workers and the account page share one package, so this lists every package its runtime dependencies reach, which is more than any one Worker bundles, except optional add-ons such as test frameworks that a dependency supports but no Hosted code loads.", summary: { count: npmDeps.hosted.length, detail: "npm packages" }, sections: [ {