From ee62d62dcefa12acd2201fbe5bf217d6c387f875 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 19:52:40 -0800 Subject: [PATCH 01/11] Serve the one-time phone page and client route from a connect Worker Adds dormouse-connect on connect.dormouse.sh: the page, its client route, and OneTimeRoom, with no Hyperdrive or secret. The relay keeps the Burrow route, binds the room by script name, and answers its old page path with a 308 to the page's origin, so nothing a link sends renders beside Pocket. Frames admit both origins; previews, the production deploy order and smokes, and the dev loop follow. Co-Authored-By: Claude Opus 5.5 --- hosted/README.md | 20 +-- hosted/package.json | 2 +- hosted/scripts/dev-one-time.mjs | 132 ++++++++++------ hosted/scripts/dev-one-time.test.mjs | 49 ++++-- hosted/scripts/one-time-smoke.mjs | 49 ++++-- hosted/scripts/preview-smoke.mjs | 56 ++++--- hosted/scripts/preview.mjs | 12 +- hosted/scripts/preview.test.mjs | 52 +++++-- hosted/scripts/production.mjs | 58 ++++--- hosted/scripts/production.test.mjs | 113 ++++++++++++-- hosted/scripts/stage-connect.mjs | 38 +++++ hosted/scripts/stage-relay.mjs | 44 ++---- hosted/scripts/stage-relay.test.mjs | 60 ++++---- hosted/scripts/workers.mjs | 17 ++- hosted/server/bindings.ts | 27 +++- hosted/server/connect-worker.ts | 23 +++ hosted/server/frame-origin.ts | 12 +- hosted/server/headers.ts | 59 +++---- hosted/server/one-time.ts | 64 +++++--- hosted/server/relay-worker.ts | 13 +- hosted/server/tests/boundary.test.ts | 47 +++++- hosted/server/tests/bundle.ts | 15 +- hosted/server/tests/frame-origin.test.ts | 6 +- hosted/server/tests/one-time-entry.ts | 2 +- hosted/server/tests/one-time.test.ts | 186 ++++++++++++++++------- hosted/server/tests/pocket.test.ts | 10 +- hosted/server/tests/relay-room-entry.ts | 3 - hosted/server/tests/relay-room.test.ts | 6 +- hosted/server/tests/relay.test.ts | 5 +- hosted/server/tests/workers.test.ts | 5 +- hosted/wrangler.connect.jsonc | 56 +++++++ hosted/wrangler.frames.jsonc | 2 +- hosted/wrangler.relay.jsonc | 20 +-- scripts/frame-origin-check/serve.mjs | 2 +- website/scripts/hosted-bundles.test.js | 1 + 35 files changed, 886 insertions(+), 380 deletions(-) create mode 100644 hosted/scripts/stage-connect.mjs create mode 100644 hosted/server/connect-worker.ts create mode 100644 hosted/wrangler.connect.jsonc diff --git a/hosted/README.md b/hosted/README.md index a03166b28..61080691e 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://connect.dormouse.sh` (`dormouse-connect`; [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 connect 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-connect-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-connect` (`connect.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-connect-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`, `connect.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 `connect.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://connect.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 connect origin, crosses a frame each way, and is refused a second phone. Load `https://connect.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..be1954b0a 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-connect.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.connect.jsonc --outdir dist-worker/connect && 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..81897ae6b 100644 --- a/hosted/scripts/dev-one-time.mjs +++ b/hosted/scripts/dev-one-time.mjs @@ -8,11 +8,13 @@ import { parseConfig } 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 connect Workers under `wrangler dev`, each on its + * own port as in production, sharing the connect 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 connect 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,25 +78,49 @@ 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 connect Worker's Wrangler config, written beside the staged page. + * Allowlisted from `hosted/wrangler.connect.jsonc` like the preview's: its + * entry, the room, its migration and rate limit, and the assets binding over the + * staging folder — never a route, so it cannot answer for production. + */ +export function devConnectConfig(base, port) { + return { + name: devName(base.name), + main: "../../server/connect-worker.ts", + compatibility_date: base.compatibility_date, + compatibility_flags: base.compatibility_flags, + assets: { ...base.assets, directory: "./connect-assets" }, + vars: { APP_ORIGIN: devOrigin(port) }, + durable_objects: base.durable_objects, + migrations: base.migrations, + ratelimits: base.ratelimits, + }; +} + /** - * 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. + * The relay Worker's Wrangler config, allowlisted from + * `hosted/wrangler.relay.jsonc` the same way: its entry, its Durable Objects, + * the connect Worker's under its dev name, its migrations and rate limits, and + * assets holding nothing, since the loop builds no Pocket — never a route or a + * production secret. 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 function devConfig(base, port, attached = null) { +export function devRelayConfig(base, port, connectPort, attached = null) { return { - name: `${base.name}-dev`, + name: devName(base.name), main: "../../server/relay-worker.ts", compatibility_date: base.compatibility_date, compatibility_flags: base.compatibility_flags, - assets: { ...base.assets, directory: "./assets" }, + assets: { ...base.assets, directory: "./relay-assets" }, vars: { APP_ORIGIN: devOrigin(port), + CONNECT_ORIGIN: devOrigin(connectPort), RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET, ...(attached && { ACCOUNT_ORIGIN: attached.accountOrigin }), }, @@ -100,7 +129,11 @@ export function devConfig(base, port, attached = null) { { binding: "HYPERDRIVE", id: LOCAL_HYPERDRIVE_ID, localConnectionString: attached.databaseUrl }, ], }), - durable_objects: base.durable_objects, + durable_objects: { + bindings: base.durable_objects.bindings.map((binding) => + binding.script_name ? { ...binding, script_name: devName(binding.script_name) } : binding, + ), + }, migrations: base.migrations, ratelimits: base.ratelimits, }; @@ -139,15 +172,21 @@ async function waitFor(what, ready, timeoutMs) { async function main() { const port = devPort(process.env); - const origin = devOrigin(port); + const connectPort = 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, "connect-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 connectConfig = writeConfig("connect.json", devConnectConfig(read("wrangler.connect.jsonc"), connectPort)); + const relayConfig = writeConfig("relay.json", devRelayConfig(read("wrangler.relay.jsonc"), port, connectPort, attached)); // Each package's own bin shim, which execs its tool, so a signal reaches it. const run = (name, args, cwd) => { @@ -177,28 +216,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(connectConfig, connectPort); + await waitFor("the connect Worker", async () => (await status(connectPort, 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(connectPort)}${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 +257,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: connectPort, path: PAGE_PATH })}\u001b\\`, ); } diff --git a/hosted/scripts/dev-one-time.test.mjs b/hosted/scripts/dev-one-time.test.mjs index a281cc2bc..168891ba7 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, + devConnectConfig, 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 connectBase = await readBase("wrangler.connect.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", + CONNECT_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 connect Worker's dev script, never a deployed one. + assert.deepEqual(config.durable_objects.bindings, [ + { name: "ONE_TIME_ROOM", class_name: "OneTimeRoom", script_name: devConnectConfig(connectBase, 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 connect Worker's dev config runs its entry and the room, with nothing of production's", () => { + const config = devConnectConfig( + { ...connectBase, vars: { ...connectBase.vars, GOOGLE_CLIENT_SECRET: "do-not-copy" }, d1_databases: [{ production: true }] }, + 8788, + ); + assert.equal(config.main, "../../server/connect-worker.ts"); + assert.notEqual(config.name, connectBase.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, connectBase.durable_objects); + assert.deepEqual(config.migrations, connectBase.migrations); + assert.deepEqual(config.ratelimits, connectBase.ratelimits); + assert.deepEqual(config.assets, { ...connectBase.assets, directory: "./connect-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 connect 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", + CONNECT_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..7df03c30d 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 `connect`, a Burrow can mint on `relay`, a browser + * Origin cannot, a phone from `connect` joins there and a frame crosses each way + * unchanged, a page on `relay` cannot join, and a second phone is refused as + * taken. `connect` 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, connect, ws = open, fetchPage = fetch) { + await movedSmoke(relay, connect, 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: connect })); 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 = `${connect.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: connect })); 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: connect })); 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, connect, 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"), + connect + 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 connect 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..f8d1f2267 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 connect 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 connectSmoke( + 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 `connect`, each retried on its own the + * same way. */ export async function relaySmoke( origin, + connect, sha, { fetcher = fetch, - oneTime = (origin) => oneTimeSmoke(origin), + oneTime = (relay, page) => oneTimeSmoke(relay, page), 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, connect), 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. + * `connectSmoke`, 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, connect }`), + * `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, connect }, sha, { fetcher = fetch, preview = false, providers = [], - oneTime = (origin) => oneTimeSmoke(origin), + oneTime = (relay, page) => oneTimeSmoke(relay, page), 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, connect, sha, { fetcher, oneTime, attempts: limit("relay"), retryMs, wait }), liveSmoke(voice, sha, fetcher, limit("voice"), retry), + connectSmoke(connect, sha, { fetcher, attempts: limit("connect"), retryMs, wait }), ]); const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [], @@ -413,10 +431,10 @@ if ( ) { const [origins, sha] = process.argv.slice(2); assert.match(sha ?? "", /^[a-f0-9]{40}$/); - const { account, relay, voice } = JSON.parse(origins); + const { account, relay, voice, connect } = JSON.parse(origins); // 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({ account, relay, voice, connect }, sha, { preview: true, attempts: 6 }); console.log( - `Preview smoke checks passed: ${account}/login, ${relay}/connect/, ${voice} (${sha})`, + `Preview smoke checks passed: ${account}/login, ${connect}/connect/, ${voice} (${sha})`, ); } diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs index 06308434a..6afb138b2 100644 --- a/hosted/scripts/preview.mjs +++ b/hosted/scripts/preview.mjs @@ -79,10 +79,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 connect preview, never + * production's. */ export function previewConfigs(bases, env, hyperdriveId) { const configs = Object.fromEntries( @@ -92,6 +93,7 @@ export function previewConfigs(bases, env, hyperdriveId) { ]), ); configs.relay.vars.ACCOUNT_ORIGIN = configs.account.vars.APP_ORIGIN; + configs.relay.vars.CONNECT_ORIGIN = configs.connect.vars.APP_ORIGIN; return configs; } @@ -238,7 +240,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 +274,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.connect}/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..25a282923 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), + connect: stale(bases.connect), }, 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"], + connect: ["dormouse-connect-pr-42", "../../server/connect-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", + CONNECT_ORIGIN: "https://dormouse-connect-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.connect.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 connect preview, which implements it. + assert.deepEqual(configs.relay.durable_objects.bindings, [ + { name: "ONE_TIME_ROOM", class_name: "OneTimeRoom", script_name: "dormouse-connect-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.connect.durable_objects, bases.connect.durable_objects); + assert.deepEqual(configs.connect.migrations, bases.connect.migrations); + assert.equal(configs.connect.assets.directory, "../../dist/connect"); + for (const worker of ["relay", "account", "connect"]) assert.deepEqual( configs[worker].ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), bases[worker].ratelimits.map(({ name, namespace_id }) => [ @@ -314,14 +326,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-connect-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 +419,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-connect", "dormouse-relay", "dormouse-voice", "dormouse-hosted"].map((name) => `${name}-pr-${pr}`); assert.deepEqual(removed, [...workers(7), "br-7", ...workers(9), "hd-9"]); }); @@ -446,6 +458,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", + connect: "https://connect.example.test", }; const events = []; // The relay's VAPID key, or null while push is off. @@ -457,6 +470,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.connect && pathname === "/connect/") + return new Response('', { + headers: { + "content-type": "text/html", + "content-security-policy": [ + `script-src ${origins.connect}/connect/assets/ 'wasm-unsafe-eval'`, + "connect-src wss://connect.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.connect && 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 +526,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.connect); events.push("one-time"); }; const waits = []; @@ -516,11 +546,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.connect} 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 efe32a8cc..9eb751c1a 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 { connectSmoke, 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 connect Worker has none. */ export const PRODUCTION = { account: { @@ -39,13 +39,20 @@ export const PRODUCTION = { hyperdrive: "VOICE_HYPERDRIVE_ID", role: "dormouse_voice", }, + connect: { + name: "dormouse-connect", + origin: "https://connect.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.connect.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.CONNECT_ORIGIN, PRODUCTION.connect.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 connect 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, connect: 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, connect, relay, voice, then account, stopping at a failure. + * The connect Worker passes `connectSmoke` 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 === "connect") + await connectSmoke(configs.connect.vars.APP_ORIGIN, sha, { + attempts: ATTEMPTS.connect, + ...options, + }); if (worker === "relay") - await relaySmoke(configs.relay.vars.APP_ORIGIN, sha, { + await relaySmoke(configs.relay.vars.APP_ORIGIN, configs.connect.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, @@ -231,7 +251,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 09aa91564..a3cdc5dff 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://connect.dormouse.sh"}`, ...(change.hsts === false ? {} : { "strict-transport-security": "max-age=31536000; includeSubDomains" }), ...(change.cookie ? { "set-cookie": "a=b" } : {}), }, }); }; +/** The connect Worker's page, or its script, as a served release answers them. */ +const connectPage = (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://connect.dormouse.sh/connect/assets/ 'wasm-unsafe-eval'", + "connect-src wss://connect.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"], + connect: ["dormouse-connect", "https://connect.dormouse.sh", "../../server/connect-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.CONNECT_ORIGIN, "https://connect.dormouse.sh"); + for (const elsewhere of [undefined, "https://evil.example.test", "https://relay.dormouse.sh"]) + assert.throws(() => + productionConfig( + { ...bases.relay, vars: { ...bases.relay.vars, CONNECT_ORIGIN: elsewhere } }, + env, + "relay", + ), + ); + // The page's Worker binds no database, whatever a config says. + assert.equal(configs.connect.hyperdrive, undefined); + assert.throws( + () => productionConfig({ ...bases.connect, hyperdrive: [{ binding: "HYPERDRIVE", id: "a".repeat(32) }] }, env, "connect"), + /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, connect, 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 connect Worker before the relay, whose room binding and redirect name it. [ ["dormouse-frames", ".wrangler/deploy-test/wrangler.frames.json"], + ["dormouse-connect", ".wrangler/deploy-test/wrangler.connect.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-connect", "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 connect Worker answers its page, as a new domain may not yet. + let pageServed = true; const fetcher = async (url) => { + if (url === "https://connect.dormouse.sh/api/health") { + events.push("connect health"); + return Response.json({ ok: true, revision: env.BUILD_SHA }); + } + if (url === "https://connect.dormouse.sh/connect/") { + events.push("connect page"); + return connectPage(pageServed); + } + if (url === "https://connect.dormouse.sh/connect/assets/page-abc.js") return connectPage(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://connect.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-connect", + "connect health", + "connect 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-connect", "connect health", "connect 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-connect", + "connect health", + ...Array(6).fill("connect 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://connect.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://connect.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://connect.dormouse.sh": 5, }; const ready = []; const fetcher = async (url) => { const { origin, pathname } = new URL(url); + if (pathname.startsWith("/connect/")) return connectPage(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://connect.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.connect.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 connect 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.connect.durable_objects, { + bindings: [{ name: "ONE_TIME_ROOM", class_name: "OneTimeRoom" }], + }); + assert.deepEqual(configs.connect.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.connect.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-connect.mjs b/hosted/scripts/stage-connect.mjs new file mode 100644 index 000000000..7c51a27f0 --- /dev/null +++ b/hosted/scripts/stage-connect.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 connect 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 stageConnect(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 = stageConnect( + fileURLToPath(new URL("../../lib/dist-one-time", import.meta.url)), + fileURLToPath(new URL("../dist/connect", import.meta.url)), + ); + console.log( + `connect assets staged at dist/connect: the one-time page at ${ONE_TIME_SHELL.base} (${scripts} script(s) under ${ONE_TIME_SHELL.scriptBase})`, + ); + } catch (error) { + console.error(`connect staging failed: ${error.message}`); + process.exitCode = 1; + } +} 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..1ddba001c 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 { stageConnect } from "./stage-connect.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 connect = join(root, "dist", "connect"); + mkdirSync(join(connect, "connect", "assets"), { recursive: true }); + writeFileSync(join(connect, "connect", "assets", "stale.js"), ""); + writeFileSync(join(connect, "stray.html"), "stray"); + return { pocket, oneTime, assets, connect, 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 connect Worker's assets hold", () => { + const { oneTime, connect } = fixture(); + assert.equal(stageConnect(oneTime, connect), 1); + assert.ok(existsSync(join(connect, "connect", "index.html"))); + assert.ok(existsSync(join(connect, "connect", "assets", "index-abc.js"))); + assert.ok(!existsSync(join(connect, "connect", "assets", "stale.js"))); + assert.deepEqual(readdirSync(connect), ["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, connect } = fixture({ oneTimeScript }); + assert.throws(() => stageConnect(oneTime, connect), 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, connect } = fixture(); + assert.throws(() => stageRelay(join(pocket, "missing"), assets), /build:pocket/); + assert.throws(() => stageConnect(join(oneTime, "missing"), connect), /build:one-time/); }); diff --git a/hosted/scripts/workers.mjs b/hosted/scripts/workers.mjs index e9edacc34..01a8cf4e3 100644 --- a/hosted/scripts/workers.mjs +++ b/hosted/scripts/workers.mjs @@ -8,14 +8,19 @@ 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 connect Worker deploys before the + * relay: the relay binds its `OneTimeRoom` and redirects to its page, so neither + * may outrun it. */ export const APP_WORKERS = { + connect: { + config: "wrangler.connect.jsonc", + // The production entry: it holds no secret and no database a preview lacks. + previewMain: "server/connect-worker.ts", + }, relay: { config: "wrangler.relay.jsonc", // The production entry: its mapper passes nothing a preview lacks. diff --git a/hosted/server/bindings.ts b/hosted/server/bindings.ts index 177df237b..c08b3bb50 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 connect 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 connect 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. + */ + CONNECT_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; } +/** `connect.dormouse.sh`: the one-time phone page and the room a phone joins. No secret and no database. */ +export interface ConnectEnv 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, + CONNECT_ORIGIN: exactOrigin(env.CONNECT_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 connectBindings = (env: ConnectEnv): ConnectEnv => ({ + 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/connect-worker.ts b/hosted/server/connect-worker.ts new file mode 100644 index 000000000..6077e7c85 --- /dev/null +++ b/hosted/server/connect-worker.ts @@ -0,0 +1,23 @@ +import { connectBindings, type ConnectEnv } from "./bindings"; +import { connectRules } from "./headers"; +import { oneTimeClientRoute, oneTimePageRoutes } from "./one-time"; +import { workerApp } from "./worker-app"; + +/** + * The connect Worker, `dormouse-connect` on `connect.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: connectBindings, + rules: connectRules, + 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/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..33039924c 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,42 @@ 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 + * Which of the relay's two kinds a path is: a non-page prefix, or Pocket's * (`docs/specs/pocket-app.md` -> "Serving the built bundle") — every other path. */ -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 function relayPathKind(path: string): "api" | "pocket" { + return RELAY_NON_PAGE_PREFIXES.some((prefix) => under(path, prefix)) ? "api" : "pocket"; } /** - * 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 relay's rules: Pocket's (with the camera) on Pocket's paths, and the + * runs-nothing policy on the non-page prefixes. 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 const relayRules: RulesFor = (path, appOrigin) => { - const kind = relayPathKind(path); - if (kind === "api") return NO_PAGE_RULES; + if (relayPathKind(path) === "api") 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 ? pocketContentSecurityPolicy(origin, HOSTED_FRAME_DOMAIN) : RUNS_NOTHING_POLICY, + permissions: POCKET_PERMISSIONS, + cache: contentHashed(path, "/assets/") ? "immutable" : "no-cache", + }; +}; + +/** + * The connect 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 connectRules: RulesFor = (path, appOrigin) => { + if (!under(path, ONE_TIME_PAGE_PATH.slice(0, -1))) return NO_PAGE_RULES; + const origin = exactOrigin(appOrigin); + 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 +124,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.ts b/hosted/server/one-time.ts index 1cff21d6e..060e1e5cb 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 { ConnectEnv, RelayEnv } from "./bindings"; import { forwardUpgrade, isUpgrade, upgradeRequired } from "./socket-room"; -type OneTimeContext = Context<{ Bindings: RelayEnv }>; +type ConnectContext = Context<{ Bindings: ConnectEnv }>; -/** - * 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 connect Worker's half: only the page on that origin may join a room. */ +export function oneTimeClientRoute(app: Hono<{ Bindings: ConnectEnv }>) { 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 connect 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: ConnectEnv }>) { + const assets = (c: ConnectContext) => 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.CONNECT_ORIGIN + ? c.redirect(`${c.env.CONNECT_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/relay-worker.ts b/hosted/server/relay-worker.ts index 6eb3a79ba..1ec959147 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/connect-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 580c5747d..02db2821c 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -16,6 +16,7 @@ import { type AccountEnv, accountBindings, accountPreviewBindings, + connectBindings, relayBindings, voiceBindings, voicePreviewBindings, @@ -25,6 +26,7 @@ import { RUNS_NOTHING_POLICY, accountRules, relayRules, + connectRules, type RulesFor, } from "../headers"; import { ADMIN_EMAIL } from "../entitlement"; @@ -170,9 +172,12 @@ const SERVED: Record = { ...ACCOUNT_BILLING, ["GET", "/login"], ], + connect: [ + ["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], @@ -239,7 +244,23 @@ const ABSENT: Record = { ["DELETE", "/api/voice/token"], ...RELAY_API, ], + connect: [ + ["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 connect Worker's alone. + ["GET", ONE_TIME_WS_ROUTES.client], ["GET", "/api/auth/csrf"], ["GET", "/api/auth/get-session"], ["POST", "/api/auth/sign-out"], @@ -334,7 +355,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("connect", ORIGINS.connect + phone))).toBe(IMMUTABLE); + expect(cache(await send("connect", ORIGINS.connect + pocket))).toBe("no-store"); // Each Worker's rules, checked on an app that serves a script at every path. const serving = (rules: RulesFor) => workerApp({ @@ -349,13 +372,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 connect = serving(connectRules); + const connectEnv = { APP_ORIGIN: ORIGINS.connect }; + expect(cache(await connect.fetch(new Request(ORIGINS.connect + phone), connectEnv))).toBe(IMMUTABLE); + for (const path of [pocket, "/connect/x.js", `${ONE_TIME_PAGE_PATH}assets/plain.js`, "/api/assets/x.js"]) + expect(cache(await connect.fetch(new Request(ORIGINS.connect + path), connectEnv)), 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); @@ -449,6 +477,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", + CONNECT_ORIGIN: "https://connect.example.test", ...STRIPE, }; const keys = (bindings: object) => Object.keys(bindings).sort(); @@ -486,8 +515,8 @@ test("each bindings mapper passes only what its Worker uses", () => { "APP_ORIGIN", "ASSETS", "BUILD_SHA", + "CONNECT_ORIGIN", "HYPERDRIVE", - "ONE_TIME_JOIN_LIMIT", "ONE_TIME_MINT_LIMIT", "ONE_TIME_ROOM", "RELAY_ENROLL_BEGIN_LIMIT", @@ -511,6 +540,14 @@ test("each bindings mapper passes only what its Worker uses", () => { undefined, ]) expect(relayBindings({ ...env, ACCOUNT_ORIGIN: loose }).ACCOUNT_ORIGIN, String(loose)).toBeUndefined(); + // `CONNECT_ORIGIN` as well: it writes a redirect's `Location`. + expect(relayBindings(env).CONNECT_ORIGIN).toBe(env.CONNECT_ORIGIN); + for (const loose of ["https://connect.example.test/", "https://connect.example.test\nX: y", "javascript:alert(1)", "", undefined]) + expect(relayBindings({ ...env, CONNECT_ORIGIN: loose }).CONNECT_ORIGIN, String(loose)).toBeUndefined(); + // No secret and no database: the page's Worker holds the page, its rooms, and their limit. + expect(keys(connectBindings(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..1240a6a7b 100644 --- a/hosted/server/tests/bundle.ts +++ b/hosted/server/tests/bundle.ts @@ -101,16 +101,23 @@ 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: Object.fromEntries( + own.map(({ name: binding, class_name }) => [ + binding, + { className: class_name, useSQLite: sqliteClass(wrangler[name], class_name) }, + ]), + ), + }, ); } diff --git a/hosted/server/tests/frame-origin.test.ts b/hosted/server/tests/frame-origin.test.ts index 0c40ab2b4..c96c7f440 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://connect.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:\/\/connect\.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..1a03deff0 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 "../connect-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..90cf30e4b 100644 --- a/hosted/server/tests/one-time.test.ts +++ b/hosted/server/tests/one-time.test.ts @@ -26,47 +26,63 @@ 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 { connectRules, 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 connect Worker's origin: the page's, and the one that joins a room. */ +const origin = ORIGINS.connect; +/** 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) { +async function start(connectEntry: string) { 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("connect", (await bundleWorker(connectEntry)).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", (await bundleWorker(ENTRIES.relay)).outputFiles[0].text, { + bindings: { APP_ORIGIN: relay, CONNECT_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 +91,7 @@ let production: Awaited>; let short: Awaited>; beforeAll(async () => { [production, short] = await Promise.all([ - start(ENTRIES.relay), + start(ENTRIES.connect), start("server/tests/one-time-entry.ts"), ]); }); @@ -87,12 +103,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 connect 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 +167,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://connect.dormouse.sh.example" }, + { origin: "http://connect.dormouse.sh" }, ]) expect((await upgrade(joinPath(frame.roomId), headers)).status).toBe(403); // None of those spent the room's one join. @@ -184,11 +205,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 +357,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.connect.name, "OneTimeRoom", { name: frame.roomId, webSockets: "hibernate", }); @@ -454,10 +474,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,6 +493,10 @@ 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, ); }); @@ -486,12 +511,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://connect.dormouse.sh/connect/assets/ 'wasm-unsafe-eval'; " + + "style-src https://connect.dormouse.sh/connect/assets/ 'unsafe-inline'; " + + "img-src https://connect.dormouse.sh/connect/ data: blob:; " + + "font-src https://connect.dormouse.sh/connect/; " + "media-src blob:; " + - "connect-src wss://relay.dormouse.sh/api/one-time/client; " + + "connect-src wss://connect.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 +524,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 +587,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 connect Worker"); + expect(await burrow.next()).toBe("through the connect Worker"); + burrow.send("through the relay Worker"); + expect(await phone.next()).toBe("through the relay Worker"); + burrow.close(); +}); + +test("the connect Worker holds nothing but the page, its rooms, and their join limit", () => { + expect(wrangler.connect.vars).toEqual({ APP_ORIGIN: origin }); + expect(wrangler.connect).not.toHaveProperty("hyperdrive"); + expect(wrangler.connect.ratelimits?.map(({ name }) => name)).toEqual(["ONE_TIME_JOIN_LIMIT"]); + expect(wrangler.connect.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.connect.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(connectRules("/connect/", "http://localhost:8787").policy).toBe( oneTimePagePolicy("http://localhost:8787"), ); expect(oneTimePagePolicy("http://localhost:8787")).toContain( @@ -581,18 +655,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://connect.dormouse.sh/", + "https://connect.dormouse.sh; script-src *", + "https://connect.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://connect.dormouse.sh", ]) for (const path of ["/connect/", "/"]) - expect(relayRules(path, bad).policy, `${path} ${String(bad)}`).toBe( + expect(connectRules(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 f6c2d6575..6dd7287ad 100644 --- a/hosted/server/tests/pocket.test.ts +++ b/hosted/server/tests/pocket.test.ts @@ -13,7 +13,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 @@ -63,12 +63,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; }); @@ -156,11 +156,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 685ba4198..6171b5055 100644 --- a/hosted/server/tests/relay-room.test.ts +++ b/hosted/server/tests/relay-room.test.ts @@ -50,7 +50,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 @@ -123,7 +123,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, { @@ -138,7 +138,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 1168ffae2..78bbb7731 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -61,6 +61,7 @@ import { ORIGINS, TEST_ENROLL_SECRET, bundleWorker, + alone, miniflareOptions, testVapidKeys, } from "./bundle"; @@ -92,7 +93,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, @@ -117,7 +118,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/wrangler.connect.jsonc b/hosted/wrangler.connect.jsonc new file mode 100644 index 000000000..1eb6bf494 --- /dev/null +++ b/hosted/wrangler.connect.jsonc @@ -0,0 +1,56 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "dormouse-connect", + "main": "server/connect-worker.ts", + "compatibility_date": "2026-09-08", + "compatibility_flags": [ + "nodejs_compat" + ], + "workers_dev": false, + "preview_urls": false, + "routes": [ + { + "pattern": "connect.dormouse.sh", + "custom_domain": true + } + ], + "assets": { + "directory": "./dist/connect", + "binding": "ASSETS", + "not_found_handling": "none", + "run_worker_first": true + }, + "vars": { + "APP_ORIGIN": "https://connect.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/hosted/wrangler.frames.jsonc b/hosted/wrangler.frames.jsonc index a71c97d52..75c775b45 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://connect.dormouse.sh" }, "observability": { "enabled": false diff --git a/hosted/wrangler.relay.jsonc b/hosted/wrangler.relay.jsonc index c08346b6d..bb3a6962e 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", + "CONNECT_ORIGIN": "https://connect.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-connect" }, { "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/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/website/scripts/hosted-bundles.test.js b/website/scripts/hosted-bundles.test.js index 615ee1c8f..32d997272 100644 --- a/website/scripts/hosted-bundles.test.js +++ b/website/scripts/hosted-bundles.test.js @@ -48,6 +48,7 @@ describe('Hosted bundle disclosure', () => { it('bundles every Worker the deploy configures and the account page', () => { expect(hostedBundleEntries(repoRoot)).toEqual([ + { entry: 'server/connect-worker.ts', platform: 'worker' }, { entry: 'server/frame-worker.ts', platform: 'worker' }, { entry: 'server/worker.ts', platform: 'worker' }, { entry: 'server/relay-worker.ts', platform: 'worker' }, From 30681cc49d1ec5b26c197963dcf0ff0249b1fd19 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 19:52:40 -0800 Subject: [PATCH 02/11] Point one-time links at the page's own origin A stock Hosted build writes HOSTED_ONE_TIME_ORIGIN into the link it shows and keeps its socket on the relay origin. The page frames at that origin alone, and a lint holds the relay Worker to serving no page and admitting no phone. Co-Authored-By: Claude Opus 5.5 --- lib/src/host/relay-origin.test.ts | 12 ++++++++-- lib/src/host/relay-origin.ts | 23 +++++++++++++++++-- lib/src/host/remote/service.test.ts | 9 +++++--- lib/src/host/remote/service.ts | 2 ++ lib/src/host/remote/test-burrow-link.ts | 4 ++-- .../remote/burrow/one-time-runtime.test.ts | 1 + lib/src/remote/burrow/one-time-runtime.ts | 10 +++++++- lib/src/remote/client/one-time-e2e.test.ts | 1 + .../remote/one-time-app/OneTimeApp.test.tsx | 2 +- lib/src/remote/one-time-app/OneTimeApp.tsx | 6 ++--- lib/src/remote/pocket-app/deployment.test.ts | 11 +++++++++ lib/src/remote/pocket-app/deployment.ts | 10 +++++++- scripts/e2e-lint-selftest.mjs | 12 ++++++++++ scripts/e2e-lint.mjs | 18 +++++++++++++-- scripts/outbound-lint.mjs | 4 ++-- 15 files changed, 106 insertions(+), 19 deletions(-) diff --git a/lib/src/host/relay-origin.test.ts b/lib/src/host/relay-origin.test.ts index 46eddcf85..40d64375b 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, connect, relay, voice } = await readConfigs(); + expect(HOSTED_ONE_TIME_ORIGIN).toBe(connect.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,11 @@ 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(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..b8d34ea69 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://connect.dormouse.sh'; + /** The account page that lists its enrolled computers, with Remove. */ export const ACCOUNT_PAGE_PATH = '/account'; @@ -94,6 +103,16 @@ export function hostedOrigin(relay: RelayBuild): string | null { return relay.mode === 'hosted' ? relay.origin : null; } +/** + * Where a Hosted 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. Only {@link hostedOrigin} says whether this build makes links at all. + */ +export function oneTimeLinkOrigin(relay: RelayBuild): string { + 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 2e683bfa7..0a5a13ae7 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -110,7 +110,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, @@ -1950,7 +1950,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); } @@ -2013,7 +2013,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://connect.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 0c2776743..7589c7e30 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -88,6 +88,7 @@ import { HOSTED_ACCOUNT_ORIGIN, hostedAccountOrigin, hostedOrigin, + oneTimeLinkOrigin, isDevHostedBuild, isRelayOrigin, type RelayBuild, @@ -1333,6 +1334,7 @@ export class BurrowService { } const runtime: OneTimeRuntime = new OneTimeRuntime({ origin: hosted, + linkOrigin: oneTimeLinkOrigin(this.#relay), 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 a916957b2..04783be91 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, @@ -136,7 +136,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.test.tsx b/lib/src/remote/one-time-app/OneTimeApp.test.tsx index 9ac058bfc..bba44123b 100644 --- a/lib/src/remote/one-time-app/OneTimeApp.test.tsx +++ b/lib/src/remote/one-time-app/OneTimeApp.test.tsx @@ -65,7 +65,7 @@ vi.mock('../client/install-state', () => ({ isInstalledWebApp: () => true })); vi.mock('../pocket-app/remote-wall', () => ({ mountRemoteWall: (client: unknown, capabilities?: unknown) => fake.mount(client, capabilities), })); -vi.mock('../pocket-app/deployment', () => ({ frameDomainFor: () => fake.frameDomain })); +vi.mock('../pocket-app/deployment', () => ({ oneTimeFrameDomainFor: () => fake.frameDomain })); vi.mock('../pocket-app/PocketWall', () => ({ PocketWall: (props: { persist?: boolean; onError?: (error: unknown) => void; sessionsHeader?: ReactNode; browsers?: { session: unknown; frameDomain: string; transport: unknown; salt: () => Uint8Array } }) => { fake.wall = props; diff --git a/lib/src/remote/one-time-app/OneTimeApp.tsx b/lib/src/remote/one-time-app/OneTimeApp.tsx index 0e31f8f70..ad07b7333 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 @@ -29,7 +29,7 @@ import { import type { RemotePtyAdapter } from '../client/remote-adapter'; import type { RemoteWebSocket } from '../ws'; import { disposeAllSessions } from '../../lib/terminal-registry'; -import { frameDomainFor } from '../pocket-app/deployment'; +import { oneTimeFrameDomainFor } from '../pocket-app/deployment'; import { PocketWall, type PocketBrowsers } from '../pocket-app/PocketWall'; import type { FrameSession } from '../client/frame-client'; import { PK, pkButton } from '../pocket-app/pocket-chrome'; @@ -392,7 +392,7 @@ export function OneTimeConnecting({ onCancel }: { onCancel: () => void }): React } /** Where this page frames pages: Hosted's own, as Pocket's. */ -const pageFrameDomain = () => frameDomainFor('hosted', location.origin); +const pageFrameDomain = () => oneTimeFrameDomainFor(location.origin); /** In a frame's origin where Pocket names a Burrow; the salt alone keeps it apart. */ const ONE_TIME_FRAME_SCOPE = 'one-time'; diff --git a/lib/src/remote/pocket-app/deployment.test.ts b/lib/src/remote/pocket-app/deployment.test.ts index ff2e3239a..00ac89e1d 100644 --- a/lib/src/remote/pocket-app/deployment.test.ts +++ b/lib/src/remote/pocket-app/deployment.test.ts @@ -16,6 +16,7 @@ import { deploymentDirectPeer, deploymentUnreachableMessage, frameDomainFor, + oneTimeFrameDomainFor, parsePocketDeployment, pocketDeploymentSource, readPocketDeployment, @@ -186,6 +187,16 @@ describe('Pocket’s direct-peer factory', () => { }); }); +describe('oneTimeFrameDomainFor', () => { + it('names the frame domain only at the one-time page\'s production origin', () => { + expect(oneTimeFrameDomainFor('https://connect.dormouse.sh')).toBe('dormouseusercontent.com'); + expect(oneTimeFrameDomainFor('https://dormouse-connect-pr-7.example.workers.dev')).toBeNull(); + expect(oneTimeFrameDomainFor('http://localhost:8788')).toBeNull(); + // Nothing of a stranger's renders at Pocket's origin any more, framed or not. + expect(oneTimeFrameDomainFor('https://relay.dormouse.sh')).toBeNull(); + }); +}); + 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'); diff --git a/lib/src/remote/pocket-app/deployment.ts b/lib/src/remote/pocket-app/deployment.ts index 20fdf0871..c2c357ef4 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'; @@ -44,6 +44,14 @@ export function frameDomainFor(deployment: PocketDeployment | null, origin: stri return deployment === 'hosted' && origin === DEFAULT_RELAY_ORIGIN ? HOSTED_FRAME_DOMAIN : null; } +/** + * The parent of the one-time page's frame origins, or `null` where it frames + * nothing, by {@link frameDomainFor}'s rule: only Hosted's production page. + */ +export function oneTimeFrameDomainFor(origin: string): string | null { + return origin === HOSTED_ONE_TIME_ORIGIN ? HOSTED_FRAME_DOMAIN : null; +} + /** * Who serves this page, read off its own origin within `timeoutMs`. **Null * when the read does not complete** — a network failure, the timeout, a 5xx, or 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 e46940485..ddf744232 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/outbound-lint.mjs b/scripts/outbound-lint.mjs index d94fd0128..e691dbc4a 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', 'connect.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'], From 80825e289b8ec3101fb22a5286e0dbebc1d3657c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 19:52:40 -0800 Subject: [PATCH 03/11] State the page's own origin in the specs and public docs Co-Authored-By: Claude Opus 5.5 --- .github/audit/hosted.md | 2 +- AGENTS.md | 4 +- docs/specs/deploy.md | 2 +- docs/specs/hosted.md | 21 +++++----- docs/specs/hosted.rationale.md | 5 ++- docs/specs/one-time.md | 39 ++++++++++++------- docs/specs/one-time.rationale.md | 8 ++++ docs/specs/pocket-app.md | 2 +- docs/specs/relay.md | 4 +- docs/specs/remote-network.rationale.md | 2 +- docs/specs/remote-security-model.md | 2 +- docs/specs/security-hosted.md | 20 +++++----- docs/specs/security-remote.md | 2 +- docs/specs/security-supply-chain.md | 2 +- docs/specs/security-supply-chain.rationale.md | 2 +- docs/specs/security.md | 2 +- dormouse.yml | 2 +- scripts/security-rule-ids.json | 2 +- scripts/spec-word-budgets.json | 8 ++-- website/scripts/product-sections.js | 6 +-- website/scripts/product-sections.test.js | 2 +- website/src/pages/Privacy.tsx | 2 +- website/src/pages/SupplyChain.test.tsx | 2 +- website/src/pages/SupplyChain.tsx | 2 +- 24 files changed, 85 insertions(+), 60 deletions(-) diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 7a438d9b9..da25ea901 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 connect 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/AGENTS.md b/AGENTS.md index 4832f8df9..0102b71db 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 (`connect.`), 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 271c1223a..4248b333e 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 connect Worker's `OneTimeRoom`, the mint, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `CONNECT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | +| `dormouse-connect` | `https://connect.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-connect`, 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 connect 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`). @@ -346,7 +347,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.** @@ -358,7 +359,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,connect}-pr-N`), one uncached Hyperdrive all but the connect 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 connect 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 `CONNECT_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). @@ -368,11 +369,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 `CONNECT_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, connect, relay, voice, then account, stopping at a failure**; the connect 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 connect 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 connect 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 0a571487c..2485fc276 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, `connect.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 connect 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 connect 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..0ce77ecde 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 connect Worker the client route (`docs/specs/hosted.md` -> "Application boundary"), and one `OneTimeRoom` Durable Object per room, which the connect 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 connect 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 connect 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 `stageConnect` empties the connect Worker's assets directory, copies the page to `hosted/dist/connect/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 connect 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`; `stageConnect` in `hosted/scripts/stage-connect.mjs`; `oneTimePageRoutes` in `hosted/server/one-time.ts`; `connectRules` 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 `CONNECT_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 connect 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 connect 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..6849ea1c4 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 connect 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 5454362c1..5924a5703 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -209,7 +209,7 @@ A framed page (`docs/specs/remote-api.md` -> "Frames") runs at a frame origin: a **The frame origins sit on a registrable domain of their own, `HOSTED_FRAME_DOMAIN`**, so a framed page cannot set a cookie on `dormouse.sh`. -- **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`, `oneTimeFrameDomainFor`), 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 eb1e91f62..3948968b3 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -265,7 +265,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://connect.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"). @@ -273,7 +273,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..da31a2297 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 `connect.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 fd392c4b0..d431d1276 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 `connect.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 e58ef1047..9b02d2b7d 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, connect, 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. @@ -17,9 +17,9 @@ - **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 connect 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` / `connectRules` / `relayPathKind` 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.connect.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 connect 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,21 @@ 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 connect 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 connect 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 connect 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 `connectRules` in `hosted/server/headers.ts`. +- **FAIL IF** `S96` the connect 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 `stageConnect` in `hosted/scripts/stage-connect.mjs` each run it. Inspect `oneTimePageRoutes` in `hosted/server/one-time.ts` and `hosted/server/connect-worker.ts`. +- **FAIL IF** `S300` 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 `CONNECT_ORIGIN`, which `productionConfig` pins, its entry must name no page or client route, its assets no page, and the connect Worker must hold no Hyperdrive or secret. Inspect `oneTimePageMoved` in `hosted/server/one-time.ts`, `stageRelay` in `hosted/scripts/stage-relay.mjs`, and `connectBindings` 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`. @@ -97,7 +99,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S113` a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. - **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 and `onlineBurrows` on relay-Worker `GET /api/burrows`. -- **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` / `relayPathKind` 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 9c7f69f23..fe38de842 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -212,7 +212,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..5f7685e2b 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 `connect.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 934c9631d..12d7b1140 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 `connect.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/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index 91fda6d7f..0345eee50 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 300, + "next": 301, "retired": [ 265 ] diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0a01fcfd8..f3c8f34a5 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": 6550, + "docs/specs/hosted.md": 6650, "docs/specs/layout.md": 7400, "docs/specs/mobile-terminal-ui.md": 1800, "docs/specs/mouse-and-clipboard.md": 4400, - "docs/specs/one-time.md": 2700, + "docs/specs/one-time.md": 2950, "docs/specs/pocket-app.md": 4350, "docs/specs/pricing.md": 2750, "docs/specs/relay.md": 5750, @@ -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": 4500, + "docs/specs/security-hosted.md": 4600, "docs/specs/security-local.md": 6700, "docs/specs/security-remote.md": 7900, "docs/specs/security-supply-chain.md": 1650, 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..f7306a357 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 connect.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..e14155819 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", "connect.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..3cde23595 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. connect.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: [ { From b284cc071f6694834368b285da0831f7af5bb34a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 19:57:27 -0800 Subject: [PATCH 04/11] Fail the deployment smoke on a relay still answering the old page Co-Authored-By: Claude Opus 5.5 --- hosted/server/tests/one-time.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/hosted/server/tests/one-time.test.ts b/hosted/server/tests/one-time.test.ts index 90cf30e4b..0d56c2e65 100644 --- a/hosted/server/tests/one-time.test.ts +++ b/hosted/server/tests/one-time.test.ts @@ -501,6 +501,22 @@ test("the deployment smoke passes against the two Workers, which their previews ); }); +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); From 52cc4e3154d024a25924b3fb3371b1ec06e35c1c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 20:03:28 -0800 Subject: [PATCH 05/11] Share the dev and preview config rewrites and one framing rule One rule for which origins frame, a null one-time link origin in a self-host build as its siblings have, one Durable Object binding rewrite and one relay bundle per suite. Co-Authored-By: Claude Opus 5.5 --- docs/specs/pocket-app.md | 2 +- docs/specs/security-hosted.md | 4 +- hosted/scripts/dev-one-time.mjs | 92 ++++++++----------- hosted/scripts/preview-smoke.mjs | 9 +- hosted/scripts/preview.mjs | 13 +-- hosted/scripts/workers.mjs | 17 +++- hosted/server/headers.ts | 15 +-- hosted/server/pocket.ts | 2 +- hosted/server/tests/bundle.ts | 34 +++---- hosted/server/tests/one-time.test.ts | 7 +- lib/src/host/relay-origin.test.ts | 1 + lib/src/host/relay-origin.ts | 7 +- lib/src/host/remote/service.ts | 5 +- .../remote/one-time-app/OneTimeApp.test.tsx | 2 +- lib/src/remote/one-time-app/OneTimeApp.tsx | 4 +- lib/src/remote/pocket-app/deployment.test.ts | 14 +-- lib/src/remote/pocket-app/deployment.ts | 16 +--- 17 files changed, 112 insertions(+), 132 deletions(-) diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 5924a5703..f70f08a17 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -209,7 +209,7 @@ A framed page (`docs/specs/remote-api.md` -> "Frames") runs at a frame origin: a **The frame origins sit on a registrable domain of their own, `HOSTED_FRAME_DOMAIN`**, so a framed page cannot set a cookie on `dormouse.sh`. -- **Must declare `capabilities.frames` only at Hosted's production origins** (`frameDomainFor`, `oneTimeFrameDomainFor`), 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/security-hosted.md b/docs/specs/security-hosted.md index 9b02d2b7d..fa1b4ebae 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -17,7 +17,7 @@ - **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`, `/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 connect 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` / `connectRules` / `relayPathKind` 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.connect.jsonc`. +- **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 connect 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` / `connectRules` 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.connect.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 connect 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. @@ -99,7 +99,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **FAIL IF** `S113` a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. - **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 and `onlineBurrows` on relay-Worker `GET /api/burrows`. -- **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` / `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/hosted/scripts/dev-one-time.mjs b/hosted/scripts/dev-one-time.mjs index 81897ae6b..81d56a343 100644 --- a/hosted/scripts/dev-one-time.mjs +++ b/hosted/scripts/dev-one-time.mjs @@ -4,7 +4,7 @@ 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` @@ -82,62 +82,48 @@ export const LOCAL_HYPERDRIVE_ID = "local-development"; const devName = (name) => `${name}-dev`; /** - * The connect Worker's Wrangler config, written beside the staged page. - * Allowlisted from `hosted/wrangler.connect.jsonc` like the preview's: its - * entry, the room, its migration and rate limit, and the assets binding over the - * staging folder — never a route, so it cannot answer for production. + * 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 devConnectConfig(base, port) { - return { - name: devName(base.name), - main: "../../server/connect-worker.ts", - compatibility_date: base.compatibility_date, - compatibility_flags: base.compatibility_flags, - assets: { ...base.assets, directory: "./connect-assets" }, - vars: { APP_ORIGIN: devOrigin(port) }, - 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 connect Worker's, over the folder the page rebuilds into. */ +export const devConnectConfig = (base, port) => + devWorkerConfig(base, "./connect-assets", { APP_ORIGIN: devOrigin(port) }); /** - * The relay Worker's Wrangler config, allowlisted from - * `hosted/wrangler.relay.jsonc` the same way: its entry, its Durable Objects, - * the connect Worker's under its dev name, its migrations and rate limits, and - * assets holding nothing, since the loop builds no Pocket — never a route or a - * production secret. 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. + * 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 function devRelayConfig(base, port, connectPort, attached = null) { - return { - name: devName(base.name), - main: "../../server/relay-worker.ts", - compatibility_date: base.compatibility_date, - compatibility_flags: base.compatibility_flags, - assets: { ...base.assets, directory: "./relay-assets" }, - vars: { - APP_ORIGIN: devOrigin(port), - CONNECT_ORIGIN: devOrigin(connectPort), - RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET, - ...(attached && { ACCOUNT_ORIGIN: attached.accountOrigin }), - }, - ...(attached && { - hyperdrive: [ - { binding: "HYPERDRIVE", id: LOCAL_HYPERDRIVE_ID, localConnectionString: attached.databaseUrl }, - ], - }), - durable_objects: { - bindings: base.durable_objects.bindings.map((binding) => - binding.script_name ? { ...binding, script_name: devName(binding.script_name) } : binding, - ), - }, - migrations: base.migrations, - ratelimits: base.ratelimits, - }; -} +export const devRelayConfig = (base, port, connectPort, attached = null) => ({ + ...devWorkerConfig(base, "./relay-assets", { + APP_ORIGIN: devOrigin(port), + CONNECT_ORIGIN: devOrigin(connectPort), + 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) { diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs index f8d1f2267..70db93062 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -374,7 +374,7 @@ export async function relaySmoke( sha, { fetcher = fetch, - oneTime = (relay, page) => oneTimeSmoke(relay, page), + oneTime = oneTimeSmoke, attempts = 1, retryMs = 10_000, wait = delay, @@ -401,7 +401,7 @@ export async function smokeAll( fetcher = fetch, preview = false, providers = [], - oneTime = (relay, page) => oneTimeSmoke(relay, page), + oneTime, attempts = 1, retryMs = 10_000, wait = delay, @@ -431,9 +431,10 @@ if ( ) { const [origins, sha] = process.argv.slice(2); assert.match(sha ?? "", /^[a-f0-9]{40}$/); - const { account, relay, voice, connect } = JSON.parse(origins); + const parsed = JSON.parse(origins); + const { account, voice, connect } = parsed; // A just-uploaded Worker may take a short time to become reachable everywhere. - await smokeAll({ account, relay, voice, connect }, sha, { preview: true, attempts: 6 }); + await smokeAll(parsed, sha, { preview: true, attempts: 6 }); console.log( `Preview smoke checks passed: ${account}/login, ${connect}/connect/, ${voice} (${sha})`, ); diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs index 6afb138b2..3a10b3325 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) diff --git a/hosted/scripts/workers.mjs b/hosted/scripts/workers.mjs index 01a8cf4e3..896cea0fc 100644 --- a/hosted/scripts/workers.mjs +++ b/hosted/scripts/workers.mjs @@ -16,15 +16,13 @@ const hosted = new URL("../", import.meta.url); * 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. connect: { config: "wrangler.connect.jsonc", - // The production entry: it holds no secret and no database a preview lacks. - previewMain: "server/connect-worker.ts", }, 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. */ @@ -65,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/headers.ts b/hosted/server/headers.ts index 33039924c..80ab348b5 100644 --- a/hosted/server/headers.ts +++ b/hosted/server/headers.ts @@ -71,21 +71,14 @@ export const NO_PAGE_RULES: PathRules = { }; /** - * Which of the relay's two kinds a path is: a non-page prefix, or Pocket's - * (`docs/specs/pocket-app.md` -> "Serving the built bundle") — every other path. - */ -export function relayPathKind(path: string): "api" | "pocket" { - return RELAY_NON_PAGE_PREFIXES.some((prefix) => under(path, prefix)) ? "api" : "pocket"; -} - -/** - * The relay's rules: Pocket's (with the camera) on Pocket's paths, and the - * runs-nothing policy on the non-page prefixes. Pocket needs `APP_ORIGIN`, and + * 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 const relayRules: RulesFor = (path, appOrigin) => { - if (relayPathKind(path) === "api") return NO_PAGE_RULES; + 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, 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/tests/bundle.ts b/hosted/server/tests/bundle.ts index 1240a6a7b..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( @@ -111,12 +118,7 @@ function standIn(name: Name) { ].join("\n"), { // Its own alone: a binding to a third Worker's class needs that Worker running. - durableObjects: Object.fromEntries( - own.map(({ name: binding, class_name }) => [ - binding, - { className: class_name, useSQLite: sqliteClass(wrangler[name], class_name) }, - ]), - ), + durableObjects: durableObjectOptions(wrangler[name], own), }, ); } diff --git a/hosted/server/tests/one-time.test.ts b/hosted/server/tests/one-time.test.ts index 0d56c2e65..63ca07bce 100644 --- a/hosted/server/tests/one-time.test.ts +++ b/hosted/server/tests/one-time.test.ts @@ -50,10 +50,13 @@ const UNHASHED_SCRIPT = `${ONE_TIME_PAGE_PATH}assets/plain.js`; const PAGE_SHELL = ``; const POCKET_SHELL = "Pocket"; +const relayBundle = bundleWorker(ENTRIES.relay); + async function start(connectEntry: string) { + const [connectBundle, relayScript] = await Promise.all([bundleWorker(connectEntry), relayBundle]); const mf = new Miniflare( together( - miniflareOptions("connect", (await bundleWorker(connectEntry)).outputFiles[0].text, { + miniflareOptions("connect", connectBundle.outputFiles[0].text, { bindings: { APP_ORIGIN: origin }, routes: [`${new URL(origin).host}/*`], serviceBindings: { @@ -73,7 +76,7 @@ async function start(connectEntry: string) { }, }, }), - miniflareOptions("relay", (await bundleWorker(ENTRIES.relay)).outputFiles[0].text, { + miniflareOptions("relay", relayScript.outputFiles[0].text, { bindings: { APP_ORIGIN: relay, CONNECT_ORIGIN: origin }, routes: [`${new URL(relay).host}/*`], // Pocket's shell for every path, so a path the relay left to it shows. diff --git a/lib/src/host/relay-origin.test.ts b/lib/src/host/relay-origin.test.ts index 40d64375b..e4b595e71 100644 --- a/lib/src/host/relay-origin.test.ts +++ b/lib/src/host/relay-origin.test.ts @@ -109,6 +109,7 @@ describe('the baked relay origin', () => { // 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); }); diff --git a/lib/src/host/relay-origin.ts b/lib/src/host/relay-origin.ts index b8d34ea69..a297f593b 100644 --- a/lib/src/host/relay-origin.ts +++ b/lib/src/host/relay-origin.ts @@ -104,12 +104,13 @@ export function hostedOrigin(relay: RelayBuild): string | null { } /** - * Where a Hosted build's one-time link sends a phone: + * 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. Only {@link hostedOrigin} says whether this build makes links at all. + * with; `null` in a self-host build. */ -export function oneTimeLinkOrigin(relay: RelayBuild): string { +export function oneTimeLinkOrigin(relay: RelayBuild): string | null { + if (relay.mode !== 'hosted') return null; return isDevHostedBuild(relay) ? relay.origin : HOSTED_ONE_TIME_ORIGIN; } diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index 7589c7e30..8b8e3f5df 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -1308,7 +1308,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}).`, @@ -1334,7 +1335,7 @@ export class BurrowService { } const runtime: OneTimeRuntime = new OneTimeRuntime({ origin: hosted, - linkOrigin: oneTimeLinkOrigin(this.#relay), + linkOrigin, createWebSocket: this.#createWebSocket, createSession: (opts) => this.#createApiSession(opts), directPeering: directPeeringFor(policy, this.#createDirectPeer), diff --git a/lib/src/remote/one-time-app/OneTimeApp.test.tsx b/lib/src/remote/one-time-app/OneTimeApp.test.tsx index bba44123b..9ac058bfc 100644 --- a/lib/src/remote/one-time-app/OneTimeApp.test.tsx +++ b/lib/src/remote/one-time-app/OneTimeApp.test.tsx @@ -65,7 +65,7 @@ vi.mock('../client/install-state', () => ({ isInstalledWebApp: () => true })); vi.mock('../pocket-app/remote-wall', () => ({ mountRemoteWall: (client: unknown, capabilities?: unknown) => fake.mount(client, capabilities), })); -vi.mock('../pocket-app/deployment', () => ({ oneTimeFrameDomainFor: () => fake.frameDomain })); +vi.mock('../pocket-app/deployment', () => ({ frameDomainFor: () => fake.frameDomain })); vi.mock('../pocket-app/PocketWall', () => ({ PocketWall: (props: { persist?: boolean; onError?: (error: unknown) => void; sessionsHeader?: ReactNode; browsers?: { session: unknown; frameDomain: string; transport: unknown; salt: () => Uint8Array } }) => { fake.wall = props; diff --git a/lib/src/remote/one-time-app/OneTimeApp.tsx b/lib/src/remote/one-time-app/OneTimeApp.tsx index ad07b7333..b9c5f0679 100644 --- a/lib/src/remote/one-time-app/OneTimeApp.tsx +++ b/lib/src/remote/one-time-app/OneTimeApp.tsx @@ -29,7 +29,7 @@ import { import type { RemotePtyAdapter } from '../client/remote-adapter'; import type { RemoteWebSocket } from '../ws'; import { disposeAllSessions } from '../../lib/terminal-registry'; -import { oneTimeFrameDomainFor } from '../pocket-app/deployment'; +import { frameDomainFor } from '../pocket-app/deployment'; import { PocketWall, type PocketBrowsers } from '../pocket-app/PocketWall'; import type { FrameSession } from '../client/frame-client'; import { PK, pkButton } from '../pocket-app/pocket-chrome'; @@ -392,7 +392,7 @@ export function OneTimeConnecting({ onCancel }: { onCancel: () => void }): React } /** Where this page frames pages: Hosted's own, as Pocket's. */ -const pageFrameDomain = () => oneTimeFrameDomainFor(location.origin); +const pageFrameDomain = () => frameDomainFor('hosted', location.origin); /** In a frame's origin where Pocket names a Burrow; the salt alone keeps it apart. */ const ONE_TIME_FRAME_SCOPE = 'one-time'; diff --git a/lib/src/remote/pocket-app/deployment.test.ts b/lib/src/remote/pocket-app/deployment.test.ts index 00ac89e1d..149e4a9c6 100644 --- a/lib/src/remote/pocket-app/deployment.test.ts +++ b/lib/src/remote/pocket-app/deployment.test.ts @@ -16,7 +16,6 @@ import { deploymentDirectPeer, deploymentUnreachableMessage, frameDomainFor, - oneTimeFrameDomainFor, parsePocketDeployment, pocketDeploymentSource, readPocketDeployment, @@ -187,19 +186,12 @@ describe('Pocket’s direct-peer factory', () => { }); }); -describe('oneTimeFrameDomainFor', () => { - it('names the frame domain only at the one-time page\'s production origin', () => { - expect(oneTimeFrameDomainFor('https://connect.dormouse.sh')).toBe('dormouseusercontent.com'); - expect(oneTimeFrameDomainFor('https://dormouse-connect-pr-7.example.workers.dev')).toBeNull(); - expect(oneTimeFrameDomainFor('http://localhost:8788')).toBeNull(); - // Nothing of a stranger's renders at Pocket's origin any more, framed or not. - expect(oneTimeFrameDomainFor('https://relay.dormouse.sh')).toBeNull(); - }); -}); - 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://connect.dormouse.sh')).toBe('dormouseusercontent.com'); + expect(frameDomainFor('hosted', 'https://dormouse-connect-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 c2c357ef4..44e15fd2c 100644 --- a/lib/src/remote/pocket-app/deployment.ts +++ b/lib/src/remote/pocket-app/deployment.ts @@ -37,19 +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; -} - -/** - * The parent of the one-time page's frame origins, or `null` where it frames - * nothing, by {@link frameDomainFor}'s rule: only Hosted's production page. - */ -export function oneTimeFrameDomainFor(origin: string): string | null { - return origin === HOSTED_ONE_TIME_ORIGIN ? HOSTED_FRAME_DOMAIN : null; + return deployment === 'hosted' && (origin === DEFAULT_RELAY_ORIGIN || origin === HOSTED_ONE_TIME_ORIGIN) + ? HOSTED_FRAME_DOMAIN + : null; } /** From e480a215c920396f41e4dccc54d6ef0fb431dcc8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 20:06:44 -0800 Subject: [PATCH 06/11] Give the preview config test's relay its production entry Co-Authored-By: Claude Opus 5.5 --- hosted/scripts/preview.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/hosted/scripts/preview.test.mjs b/hosted/scripts/preview.test.mjs index 25a282923..1fa61209d 100644 --- a/hosted/scripts/preview.test.mjs +++ b/hosted/scripts/preview.test.mjs @@ -142,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, From b1f6a9aad82f2c798e420d3f76c346a2308fc865 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 20:25:36 -0800 Subject: [PATCH 07/11] Name the connect Worker in the release workflow's steps Co-Authored-By: Claude Opus 5.5 --- .github/workflows/hosted-production.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hosted-production.yml b/.github/workflows/hosted-production.yml index 68f00a132..76b4c851f 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: @@ -100,7 +100,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 connect 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 }} From 361a0a5ff1378fad69fff274beff841a846df7be Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 20:33:56 -0800 Subject: [PATCH 08/11] Fold the page-origin check into S96 and name both routes at the room Co-Authored-By: Claude Opus 5.5 --- docs/specs/security-hosted.md | 3 +-- hosted/server/one-time-room.ts | 3 ++- scripts/security-rule-ids.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 0952f6515..d079a5415 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -66,8 +66,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended - **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 connect 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 `connectRules` in `hosted/server/headers.ts`. -- **FAIL IF** `S96` the connect 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 `stageConnect` in `hosted/scripts/stage-connect.mjs` each run it. Inspect `oneTimePageRoutes` in `hosted/server/one-time.ts` and `hosted/server/connect-worker.ts`. -- **FAIL IF** `S302` 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 `CONNECT_ORIGIN`, which `productionConfig` pins, its entry must name no page or client route, its assets no page, and the connect Worker must hold no Hyperdrive or secret. Inspect `oneTimePageMoved` in `hosted/server/one-time.ts`, `stageRelay` in `hosted/scripts/stage-relay.mjs`, and `connectBindings` in `hosted/server/bindings.ts`; `scripts/e2e-lint.mjs` holds the entry textually. +- **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 `CONNECT_ORIGIN`, which `productionConfig` pins, its entry must name no page or client route, its assets no page, and the connect Worker must hold no Hyperdrive or secret; or the connect 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 `stageConnect` in `hosted/scripts/stage-connect.mjs` each run it). Inspect `oneTimePageRoutes` and `oneTimePageMoved` in `hosted/server/one-time.ts`, `hosted/server/connect-worker.ts`, `stageRelay` in `hosted/scripts/stage-relay.mjs`, and `connectBindings` 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`. diff --git a/hosted/server/one-time-room.ts b/hosted/server/one-time-room.ts index bffcc9483..132638e62 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 connect 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/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index a2a3c40dd..62f93ac4f 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 303, + "next": 302, "retired": [ 265 ] From 44387c1a9cf668639b267ab940c873e7024025fe Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 21:51:08 -0800 Subject: [PATCH 09/11] Ratchet spec budgets after merging main Co-Authored-By: Claude Opus 5.5 --- scripts/spec-word-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index e1ff5f4ae..964a1bb0c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -19,7 +19,7 @@ "docs/specs/one-time.md": 2950, "docs/specs/pocket-app.md": 4350, "docs/specs/pricing.md": 2750, - "docs/specs/relay.md": 5800, + "docs/specs/relay.md": 5850, "docs/specs/remote-api.md": 5650, "docs/specs/remote-network.md": 2400, "docs/specs/remote-security-model.md": 6600, From fe483e6a235fb76c67f05cdd08f4ec4f291029b9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 23:03:07 -0800 Subject: [PATCH 10/11] Name the one-time origin try.dormouse.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Worker is dormouse-try, its previews dormouse-try-pr-N, and its config, entry, bindings, rules, smoke, and staging follow. The link keeps its path, so it reads https://try.dormouse.sh/connect/#…. The Worker has never deployed, so this needs no migration of its own: its v1 still introduces OneTimeRoom, and the relay's v3 still deletes its own copy. Co-Authored-By: Claude Opus 5.5 --- .github/audit/hosted.md | 2 +- .github/workflows/hosted-production.yml | 2 +- AGENTS.md | 2 +- docs/specs/hosted.md | 14 ++-- docs/specs/hosted.rationale.md | 4 +- docs/specs/one-time.md | 16 ++-- docs/specs/one-time.rationale.md | 2 +- docs/specs/relay.md | 2 +- docs/specs/remote-network.rationale.md | 2 +- docs/specs/remote-security-model.md | 2 +- docs/specs/security-hosted.md | 14 ++-- docs/specs/security-supply-chain.rationale.md | 2 +- docs/specs/security.md | 2 +- hosted/README.md | 16 ++-- hosted/package.json | 2 +- hosted/scripts/dev-one-time.mjs | 32 ++++---- hosted/scripts/dev-one-time.test.mjs | 32 ++++---- hosted/scripts/one-time-smoke.mjs | 24 +++--- hosted/scripts/preview-smoke.mjs | 24 +++--- hosted/scripts/preview.mjs | 6 +- hosted/scripts/preview.test.mjs | 38 ++++----- hosted/scripts/production.mjs | 30 +++---- hosted/scripts/production.test.mjs | 78 +++++++++---------- hosted/scripts/stage-relay.test.mjs | 34 ++++---- .../{stage-connect.mjs => stage-try.mjs} | 12 +-- hosted/scripts/workers.mjs | 6 +- hosted/server/bindings.ts | 14 ++-- hosted/server/headers.ts | 4 +- hosted/server/one-time-room.ts | 2 +- hosted/server/one-time.ts | 18 ++--- hosted/server/relay-worker.ts | 2 +- hosted/server/tests/boundary.test.ts | 36 ++++----- hosted/server/tests/frame-origin.test.ts | 4 +- hosted/server/tests/one-time-entry.ts | 2 +- hosted/server/tests/one-time.test.ts | 62 +++++++-------- .../{connect-worker.ts => try-worker.ts} | 12 +-- hosted/wrangler.frames.jsonc | 2 +- hosted/wrangler.relay.jsonc | 4 +- ...ngler.connect.jsonc => wrangler.try.jsonc} | 10 +-- lib/src/host/relay-origin.test.ts | 4 +- lib/src/host/relay-origin.ts | 2 +- lib/src/host/remote/service.test.ts | 2 +- lib/src/remote/pocket-app/deployment.test.ts | 4 +- scripts/outbound-lint.mjs | 2 +- website/scripts/hosted-bundles.test.js | 2 +- website/src/pages/Privacy.tsx | 2 +- website/src/pages/SupplyChain.test.tsx | 2 +- website/src/pages/SupplyChain.tsx | 2 +- 48 files changed, 297 insertions(+), 297 deletions(-) rename hosted/scripts/{stage-connect.mjs => stage-try.mjs} (75%) rename hosted/server/{connect-worker.ts => try-worker.ts} (69%) rename hosted/{wrangler.connect.jsonc => wrangler.try.jsonc} (82%) diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index da25ea901..40bdfde6b 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 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 connect 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`. +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 76b4c851f..bf321eed8 100644 --- a/.github/workflows/hosted-production.yml +++ b/.github/workflows/hosted-production.yml @@ -100,7 +100,7 @@ jobs: run: pnpm --filter dormouse-hosted db:migrate && pnpm --filter dormouse-hosted db:validate env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - - name: Deploy the frame and connect Workers, the relay with its readiness and rendezvous verified, 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 0102b71db..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 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 (`connect.`), 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. diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 28d1cffaa..3c2936cb7 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -10,12 +10,12 @@ | 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 Burrow route (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `RelayRoom`, a binding to the connect Worker's `OneTimeRoom`, the mint, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `CONNECT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | -| `dormouse-connect` | `https://connect.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-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, but `dormouse-connect`, 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 connect Worker to nothing. 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. @@ -360,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 four persistent workers.dev Workers (`dormouse-{hosted,relay,voice,connect}-pr-N`), one uncached Hyperdrive all but the connect 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 connect 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 `CONNECT_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). @@ -370,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` and `CONNECT_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, connect, relay, voice, then account, stopping at a failure**; the connect 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 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 connect 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. +**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, voice, and connect 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 2485fc276..2511babe3 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -20,12 +20,12 @@ 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 both left the account's origin. -- A fourth origin, `connect.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 connect Worker deploys first in production and previews alike, and the dev loop's rooms need no Postgres. +- 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` 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 connect 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. +- 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 0ce77ecde..b12883e3c 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -118,12 +118,12 @@ Source of truth: `OneTimeClient` in `lib/src/remote/client/one-time-client.ts`. ## Hosted rendezvous -The relay Worker serves the Burrow route and the connect Worker the client route (`docs/specs/hosted.md` -> "Application boundary"), and one `OneTimeRoom` Durable Object per room, which the connect 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). +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 the connect 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` | +| `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). @@ -144,7 +144,7 @@ Source of truth: `oneTimeBurrowRoute` and `oneTimeClientRoute` in `hosted/server ## Phone page -The connect 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). +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. @@ -154,20 +154,20 @@ The connect Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`) - **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 `stageConnect` empties the connect Worker's assets directory, copies the page to `hosted/dist/connect/connect/`, 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 connect 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`. +**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`; `stageConnect` in `hosted/scripts/stage-connect.mjs`; `oneTimePageRoutes` in `hosted/server/one-time.ts`; `connectRules` 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 `CONNECT_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 connect Worker on (rationale). +- **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. @@ -176,7 +176,7 @@ 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**: the relay Worker at `PORT` or a fixed default, since a Burrow build bakes that origin in, and the connect 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"). +**`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 6849ea1c4..225500206 100644 --- a/docs/specs/one-time.rationale.md +++ b/docs/specs/one-time.rationale.md @@ -28,7 +28,7 @@ Evidence for `docs/specs/one-time.md`, keyed by its headings. ## 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 connect Worker, 2026-11-02. +**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. diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 2533a67fb..eaebf8870 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 | its socket at this origin, its link at `https://connect.dormouse.sh` | 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"). diff --git a/docs/specs/remote-network.rationale.md b/docs/specs/remote-network.rationale.md index da31a2297..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 from `relay.dormouse.sh` or the one-time page from `connect.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 93c49c7a6..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.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)). +- **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 d079a5415..0dd0d562d 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 five Workers: account, relay, connect, 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. @@ -17,8 +17,8 @@ - **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`, `/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 connect 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` / `connectRules` 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.connect.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 connect 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** `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. @@ -55,18 +55,18 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended | **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**, reached from the relay and connect 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. +**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 connect Worker's `APP_ORIGIN`; inspect `oneTimeBurrowRoute` and `oneTimeClientRoute` 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 connect 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 `connectRules` 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 `CONNECT_ORIGIN`, which `productionConfig` pins, its entry must name no page or client route, its assets no page, and the connect Worker must hold no Hyperdrive or secret; or the connect 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 `stageConnect` in `hosted/scripts/stage-connect.mjs` each run it). Inspect `oneTimePageRoutes` and `oneTimePageMoved` in `hosted/server/one-time.ts`, `hosted/server/connect-worker.ts`, `stageRelay` in `hosted/scripts/stage-relay.mjs`, and `connectBindings` in `hosted/server/bindings.ts`; `scripts/e2e-lint.mjs` holds the entry textually. +- **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`. diff --git a/docs/specs/security-supply-chain.rationale.md b/docs/specs/security-supply-chain.rationale.md index 5f7685e2b..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; it and `connect.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 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 658215b2f..8236b3b59 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)); the phone opens the page at `connect.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)). | +| **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/hosted/README.md b/hosted/README.md index 61080691e..918247112 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -1,6 +1,6 @@ # Dormouse Hosted -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://connect.dormouse.sh` (`dormouse-connect`; [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. @@ -30,7 +30,7 @@ The one-time rendezvous and `/connect/` page run on their own, without Docker or dor tool one-time # outside Dormouse: pnpm dev:one-time ``` -It serves the relay at `http://localhost:8787` (or `PORT`) and the connect 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. +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 `RelayRoom` Durable Object namespace), `dormouse-connect-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-connect` (`connect.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 | +| 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,7 +105,7 @@ 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-connect-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 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. @@ -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`, `connect.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. +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` on all four hostnames and `/api/ready` on all but `connect.dormouse.sh`. 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: `https://connect.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 connect origin, crosses a frame each way, and is refused a second phone. Load `https://connect.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 be1954b0a..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-connect.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.connect.jsonc --outdir dist-worker/connect && 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 81d56a343..e3797ca15 100644 --- a/hosted/scripts/dev-one-time.mjs +++ b/hosted/scripts/dev-one-time.mjs @@ -8,8 +8,8 @@ import { parseConfig, renameBoundScripts } from "./workers.mjs"; /** * The one-time rendezvous and phone page on loopback (`docs/specs/one-time.md` - * -> "Dev loop"): the relay and connect Workers under `wrangler dev`, each on its - * own port as in production, sharing the connect Worker's Durable Object through + * -> "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 @@ -39,7 +39,7 @@ export const DEFAULT_PORT = 8787; /** * The relay's port, which `PORT` names, or the default; anything else is - * refused before a process starts. The connect Worker takes the next one. + * 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; @@ -100,9 +100,9 @@ const devWorkerConfig = (base, assets, vars) => ({ ratelimits: base.ratelimits, }); -/** The connect Worker's, over the folder the page rebuilds into. */ -export const devConnectConfig = (base, port) => - devWorkerConfig(base, "./connect-assets", { APP_ORIGIN: devOrigin(port) }); +/** 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 @@ -111,10 +111,10 @@ export const devConnectConfig = (base, port) => * account origin and reaches its database through a local Hyperdrive, never * production's. */ -export const devRelayConfig = (base, port, connectPort, attached = null) => ({ +export const devRelayConfig = (base, port, tryPort, attached = null) => ({ ...devWorkerConfig(base, "./relay-assets", { APP_ORIGIN: devOrigin(port), - CONNECT_ORIGIN: devOrigin(connectPort), + TRY_ORIGIN: devOrigin(tryPort), RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET, ...(attached && { ACCOUNT_ORIGIN: attached.accountOrigin }), }), @@ -158,10 +158,10 @@ async function waitFor(what, ready, timeoutMs) { async function main() { const port = devPort(process.env); - const connectPort = port + 1; + const tryPort = port + 1; const attached = hostedAttachment(process.env); const read = (name) => parseConfig(readFileSync(resolve(hosted, name), "utf8")); - const pageDir = resolve(devDir, "connect-assets", PAGE_PATH.slice(1)); + 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 }); @@ -171,8 +171,8 @@ async function main() { writeFileSync(path, JSON.stringify(config, null, 2) + "\n"); return path; }; - const connectConfig = writeConfig("connect.json", devConnectConfig(read("wrangler.connect.jsonc"), connectPort)); - const relayConfig = writeConfig("relay.json", devRelayConfig(read("wrangler.relay.jsonc"), port, connectPort, attached)); + 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) => { @@ -217,8 +217,8 @@ async function main() { hosted, ); // The room's Worker first, which the relay binds through the local registry. - wrangler(connectConfig, connectPort); - await waitFor("the connect Worker", async () => (await status(connectPort, PAGE_PATH)) === 200, 120_000); + 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); @@ -227,7 +227,7 @@ async function main() { console.log( [ "", - `One-time phone page: ${devOrigin(connectPort)}${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=${devOrigin(port)}`, @@ -243,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: connectPort, 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 168891ba7..f2e558496 100644 --- a/hosted/scripts/dev-one-time.test.mjs +++ b/hosted/scripts/dev-one-time.test.mjs @@ -5,7 +5,7 @@ import { DEFAULT_PORT, DEV_ENROLL_SECRET, LOCAL_HYPERDRIVE_ID, - devConnectConfig, + devTryConfig, devOrigin, devPort, devRelayConfig, @@ -16,7 +16,7 @@ import { parseConfig } from "./workers.mjs"; const readBase = async (name) => parseConfig(await readFile(new URL(`../${name}`, import.meta.url), "utf8")); const base = await readBase("wrangler.relay.jsonc"); -const connectBase = await readBase("wrangler.connect.jsonc"); +const tryBase = await readBase("wrangler.try.jsonc"); test("the relay's dev config runs its entry on loopback, with nothing of production's", () => { const config = devRelayConfig( @@ -33,14 +33,14 @@ test("the relay's dev config runs its entry on loopback, with nothing of product assert.notEqual(config.name, base.name); assert.deepEqual(config.vars, { APP_ORIGIN: "http://localhost:8787", - CONNECT_ORIGIN: "http://localhost:8788", + 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); - // Its room is the connect Worker's dev script, never a deployed one. + // 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: devConnectConfig(connectBase, 8788).name }, + { 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); @@ -52,27 +52,27 @@ test("the relay's dev config runs its entry on loopback, with nothing of product assert.deepEqual(config.compatibility_flags, base.compatibility_flags); }); -test("the connect Worker's dev config runs its entry and the room, with nothing of production's", () => { - const config = devConnectConfig( - { ...connectBase, vars: { ...connectBase.vars, GOOGLE_CLIENT_SECRET: "do-not-copy" }, d1_databases: [{ production: true }] }, +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/connect-worker.ts"); - assert.notEqual(config.name, connectBase.name); + 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, connectBase.durable_objects); - assert.deepEqual(config.migrations, connectBase.migrations); - assert.deepEqual(config.ratelimits, connectBase.ratelimits); - assert.deepEqual(config.assets, { ...connectBase.assets, directory: "./connect-assets" }); + 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); - // 65535 leaves no port for the connect Worker. + // 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"); @@ -103,7 +103,7 @@ test("attached, it approves at that account origin and reads its database, never ); assert.deepEqual(config.vars, { APP_ORIGIN: "http://localhost:8787", - CONNECT_ORIGIN: "http://localhost:8788", + 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 7df03c30d..dde070f53 100644 --- a/hosted/scripts/one-time-smoke.mjs +++ b/hosted/scripts/one-time-smoke.mjs @@ -19,13 +19,13 @@ const open = (url, headers) => new WebSocket(url, { headers }); /** * The live rendezvous across its two Workers: a link an older build shows moves - * from `relay` to the page at `connect`, a Burrow can mint on `relay`, a browser - * Origin cannot, a phone from `connect` joins there and a frame crosses each way + * 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. `connect` and `fetchPage` stand in for the network in tests. + * taken. `ws` and `fetchPage` stand in for the network in tests. */ -export async function oneTimeSmoke(relay, connect, ws = open, fetchPage = fetch) { - await movedSmoke(relay, connect, fetchPage); +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 { @@ -34,21 +34,21 @@ export async function oneTimeSmoke(relay, connect, ws = open, fetchPage = fetch) assert.match(frame.roomId, /^[A-Za-z0-9_-]{22}$/); assert.ok(frame.expiresAt > Date.now(), "A fresh room has not expired"); - const page = watch(ws(burrowUrl, { origin: connect })); + const page = watch(ws(burrowUrl, { origin: tryOrigin })); assert.equal( await page.settled(), false, "A browser Origin must not mint a room", ); - const clientUrl = `${connect.replace(/^http/, "ws")}${ONE_TIME_WS_ROUTES.client}?${ONE_TIME_ROOM_PARAM}=${frame.roomId}`; + 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: connect })); + 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`; @@ -60,7 +60,7 @@ export async function oneTimeSmoke(relay, connect, ws = open, fetchPage = fetch) marker.toUpperCase(), "Burrow → phone, verbatim", ); - const second = watch(ws(clientUrl, { origin: connect })); + const second = watch(ws(clientUrl, { origin: tryOrigin })); assert.equal( (await second.closed()).code, WS_CLOSE_ONE_TIME_TAKEN, @@ -75,12 +75,12 @@ export async function oneTimeSmoke(relay, connect, ws = 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, connect, fetchPage) { +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"), - connect + ONE_TIME_PAGE_PATH, + tryOrigin + ONE_TIME_PAGE_PATH, "An older build's link moves to the page's own origin", ); } @@ -92,7 +92,7 @@ async function movedSmoke(relay, connect, fetchPage) { */ export async function oneTimePageSmoke(origin, fetchPage = fetch) { const page = await fetchPage(origin + ONE_TIME_PAGE_PATH); - assert.equal(page.status, 200, "The connect Worker 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 70db93062..dd01a651b 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -349,11 +349,11 @@ async function liveSmoke(origin, sha, fetcher, attempts, retry) { } /** - * The connect Worker's smoke: its revision, then the phone page, each retried + * 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 connectSmoke( +export async function trySmoke( origin, sha, { fetcher = fetch, attempts = 1, retryMs = 10_000, wait = delay } = {}, @@ -365,12 +365,12 @@ export async function connectSmoke( /** * The relay's smoke: `liveSmoke`, then its push config and the one-time - * rendezvous it mints for the page at `connect`, each retried on its own the + * rendezvous it mints for the page at `tryOrigin`, each retried on its own the * same way. */ export async function relaySmoke( origin, - connect, + tryOrigin, sha, { fetcher = fetch, @@ -383,19 +383,19 @@ 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, connect), retry); + await retrying(attempts, `${origin} one-time`, () => oneTime(origin, tryOrigin), retry); } /** * Every Worker's smoke: the account's auth boundary, the voice's `liveSmoke`, - * `connectSmoke`, and `relaySmoke` — never waiting on the account, so an account + * `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, connect }`), + * 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, connect }, + { account, relay, voice, try: tryOrigin }, sha, { fetcher = fetch, @@ -413,9 +413,9 @@ export async function smokeAll( const results = await Promise.allSettled([ retrying(limit("account"), account, () => smoke(account, sha, fetcher, preview, providers), retry), - relaySmoke(relay, connect, 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), - connectSmoke(connect, sha, { fetcher, attempts: limit("connect"), retryMs, wait }), + trySmoke(tryOrigin, sha, { fetcher, attempts: limit("try"), retryMs, wait }), ]); const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [], @@ -432,10 +432,10 @@ if ( const [origins, sha] = process.argv.slice(2); assert.match(sha ?? "", /^[a-f0-9]{40}$/); const parsed = JSON.parse(origins); - const { account, voice, connect } = parsed; + const { account, voice, try: tryOrigin } = parsed; // A just-uploaded Worker may take a short time to become reachable everywhere. await smokeAll(parsed, sha, { preview: true, attempts: 6 }); console.log( - `Preview smoke checks passed: ${account}/login, ${connect}/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 3a10b3325..704b97981 100644 --- a/hosted/scripts/preview.mjs +++ b/hosted/scripts/preview.mjs @@ -79,7 +79,7 @@ export function previewConfig(base, env, worker, hyperdriveId) { * 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 and an older link's redirect the connect preview, never + * the account preview and an older link's redirect the try preview, never * production's. */ export function previewConfigs(bases, env, hyperdriveId) { @@ -90,7 +90,7 @@ export function previewConfigs(bases, env, hyperdriveId) { ]), ); configs.relay.vars.ACCOUNT_ORIGIN = configs.account.vars.APP_ORIGIN; - configs.relay.vars.CONNECT_ORIGIN = configs.connect.vars.APP_ORIGIN; + configs.relay.vars.TRY_ORIGIN = configs.try.vars.APP_ORIGIN; return configs; } @@ -271,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.connect}/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 1fa61209d..c2d698004 100644 --- a/hosted/scripts/preview.test.mjs +++ b/hosted/scripts/preview.test.mjs @@ -49,7 +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), - connect: stale(bases.connect), + try: stale(bases.try), }, env, "c".repeat(32), @@ -58,7 +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"], - connect: ["dormouse-connect-pr-42", "../../server/connect-worker.ts"], + try: ["dormouse-try-pr-42", "../../server/try-worker.ts"], }; for (const [worker, [name, main]] of Object.entries(expected)) { const config = configs[worker]; @@ -73,7 +73,7 @@ test("each preview configuration isolates its origin and excludes production bin // older link's redirect this PR's page, never production's. ...(worker === "relay" && { ACCOUNT_ORIGIN: "https://dormouse-hosted-pr-42.hosted-tests.workers.dev", - CONNECT_ORIGIN: "https://dormouse-connect-pr-42.hosted-tests.workers.dev", + TRY_ORIGIN: "https://dormouse-try-pr-42.hosted-tests.workers.dev", }), }, worker, @@ -86,7 +86,7 @@ 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); // Every preview that binds one shares one database; the page's binds none. - assert.equal(configs.connect.hyperdrive, undefined); + 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); @@ -103,16 +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" }], }); - // The relay's room binding names this PR's connect preview, which implements it. + // 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-connect-pr-42" }, + { 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); - assert.deepEqual(configs.connect.durable_objects, bases.connect.durable_objects); - assert.deepEqual(configs.connect.migrations, bases.connect.migrations); - assert.equal(configs.connect.assets.directory, "../../dist/connect"); - for (const worker of ["relay", "account", "connect"]) + 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 }) => [ @@ -327,7 +327,7 @@ 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-connect-pr-42", "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], @@ -420,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-connect", "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"]); }); @@ -459,7 +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", - connect: "https://connect.example.test", + try: "https://try.example.test", }; const events = []; // The relay's VAPID key, or null while push is off. @@ -471,19 +471,19 @@ 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.connect && pathname === "/connect/") + if (origin === origins.try && pathname === "/connect/") return new Response('', { headers: { "content-type": "text/html", "content-security-policy": [ - `script-src ${origins.connect}/connect/assets/ 'wasm-unsafe-eval'`, - "connect-src wss://connect.example.test/api/one-time/client", + `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.connect && pathname.startsWith("/connect/assets/")) + if (origin === origins.try && pathname.startsWith("/connect/assets/")) return new Response("export {};", { headers: { "content-type": "text/javascript", "cache-control": "public, max-age=31536000, immutable" }, }); @@ -529,7 +529,7 @@ test("the smoke runs its parts concurrently, retries each alone, and checks read const count = (event) => events.filter((e) => e === event).length; const oneTime = async (origin, page) => { assert.equal(origin, origins.relay); - assert.equal(page, origins.connect); + assert.equal(page, origins.try); events.push("one-time"); }; const waits = []; @@ -548,7 +548,7 @@ test("the smoke runs its parts concurrently, retries each alone, and checks read assert.equal(count("one-time"), 1); assert.equal(count("push"), 1); // Holding no database, the page's Worker reports no readiness. - assert.equal(count(`${origins.connect} ready`), 0); + 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`)); diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs index 9eb751c1a..1c532581e 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 { connectSmoke, 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). The connect Worker has none. + * account's by hand (hosted/README.md). The try Worker has none. */ export const PRODUCTION = { account: { @@ -39,9 +39,9 @@ export const PRODUCTION = { hyperdrive: "VOICE_HYPERDRIVE_ID", role: "dormouse_voice", }, - connect: { - name: "dormouse-connect", - origin: "https://connect.dormouse.sh", + try: { + name: "dormouse-try", + origin: "https://try.dormouse.sh", }, }; @@ -52,7 +52,7 @@ const hyperdriveWorkers = Object.entries(PRODUCTION).filter(([, { hyperdrive }]) export const FRAMES = { name: "dormouse-frames", domain: HOSTED_FRAME_DOMAIN, - clientOrigins: `${PRODUCTION.relay.origin} ${PRODUCTION.connect.origin}`, + clientOrigins: `${PRODUCTION.relay.origin} ${PRODUCTION.try.origin}`, }; /** Its whole config: anything more, a binding above all, it must not have. */ @@ -93,7 +93,7 @@ export function productionConfig(base, env, worker) { // 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.CONNECT_ORIGIN, PRODUCTION.connect.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, [ @@ -170,15 +170,15 @@ export function requiredSecrets(configs) { ); } /** - * The relay, voice, and connect Workers retry as a preview's parts do, since a + * 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, connect: 6 }; +const ATTEMPTS = { account: 1, relay: 6, voice: 6, try: 6 }; /** - * Deploys frames, connect, relay, voice, then account, stopping at a failure. - * The connect Worker passes `connectSmoke` before the relay deploys, so a link + * 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. @@ -193,13 +193,13 @@ export function deployProduction( afterDeploy: async (worker) => { if (worker === "frames") await frameSmoke(FRAMES.domain, FRAMES.clientOrigins, { attempts: ATTEMPTS.relay, ...options }); - if (worker === "connect") - await connectSmoke(configs.connect.vars.APP_ORIGIN, sha, { - attempts: ATTEMPTS.connect, + 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, configs.connect.vars.APP_ORIGIN, sha, { + await relaySmoke(configs.relay.vars.APP_ORIGIN, configs.try.vars.APP_ORIGIN, sha, { attempts: ATTEMPTS.relay, ...options, }); diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs index a3cdc5dff..d4cfe56d4 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -22,14 +22,14 @@ 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 https://connect.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 connect Worker's page, or its script, as a served release answers them. */ -const connectPage = (served, url = "") => +/** 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/") @@ -40,8 +40,8 @@ const connectPage = (served, url = "") => headers: { "content-type": "text/html", "content-security-policy": [ - "script-src https://connect.dormouse.sh/connect/assets/ 'wasm-unsafe-eval'", - "connect-src wss://connect.dormouse.sh/api/one-time/client", + "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("; "), @@ -75,7 +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"], - connect: ["dormouse-connect", "https://connect.dormouse.sh", "../../server/connect-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]; @@ -112,19 +112,19 @@ test("each production config keeps its canonical domain and production entry, an ), ); // An older build's one-time link moves to production's page, and no other. - assert.equal(configs.relay.vars.CONNECT_ORIGIN, "https://connect.dormouse.sh"); + 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, CONNECT_ORIGIN: elsewhere } }, + { ...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.connect.hyperdrive, undefined); + assert.equal(configs.try.hyperdrive, undefined); assert.throws( - () => productionConfig({ ...bases.connect, hyperdrive: [{ binding: "HYPERDRIVE", id: "a".repeat(32) }] }, env, "connect"), + () => 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. @@ -173,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, connect, 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 }), ); @@ -188,10 +188,10 @@ test("Workers deploy frames, connect, relay, voice, then account from one config await deployWorkers("deploy-test", configs, { spawn: spawn() }); assert.deepEqual( deployed.map(([name, path]) => [name, path.split("/").slice(-3).join("/")]), - // The connect Worker before the relay, whose room binding and redirect name it. + // The try Worker before the relay, whose room binding and redirect name it. [ ["dormouse-frames", ".wrangler/deploy-test/wrangler.frames.json"], - ["dormouse-connect", ".wrangler/deploy-test/wrangler.connect.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"], @@ -202,7 +202,7 @@ test("Workers deploy frames, connect, relay, voice, then account from one config deployWorkers("deploy-test", configs, { spawn: spawn("dormouse-voice") }), { message: "dormouse-voice deploy failed" }, ); - assert.deepEqual(deployed.map(([name]) => name), ["dormouse-frames", "dormouse-connect", "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" }); @@ -224,18 +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 connect Worker answers its page, as a new domain may not yet. + // Whether the try Worker answers its page, as a new domain may not yet. let pageServed = true; const fetcher = async (url) => { - if (url === "https://connect.dormouse.sh/api/health") { - events.push("connect health"); + if (url === "https://try.dormouse.sh/api/health") { + events.push("try health"); return Response.json({ ok: true, revision: env.BUILD_SHA }); } - if (url === "https://connect.dormouse.sh/connect/") { - events.push("connect page"); - return connectPage(pageServed); + if (url === "https://try.dormouse.sh/connect/") { + events.push("try page"); + return tryPage(pageServed); } - if (url === "https://connect.dormouse.sh/connect/assets/page-abc.js") return connectPage(pageServed, url); + 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) }); @@ -261,16 +261,16 @@ test("production smokes the relay before anything after it deploys, and a relay }); await deploy(async (origin, page) => { assert.equal(origin, "https://relay.dormouse.sh"); - assert.equal(page, "https://connect.dormouse.sh"); + assert.equal(page, "https://try.dormouse.sh"); events.push("relay one-time"); }); // 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-connect", - "connect health", - "connect page", + "deploy dormouse-try", + "try health", + "try page", "deploy dormouse-relay", ...Array(6).fill("relay health"), "relay ready", @@ -290,7 +290,7 @@ test("production smokes the relay before anything after it deploys, and a relay ); // Six attempts, then nothing else deploys. assert.equal(rendezvous, 6); - const toRelay = ["deploy dormouse-frames", "deploy dormouse-connect", "connect health", "connect page", "deploy dormouse-relay"]; + 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; @@ -309,9 +309,9 @@ test("production smokes the relay before anything after it deploys, and a relay }); assert.deepEqual(events, [ "deploy dormouse-frames", - "deploy dormouse-connect", - "connect health", - ...Array(6).fill("connect page"), + "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; @@ -338,14 +338,14 @@ test("the frame Worker holds nothing but its assets, on the wildcard of its own [{ routes: [{ pattern: "*.dormouse.sh/*", zone_name: "dormouse.sh" }] }, /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://connect.dormouse.sh", SECRET: "x" } }, /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 https://connect.dormouse.sh", { + frameSmoke("dormouseusercontent.com", "https://relay.dormouse.sh https://try.dormouse.sh", { fetcher: frameOrigin(change), worker: async () => "worker", }); @@ -360,12 +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://connect.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 connectPage(true, 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)}` }); @@ -395,7 +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://connect.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"]); @@ -409,21 +409,21 @@ test("the history and idle-token sweeps' crons are the voice Worker's, the relay 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", script_name: configs.connect.name }, + { 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 connect Worker implements it. + // 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.connect.durable_objects, { + assert.deepEqual(configs.try.durable_objects, { bindings: [{ name: "ONE_TIME_ROOM", class_name: "OneTimeRoom" }], }); - assert.deepEqual(configs.connect.migrations, [{ tag: "v1", new_sqlite_classes: ["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 }], @@ -439,7 +439,7 @@ test("each Durable Object is one Worker's, each rate limit its Worker's, and Dur ], ); assert.deepEqual( - configs.connect.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), + configs.try.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), [["ONE_TIME_JOIN_LIMIT", "2"]], ); // Approvals are limited per account, on the account. diff --git a/hosted/scripts/stage-relay.test.mjs b/hosted/scripts/stage-relay.test.mjs index 1ddba001c..47cb62765 100644 --- a/hosted/scripts/stage-relay.test.mjs +++ b/hosted/scripts/stage-relay.test.mjs @@ -3,7 +3,7 @@ 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 { stageConnect } from "./stage-connect.mjs"; +import { stageTry } from "./stage-try.mjs"; import { stageRelay } from "./stage-relay.mjs"; /** @@ -31,11 +31,11 @@ function fixture({ mkdirSync(join(assets, "connect", "assets"), { recursive: true }); writeFileSync(join(assets, "connect", "assets", "stale.js"), ""); writeFileSync(join(assets, "stray.html"), "stray"); - const connect = join(root, "dist", "connect"); - mkdirSync(join(connect, "connect", "assets"), { recursive: true }); - writeFileSync(join(connect, "connect", "assets", "stale.js"), ""); - writeFileSync(join(connect, "stray.html"), "stray"); - return { pocket, oneTime, assets, connect, 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 it is all the relay's assets hold: no one-time page beside it", () => { @@ -58,13 +58,13 @@ test("Pocket lands at the root, and it is all the relay's assets hold: no one-ti }); }); -test("the one-time page lands at its path, and it is all the connect Worker's assets hold", () => { - const { oneTime, connect } = fixture(); - assert.equal(stageConnect(oneTime, connect), 1); - assert.ok(existsSync(join(connect, "connect", "index.html"))); - assert.ok(existsSync(join(connect, "connect", "assets", "index-abc.js"))); - assert.ok(!existsSync(join(connect, "connect", "assets", "stale.js"))); - assert.deepEqual(readdirSync(connect), ["connect"]); +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", () => { @@ -80,8 +80,8 @@ test("a shell its policy would refuse is not staged quietly", () => { '', "", ]) { - const { oneTime, connect } = fixture({ oneTimeScript }); - assert.throws(() => stageConnect(oneTime, connect), undefined, oneTimeScript); + const { oneTime, tryAssets } = fixture({ oneTimeScript }); + assert.throws(() => stageTry(oneTime, tryAssets), undefined, oneTimeScript); } for (const pocketScript of [ '', @@ -94,7 +94,7 @@ test("a shell its policy would refuse is not staged quietly", () => { }); test("staging refuses to run before its build", () => { - const { pocket, oneTime, assets, connect } = fixture(); + const { pocket, oneTime, assets, tryAssets } = fixture(); assert.throws(() => stageRelay(join(pocket, "missing"), assets), /build:pocket/); - assert.throws(() => stageConnect(join(oneTime, "missing"), connect), /build:one-time/); + assert.throws(() => stageTry(join(oneTime, "missing"), tryAssets), /build:one-time/); }); diff --git a/hosted/scripts/stage-connect.mjs b/hosted/scripts/stage-try.mjs similarity index 75% rename from hosted/scripts/stage-connect.mjs rename to hosted/scripts/stage-try.mjs index 7c51a27f0..769eda290 100644 --- a/hosted/scripts/stage-connect.mjs +++ b/hosted/scripts/stage-try.mjs @@ -4,13 +4,13 @@ import { fileURLToPath } from "node:url"; import { assertPocketShell, ONE_TIME_SHELL } from "../../lib/scripts/assert-pocket-worker.mjs"; /** - * Stage the connect Worker's assets: the one-time phone page from `lib`'s + * 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 stageConnect(built, assets) { +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 }); @@ -24,15 +24,15 @@ export function stageConnect(built, assets) { if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { try { - const scripts = stageConnect( + const scripts = stageTry( fileURLToPath(new URL("../../lib/dist-one-time", import.meta.url)), - fileURLToPath(new URL("../dist/connect", import.meta.url)), + fileURLToPath(new URL("../dist/try", import.meta.url)), ); console.log( - `connect assets staged at dist/connect: the one-time page at ${ONE_TIME_SHELL.base} (${scripts} script(s) under ${ONE_TIME_SHELL.scriptBase})`, + `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(`connect staging failed: ${error.message}`); + console.error(`try staging failed: ${error.message}`); process.exitCode = 1; } } diff --git a/hosted/scripts/workers.mjs b/hosted/scripts/workers.mjs index 896cea0fc..61f8785b3 100644 --- a/hosted/scripts/workers.mjs +++ b/hosted/scripts/workers.mjs @@ -11,15 +11,15 @@ const hosted = new URL("../", import.meta.url); * 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 connect Worker deploys before the + * 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. - connect: { - config: "wrangler.connect.jsonc", + try: { + config: "wrangler.try.jsonc", }, relay: { config: "wrangler.relay.jsonc", diff --git a/hosted/server/bindings.ts b/hosted/server/bindings.ts index c08b3bb50..10b61f03a 100644 --- a/hosted/server/bindings.ts +++ b/hosted/server/bindings.ts @@ -47,7 +47,7 @@ export interface AccountEnv extends BetterAuthWorkerBindings, WorkerEnv, Billing export interface RelayEnv extends WorkerEnv { ASSETS: Assets; HYPERDRIVE: { connectionString: string }; - /** The connect Worker's `OneTimeRoom`s, which a Burrow's socket opens. */ + /** 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; @@ -62,10 +62,10 @@ export interface RelayEnv extends WorkerEnv { */ ACCOUNT_ORIGIN?: string; /** - * The connect Worker's origin, where a link from an older build is sent: + * 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. */ - CONNECT_ORIGIN?: string; + 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. */ @@ -73,8 +73,8 @@ export interface RelayEnv extends WorkerEnv { RELAY_VAPID_PRIVATE_KEY?: string; } -/** `connect.dormouse.sh`: the one-time phone page and the room a phone joins. No secret and no database. */ -export interface ConnectEnv extends WorkerEnv { +/** `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; @@ -137,14 +137,14 @@ export const relayBindings = (env: RelayEnv): RelayEnv => ({ 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, - CONNECT_ORIGIN: exactOrigin(env.CONNECT_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 connectBindings = (env: ConnectEnv): ConnectEnv => ({ +export const tryBindings = (env: TryEnv): TryEnv => ({ ASSETS: env.ASSETS, APP_ORIGIN: env.APP_ORIGIN, BUILD_SHA: env.BUILD_SHA, diff --git a/hosted/server/headers.ts b/hosted/server/headers.ts index 80ab348b5..bdf885156 100644 --- a/hosted/server/headers.ts +++ b/hosted/server/headers.ts @@ -88,11 +88,11 @@ export const relayRules: RulesFor = (path, appOrigin) => { }; /** - * The connect Worker's rules: the page's policy under `/connect`, and the + * 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 connectRules: RulesFor = (path, appOrigin) => { +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 { diff --git a/hosted/server/one-time-room.ts b/hosted/server/one-time-room.ts index 132638e62..ca213bd6b 100644 --- a/hosted/server/one-time-room.ts +++ b/hosted/server/one-time-room.ts @@ -65,7 +65,7 @@ export class OneTimeRoom { } // Reached only through `oneTimeBurrowRoute` on the relay Worker or - // `oneTimeClientRoute` on the connect Worker: each has already checked the + // `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 060e1e5cb..83266c8f3 100644 --- a/hosted/server/one-time.ts +++ b/hosted/server/one-time.ts @@ -8,10 +8,10 @@ import { ONE_TIME_WS_ROUTES, toBase64Url, } from "remote-lib-common"; -import type { ConnectEnv, RelayEnv } from "./bindings"; +import type { TryEnv, RelayEnv } from "./bindings"; import { forwardUpgrade, isUpgrade, upgradeRequired } from "./socket-room"; -type ConnectContext = Context<{ Bindings: ConnectEnv }>; +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 @@ -35,8 +35,8 @@ export function oneTimeBurrowRoute(app: Hono<{ Bindings: RelayEnv }>) { }); } -/** The connect Worker's half: only the page on that origin may join a room. */ -export function oneTimeClientRoute(app: Hono<{ Bindings: ConnectEnv }>) { +/** 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) @@ -51,13 +51,13 @@ export function oneTimeClientRoute(app: Hono<{ Bindings: ConnectEnv }>) { /** * The one-time phone page (`docs/specs/one-time.md` -> "Phone page"), staged - * into the connect Worker's assets under `/connect/`: its shell and its + * 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: ConnectEnv }>) { - const assets = (c: ConnectContext) => 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); @@ -75,8 +75,8 @@ export function oneTimePageRoutes(app: Hono<{ Bindings: ConnectEnv }>) { */ export function oneTimePageMoved(app: Hono<{ Bindings: RelayEnv }>) { const moved = (c: Context<{ Bindings: RelayEnv }>) => - c.env.CONNECT_ORIGIN - ? c.redirect(`${c.env.CONNECT_ORIGIN}${ONE_TIME_PAGE_PATH}`, 308) + 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); diff --git a/hosted/server/relay-worker.ts b/hosted/server/relay-worker.ts index 1ec959147..19cd4a3f8 100644 --- a/hosted/server/relay-worker.ts +++ b/hosted/server/relay-worker.ts @@ -12,7 +12,7 @@ import { workerApp } from "./worker-app"; * The relay Worker, `dormouse-relay` on `relay.dormouse.sh`: the Hosted Relay's * 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/connect-worker.ts`); its old path + * 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 diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 48ffdfcd9..dfa89b9fc 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -16,7 +16,7 @@ import { type AccountEnv, accountBindings, accountPreviewBindings, - connectBindings, + tryBindings, relayBindings, voiceBindings, voicePreviewBindings, @@ -26,7 +26,7 @@ import { RUNS_NOTHING_POLICY, accountRules, relayRules, - connectRules, + tryRules, type RulesFor, } from "../headers"; import { ADMIN_EMAIL } from "../entitlement"; @@ -172,7 +172,7 @@ const SERVED: Record = { ...ACCOUNT_BILLING, ["GET", "/login"], ], - connect: [ + try: [ ["GET", ONE_TIME_WS_ROUTES.client], ["GET", ONE_TIME_PAGE_PATH], ], @@ -246,7 +246,7 @@ const ABSENT: Record = { ["DELETE", "/api/voice/token"], ...RELAY_API, ], - connect: [ + try: [ ["GET", "/api/auth/csrf"], ["GET", "/api/providers"], ["GET", "/api/ready"], @@ -261,7 +261,7 @@ const ABSENT: Record = { ["GET", WS_ROUTES.client], ], relay: [ - // The phone's route is the connect Worker's alone. + // 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"], @@ -358,8 +358,8 @@ test("each Worker caches only its own hashed assets as immutable", async () => { 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("no-store"); - expect(cache(await send("connect", ORIGINS.connect + phone))).toBe(IMMUTABLE); - expect(cache(await send("connect", ORIGINS.connect + pocket))).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({ @@ -381,11 +381,11 @@ test("each Worker caches only its own hashed assets as immutable", async () => { expect(cache(await relay.fetch(new Request(ORIGINS.relay + path), env)), path).toBe("no-cache"); 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 connect = serving(connectRules); - const connectEnv = { APP_ORIGIN: ORIGINS.connect }; - expect(cache(await connect.fetch(new Request(ORIGINS.connect + phone), connectEnv))).toBe(IMMUTABLE); + 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 connect.fetch(new Request(ORIGINS.connect + path), connectEnv)), path).toBe("no-store"); + 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); @@ -479,7 +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", - CONNECT_ORIGIN: "https://connect.example.test", + TRY_ORIGIN: "https://try.example.test", ...STRIPE, }; const keys = (bindings: object) => Object.keys(bindings).sort(); @@ -517,7 +517,7 @@ test("each bindings mapper passes only what its Worker uses", () => { "APP_ORIGIN", "ASSETS", "BUILD_SHA", - "CONNECT_ORIGIN", + "TRY_ORIGIN", "HYPERDRIVE", "ONE_TIME_MINT_LIMIT", "ONE_TIME_ROOM", @@ -542,12 +542,12 @@ test("each bindings mapper passes only what its Worker uses", () => { undefined, ]) expect(relayBindings({ ...env, ACCOUNT_ORIGIN: loose }).ACCOUNT_ORIGIN, String(loose)).toBeUndefined(); - // `CONNECT_ORIGIN` as well: it writes a redirect's `Location`. - expect(relayBindings(env).CONNECT_ORIGIN).toBe(env.CONNECT_ORIGIN); - for (const loose of ["https://connect.example.test/", "https://connect.example.test\nX: y", "javascript:alert(1)", "", undefined]) - expect(relayBindings({ ...env, CONNECT_ORIGIN: loose }).CONNECT_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(connectBindings(env))).toEqual( + 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. diff --git a/hosted/server/tests/frame-origin.test.ts b/hosted/server/tests/frame-origin.test.ts index c96c7f440..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_ORIGINS: "https://relay.dormouse.sh https://connect.dormouse.sh", + CLIENT_ORIGINS: "https://relay.dormouse.sh https://try.dormouse.sh", ASSETS: { fetch: async (request: Request) => { asked.push(new URL(request.url).pathname); @@ -25,7 +25,7 @@ describe("frameOriginResponse", () => { expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain(type); expect(response.headers.get("content-security-policy")).toMatch( - /frame-ancestors https:\/\/relay\.dormouse\.sh https:\/\/connect\.dormouse\.sh$/, + /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 1a03deff0..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 "../connect-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 63ca07bce..80bc6cbbb 100644 --- a/hosted/server/tests/one-time.test.ts +++ b/hosted/server/tests/one-time.test.ts @@ -27,7 +27,7 @@ import { } from "remote-lib-common"; import * as smoke from "../../scripts/one-time-smoke.mjs"; import { HOSTED_FRAME_DOMAIN } from "remote-lib-common"; -import { connectRules, oneTimePagePolicy, RUNS_NOTHING_POLICY } from "../headers"; +import { tryRules, oneTimePagePolicy, RUNS_NOTHING_POLICY } from "../headers"; import { rateLimitKey } from "../one-time"; import { ENTRIES, ORIGINS, bundleWorker, miniflareOptions, together, wrangler } from "./bundle"; import { limitOf, untilLimited } from "./rate-limit"; @@ -38,8 +38,8 @@ import { rawUpgrade, type RawSocket } from "./raw-socket"; // without Postgres: none of it touches Hyperdrive, so this suite runs in the // root `pnpm test`. -/** The connect Worker's origin: the page's, and the one that joins a room. */ -const origin = ORIGINS.connect; +/** 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. */ @@ -52,11 +52,11 @@ const POCKET_SHELL = "Pocket"; const relayBundle = bundleWorker(ENTRIES.relay); -async function start(connectEntry: string) { - const [connectBundle, relayScript] = await Promise.all([bundleWorker(connectEntry), relayBundle]); +async function start(tryEntry: string) { + const [tryBundle, relayScript] = await Promise.all([bundleWorker(tryEntry), relayBundle]); const mf = new Miniflare( together( - miniflareOptions("connect", connectBundle.outputFiles[0].text, { + miniflareOptions("try", tryBundle.outputFiles[0].text, { bindings: { APP_ORIGIN: origin }, routes: [`${new URL(origin).host}/*`], serviceBindings: { @@ -77,7 +77,7 @@ async function start(connectEntry: string) { }, }), miniflareOptions("relay", relayScript.outputFiles[0].text, { - bindings: { APP_ORIGIN: relay, CONNECT_ORIGIN: origin }, + 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: { @@ -94,7 +94,7 @@ let production: Awaited>; let short: Awaited>; beforeAll(async () => { [production, short] = await Promise.all([ - start(ENTRIES.connect), + start(ENTRIES.try), start("server/tests/one-time-entry.ts"), ]); }); @@ -108,7 +108,7 @@ const freshIp = () => `198.51.100.${++addresses}`; /** * An upgrade through the Worker, as a request for `url`: by default the relay - * for the Burrow's route, and the connect Worker for anything else. + * for the Burrow's route, and the try Worker for anything else. */ function upgrade( path: string, @@ -182,8 +182,8 @@ test("no Origin may mint, and only the page's origin may join", async () => { { origin: relay }, ...SIBLINGS.map((sibling) => ({ origin: sibling })), { origin: "https://dormouse.sh" }, - { origin: "https://connect.dormouse.sh.example" }, - { origin: "http://connect.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. @@ -360,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.connect.name, "OneTimeRoom", { + production.mf.unsafeEvictDurableObject(wrangler.try.name, "OneTimeRoom", { name: frame.roomId, webSockets: "hibernate", }); @@ -530,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://connect.dormouse.sh/connect/assets/ 'wasm-unsafe-eval'; " + - "style-src https://connect.dormouse.sh/connect/assets/ 'unsafe-inline'; " + - "img-src https://connect.dormouse.sh/connect/ data: blob:; " + - "font-src https://connect.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://connect.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 " + @@ -643,29 +643,29 @@ test("a link from an older build moves to the page's own origin, rendering nothi 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 connect Worker"); - expect(await burrow.next()).toBe("through the connect Worker"); + 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 connect Worker holds nothing but the page, its rooms, and their join limit", () => { - expect(wrangler.connect.vars).toEqual({ APP_ORIGIN: origin }); - expect(wrangler.connect).not.toHaveProperty("hyperdrive"); - expect(wrangler.connect.ratelimits?.map(({ name }) => name)).toEqual(["ONE_TIME_JOIN_LIMIT"]); - expect(wrangler.connect.durable_objects?.bindings).toEqual([ +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.connect.name, + script_name: wrangler.try.name, }); }); test("a malformed APP_ORIGIN falls back to the policy that runs nothing on the page", () => { - expect(connectRules("/connect/", "http://localhost:8787").policy).toBe( + expect(tryRules("/connect/", "http://localhost:8787").policy).toBe( oneTimePagePolicy("http://localhost:8787"), ); expect(oneTimePagePolicy("http://localhost:8787")).toContain( @@ -674,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://connect.dormouse.sh/", - "https://connect.dormouse.sh; script-src *", - "https://connect.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://connect.dormouse.sh", + "ws://try.dormouse.sh", ]) for (const path of ["/connect/", "/"]) - expect(connectRules(path, bad).policy, `${path} ${String(bad)}`).toBe( + expect(tryRules(path, bad).policy, `${path} ${String(bad)}`).toBe( RUNS_NOTHING_POLICY, ); }); diff --git a/hosted/server/connect-worker.ts b/hosted/server/try-worker.ts similarity index 69% rename from hosted/server/connect-worker.ts rename to hosted/server/try-worker.ts index 6077e7c85..e390cef3e 100644 --- a/hosted/server/connect-worker.ts +++ b/hosted/server/try-worker.ts @@ -1,18 +1,18 @@ -import { connectBindings, type ConnectEnv } from "./bindings"; -import { connectRules } from "./headers"; +import { tryBindings, type TryEnv } from "./bindings"; +import { tryRules } from "./headers"; import { oneTimeClientRoute, oneTimePageRoutes } from "./one-time"; import { workerApp } from "./worker-app"; /** - * The connect Worker, `dormouse-connect` on `connect.dormouse.sh`: the one-time + * 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: connectBindings, - rules: connectRules, +export default workerApp({ + bindings: tryBindings, + rules: tryRules, unavailable: "The one-time connection is temporarily unavailable. Please try again.", routes(app) { oneTimeClientRoute(app); diff --git a/hosted/wrangler.frames.jsonc b/hosted/wrangler.frames.jsonc index 75c775b45..4b753377d 100644 --- a/hosted/wrangler.frames.jsonc +++ b/hosted/wrangler.frames.jsonc @@ -21,7 +21,7 @@ "run_worker_first": true }, "vars": { - "CLIENT_ORIGINS": "https://relay.dormouse.sh https://connect.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 bb3a6962e..2756dfe9d 100644 --- a/hosted/wrangler.relay.jsonc +++ b/hosted/wrangler.relay.jsonc @@ -23,7 +23,7 @@ "vars": { "APP_ORIGIN": "https://relay.dormouse.sh", "ACCOUNT_ORIGIN": "https://hosted.dormouse.sh", - "CONNECT_ORIGIN": "https://connect.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 -> @@ -40,7 +40,7 @@ { "name": "ONE_TIME_ROOM", "class_name": "OneTimeRoom", - "script_name": "dormouse-connect" + "script_name": "dormouse-try" }, { "name": "RELAY_ROOM", diff --git a/hosted/wrangler.connect.jsonc b/hosted/wrangler.try.jsonc similarity index 82% rename from hosted/wrangler.connect.jsonc rename to hosted/wrangler.try.jsonc index 1eb6bf494..91825c289 100644 --- a/hosted/wrangler.connect.jsonc +++ b/hosted/wrangler.try.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "dormouse-connect", - "main": "server/connect-worker.ts", + "name": "dormouse-try", + "main": "server/try-worker.ts", "compatibility_date": "2026-09-08", "compatibility_flags": [ "nodejs_compat" @@ -10,18 +10,18 @@ "preview_urls": false, "routes": [ { - "pattern": "connect.dormouse.sh", + "pattern": "try.dormouse.sh", "custom_domain": true } ], "assets": { - "directory": "./dist/connect", + "directory": "./dist/try", "binding": "ASSETS", "not_found_handling": "none", "run_worker_first": true }, "vars": { - "APP_ORIGIN": "https://connect.dormouse.sh" + "APP_ORIGIN": "https://try.dormouse.sh" }, "durable_objects": { "bindings": [ diff --git a/lib/src/host/relay-origin.test.ts b/lib/src/host/relay-origin.test.ts index e4b595e71..f5cd5a650 100644 --- a/lib/src/host/relay-origin.test.ts +++ b/lib/src/host/relay-origin.test.ts @@ -77,8 +77,8 @@ describe('the baked relay origin', () => { }); it('names the origins Hosted deploys its Workers at', async () => { - const { account, connect, relay, voice } = await readConfigs(); - expect(HOSTED_ONE_TIME_ORIGIN).toBe(connect.vars.APP_ORIGIN); + 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); diff --git a/lib/src/host/relay-origin.ts b/lib/src/host/relay-origin.ts index a297f593b..68dac000b 100644 --- a/lib/src/host/relay-origin.ts +++ b/lib/src/host/relay-origin.ts @@ -41,7 +41,7 @@ export const HOSTED_ACCOUNT_ORIGIN = 'https://hosted.dormouse.sh'; * 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://connect.dormouse.sh'; +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'; diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index caefc34ee..39c00f744 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -2437,7 +2437,7 @@ describe('one-time connection', () => { expect(rendezvous.rooms).toHaveLength(1); expect(rendezvous.room().burrowUrl).toBe('wss://relay.dormouse.sh/api/one-time/burrow'); // 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://connect.dormouse.sh'); + 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, diff --git a/lib/src/remote/pocket-app/deployment.test.ts b/lib/src/remote/pocket-app/deployment.test.ts index 149e4a9c6..fd56f5506 100644 --- a/lib/src/remote/pocket-app/deployment.test.ts +++ b/lib/src/remote/pocket-app/deployment.test.ts @@ -189,8 +189,8 @@ 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://connect.dormouse.sh')).toBe('dormouseusercontent.com'); - expect(frameDomainFor('hosted', 'https://dormouse-connect-pr-7.example.workers.dev')).toBeNull(); + 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(); diff --git a/scripts/outbound-lint.mjs b/scripts/outbound-lint.mjs index e691dbc4a..eb53a2311 100644 --- a/scripts/outbound-lint.mjs +++ b/scripts/outbound-lint.mjs @@ -304,7 +304,7 @@ const OUTBOUND_SITES = { */ const REMOTE_LITERALS = { 'lib/src/host/relay-origin.ts': { - hosts: ['relay.dormouse.sh', 'voice.dormouse.sh', 'hosted.dormouse.sh', 'connect.dormouse.sh'], + 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': { diff --git a/website/scripts/hosted-bundles.test.js b/website/scripts/hosted-bundles.test.js index 32d997272..89a581d6a 100644 --- a/website/scripts/hosted-bundles.test.js +++ b/website/scripts/hosted-bundles.test.js @@ -48,10 +48,10 @@ describe('Hosted bundle disclosure', () => { it('bundles every Worker the deploy configures and the account page', () => { expect(hostedBundleEntries(repoRoot)).toEqual([ - { entry: 'server/connect-worker.ts', platform: 'worker' }, { 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/src/pages/Privacy.tsx b/website/src/pages/Privacy.tsx index f7306a357..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, the one-time connection page at connect.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 e14155819..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", "connect.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 3cde23595..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 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. connect.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.", + "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: [ { From 7d5e1ed840a409706aadb1626f65ddff1d27438f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 2 Nov 2026 23:57:14 -0800 Subject: [PATCH 11/11] Ratchet the Hosted spec's budget after merging main Co-Authored-By: Claude Opus 5.5 --- scripts/spec-word-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 52e3587e7..3c0185826 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "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,