From 81e77a0ae6537a5cb644bbf0e179648de51af473 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 22:21:54 -0700 Subject: [PATCH 1/7] Split Hosted into account, relay, and voice Workers hosted.dormouse.sh keeps the account and its login cookie; relay.dormouse.sh serves the one-time rendezvous and /connect/; voice.dormouse.sh serves speak and the ElevenLabs sweep. Each Worker has its own 421 gate, bindings mapper, header policy, previews, and production deploy. Co-Authored-By: Claude Opus 5.5 --- .github/audit/application-security.md | 2 +- .github/audit/hosted.md | 48 ++-- .github/workflows/hosted-preview.yml | 17 +- .github/workflows/hosted-production.yml | 15 +- .gitignore | 1 + AGENTS.md | 2 +- docs/specs/hosted.md | 47 ++-- docs/specs/hosted.rationale.md | 12 +- docs/specs/one-time.md | 46 ++-- docs/specs/security-hosted.md | 26 ++- dormouse.yml | 13 +- hosted/README.md | 128 +++++++---- hosted/package.json | 6 +- hosted/scripts/changed.test.mjs | 4 + hosted/scripts/dev-one-time.mjs | 19 +- hosted/scripts/dev-one-time.test.mjs | 12 +- hosted/scripts/one-time-smoke.mjs | 2 +- hosted/scripts/preview-smoke.mjs | 25 +- hosted/scripts/preview.mjs | 164 ++++++++++---- hosted/scripts/preview.test.mjs | 116 +++++++--- hosted/scripts/production.mjs | 179 ++++++++++----- hosted/scripts/production.test.mjs | 212 +++++++++++------ hosted/scripts/stage-one-time.mjs | 25 +- hosted/scripts/stage-one-time.test.mjs | 40 ++-- hosted/server/account-app.ts | 51 +++++ hosted/server/bindings.ts | 87 +++++++ hosted/server/dev.ts | 19 +- hosted/server/headers.ts | 48 ++-- hosted/server/one-time.ts | 17 +- hosted/server/preview-worker.ts | 58 ++--- hosted/server/relay-worker.ts | 22 ++ hosted/server/tests/boundary.test.ts | 289 ++++++++++++++++++++++++ hosted/server/tests/bundle.ts | 21 +- hosted/server/tests/one-time-entry.ts | 2 +- hosted/server/tests/one-time.test.ts | 136 +++++------ hosted/server/tests/voice-entry.ts | 26 +++ hosted/server/tests/worker-entry.ts | 22 +- hosted/server/tests/workers.test.ts | 258 ++++++++++++--------- hosted/server/voice-app.ts | 45 ++++ hosted/server/voice-preview-worker.ts | 5 + hosted/server/voice-worker.ts | 5 + hosted/server/voice.ts | 30 ++- hosted/server/worker-app.ts | 106 +++------ hosted/server/worker.ts | 55 +---- hosted/wrangler.jsonc | 32 +-- hosted/wrangler.relay.jsonc | 63 ++++++ hosted/wrangler.voice.jsonc | 34 +++ package.json | 2 +- 48 files changed, 1778 insertions(+), 816 deletions(-) create mode 100644 hosted/server/account-app.ts create mode 100644 hosted/server/bindings.ts create mode 100644 hosted/server/relay-worker.ts create mode 100644 hosted/server/tests/boundary.test.ts create mode 100644 hosted/server/tests/voice-entry.ts create mode 100644 hosted/server/voice-app.ts create mode 100644 hosted/server/voice-preview-worker.ts create mode 100644 hosted/server/voice-worker.ts create mode 100644 hosted/wrangler.relay.jsonc create mode 100644 hosted/wrangler.voice.jsonc diff --git a/.github/audit/application-security.md b/.github/audit/application-security.md index d86438a66..ef1a19de3 100644 --- a/.github/audit/application-security.md +++ b/.github/audit/application-security.md @@ -123,7 +123,7 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: Follow `DORMOUSE_RELAY_ORIGIN` from `scripts/relay-origin.mjs` into both host bundles and the standalone webview (`standalone/vite.config.ts`), then list every request a build baked with a non-default origin could make to - `dormouse.sh` or `hosted.dormouse.sh` — the one-time half of `service.ts`, + `dormouse.sh` or any of its subdomains (`hosted.`, `relay.`, `voice.`) — the one-time half of `service.ts`, `lib/src/host/managed-voice-host.ts`, `standalone/src/updater.ts` and the updater endpoint `standalone/scripts/tauri.mjs` overlays away, and anything else that fetches. A release build that accepts `DORMOUSE_RELAY_IS_HOSTED` diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 321213664..34203430d 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -6,8 +6,9 @@ **Output file:** `audit-hosted.md` -This is a code-and-specs audit of the Hosted account application and the -one-time rendezvous it serves. You need no +This is a code-and-specs audit of Hosted's three Workers — the account +application (`hosted.dormouse.sh`), the relay that serves the one-time +rendezvous (`relay.dormouse.sh`), and managed voice (`voice.dormouse.sh`). 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 @@ -15,8 +16,9 @@ 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"), `hosted/server/`, `hosted/src/`, -`hosted/scripts/`, `hosted/wrangler.jsonc`, -`remote-lib-common/src/remote/one-time-wire.ts`, the phone page Hosted serves — +`hosted/scripts/`, `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, +`hosted/wrangler.voice.jsonc`, +`remote-lib-common/src/remote/one-time-wire.ts`, the phone page the relay 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 @@ -76,13 +78,21 @@ report the same finding twice. Be adversarial, and go past the `FAIL IF` list. Ask specifically: -- **Is the Hosted origin the only one that can drive Hosted?** Trace a request - from `hosted/server/worker.ts` through `workerApp`'s origin gate and - `secureHeaders`: a foreign `Host`, a preview hostname, a misconfigured - deployment's error path, and the SPA fallback must each answer without - credentialed CORS, without a cacheable shell, and without inline script. - Check that authentication cookies stay `__Host-`, Secure, HttpOnly, `Path=/` - and Domain-less, and that no session token reaches browser JSON or storage. +- **Is each Worker's origin the only one that can drive it?** Trace a request + from `hosted/server/worker.ts`, `hosted/server/relay-worker.ts`, and + `hosted/server/voice-worker.ts` through `workerApp`'s origin gate and + `secureHeaders`: a foreign `Host`, a sibling Worker's origin, a preview + hostname, a misconfigured deployment's error path, and the account's SPA + fallback must each answer without credentialed CORS, without a cacheable + shell, and without inline script. The siblings are same-site, so the login + cookie rides their requests to the account: every account cookie route must + refuse their `Origin`. Check that authentication cookies stay `__Host-`, + Secure, HttpOnly, `Path=/` and Domain-less, and that no session token + reaches browser JSON or storage. +- **Does a secret reach a Worker that has no use for it?** Read each mapper in + `hosted/server/bindings.ts` and each Wrangler config: the relay and voice + Workers must hold and pass no auth secret and never import Better Auth, the + relay no Hyperdrive, the account no ElevenLabs key. - **Can a Hosted login become terminal access, or an account become someone else's?** `authPolicy` must keep explicit linking and independent logins; a callback whose initiating login was revoked must fail; an unused or unknown @@ -95,13 +105,15 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: forwarded. Look for a second phone admitted across an await or a hibernation, a room that outlives its alarm, a web page that can mint a room, a join from another origin, a room id a caller can choose, and a limit a caller can step - around. Account cookies ride the phone's upgrade to this same origin: no - one-time route or the room may read them or reach auth. -- **Does anything from the test or preview build reach production?** The - production Worker must not export the captured-email inbox, the deterministic - clock, or the testing injection module; preview must not copy production - routes, bindings, or credentials, must not call real mail or OAuth, and its - cleanup must check out the base branch rather than the closed PR's. + around. The relay is same-site with the account, so account cookies can + ride the phone's upgrade: no one-time route or the room may read them or + reach auth. +- **Does anything from the test or preview build reach production?** No + production Worker may export the captured-email inbox, the deterministic + clock, or the testing injection module; previews must not copy production + routes, bindings, triggers, or credentials, must not call real mail, OAuth, + or ElevenLabs, and their cleanup must check out the base branch rather than + the closed PR's. Does the shipped code still match what the spec and this section claim? Spec drift is a finding; say which side is wrong. diff --git a/.github/workflows/hosted-preview.yml b/.github/workflows/hosted-preview.yml index 4eb60472c..e824efaa3 100644 --- a/.github/workflows/hosted-preview.yml +++ b/.github/workflows/hosted-preview.yml @@ -62,10 +62,13 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test:hosted - run: pnpm build:hosted + # The account's assets and the relay's; the artifact keeps both folder names. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: preview-assets - path: hosted/dist + path: | + hosted/dist + hosted/dist-relay retention-days: 3 - if: vars.HOSTED_PREVIEWS_ENABLED != 'true' run: echo '::notice::Cloud previews are not configured yet. Follow hosted/README.md -> Provision PR previews, set HOSTED_PREVIEWS_ENABLED=true, then rerun this workflow.' @@ -102,7 +105,7 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: preview-assets - path: hosted/dist + path: hosted - name: Check preview settings before creating resources run: | node --input-type=module <<'JS' @@ -132,17 +135,19 @@ jobs: run: pnpm --filter dormouse-hosted db:migrate --preview && pnpm --filter dormouse-hosted db:validate --preview env: DATABASE_URL: ${{ steps.database.outputs.db_url }} - - name: Deploy Worker and Hyperdrive + - name: Deploy the account, relay, and voice Workers and Hyperdrive id: deploy run: pnpm --filter dormouse-hosted preview:deploy env: DATABASE_URL: ${{ steps.database.outputs.db_url }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} PREVIEW_AUTH_SECRET: ${{ secrets.PREVIEW_AUTH_SECRET }} - - name: Check deployed revision, database, cookies and routes - run: pnpm --filter dormouse-hosted preview:smoke "$PREVIEW_ORIGIN" "$BUILD_SHA" + - name: Check deployed revisions, database, cookies, routes and the rendezvous + run: pnpm --filter dormouse-hosted preview:smoke "$PREVIEW_ORIGIN" "$RELAY_ORIGIN" "$VOICE_ORIGIN" "$BUILD_SHA" env: PREVIEW_ORIGIN: ${{ steps.deploy.outputs.url }} + RELAY_ORIGIN: ${{ steps.deploy.outputs.relay-url }} + VOICE_ORIGIN: ${{ steps.deploy.outputs.voice-url }} cleanup: if: >- @@ -162,7 +167,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json - - name: Remove this PR's Worker, Hyperdrive and Neon branch + - name: Remove this PR's Workers, Hyperdrive and Neon branch run: node hosted/scripts/preview.mjs cleanup env: PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/hosted-production.yml b/.github/workflows/hosted-production.yml index 477f1b671..d31d7f5dc 100644 --- a/.github/workflows/hosted-production.yml +++ b/.github/workflows/hosted-production.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: inputs: promote: - description: Deploy to hosted.dormouse.sh after verification + description: Deploy the account, relay, and voice Workers after verification type: boolean default: false permissions: @@ -30,10 +30,13 @@ jobs: - run: pnpm build:hosted - name: Require accepted package provenance run: node --input-type=module -e 'import { verifyPackages } from "./hosted/scripts/production.mjs"; await verifyPackages();' + # The account's assets and the relay's; the artifact keeps both folder names. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: hosted-production-assets - path: hosted/dist + path: | + hosted/dist + hosted/dist-relay if-no-files-found: error retention-days: 3 deploy: @@ -64,8 +67,8 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: hosted-production-assets - path: hosted/dist - - name: Validate production identity, uncached Hyperdrive and Worker secrets + path: hosted + - name: Validate production identities, uncached Hyperdrive and each Worker's secrets run: node hosted/scripts/production.mjs preflight env: DATABASE_URL: ${{ secrets.DATABASE_URL }} @@ -87,12 +90,12 @@ jobs: run: pnpm --filter dormouse-hosted db:migrate && pnpm --filter dormouse-hosted db:validate env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - - name: Deploy verified build + - name: Deploy verified build to the account, relay, and voice Workers run: node hosted/scripts/production.mjs deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - - name: Verify live production revision and auth boundary + - name: Verify live production revisions, auth boundary and rendezvous id: live run: | node hosted/scripts/production.mjs smoke diff --git a/.gitignore b/.gitignore index a3a861408..2cf487d49 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ hosted/.dev.vars* hosted/.wrangler/ hosted/.pgstencil/ hosted/dist-worker/ +hosted/dist-relay/ # Storybook / Chromatic / Argos storybook-static/ diff --git a/AGENTS.md b/AGENTS.md index d1e5cafb9..998d4d6df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ The Tool shows the harness in its own pane and prints the command to drive it - **`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/`** — Separate Hosted account frontend and Hono Worker; packed pgstencil Better Auth, Postgres, and provider configuration. +- **`hosted/`** — Hosted's three Hono Workers: the account and packed pgstencil Better Auth (`hosted.dormouse.sh`), the one-time rendezvous and phone page (`relay.`), managed 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 acc0d6fc3..1cab29c78 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -1,13 +1,26 @@ # Dormouse Hosted accounts > See `docs/specs/glossary.md` for Burrow, Client, Relay, and Session vocabulary. -> Owns the Hosted account application and the Worker's deployment. The one-time rendezvous it also serves belongs to `docs/specs/one-time.md` -> "Hosted rendezvous"; remote authorization to `docs/specs/remote-security-model.md`; the multi-tenant Relay remains in `docs/specs/relay.md` -> Future. +> Owns the Hosted account application and the deployment of Hosted's three Workers. The one-time rendezvous the relay Worker serves belongs to `docs/specs/one-time.md` -> "Hosted rendezvous"; remote authorization to `docs/specs/remote-security-model.md`; the multi-tenant Relay remains in `docs/specs/relay.md` -> Future. ## Application boundary -**Must serve the account frontend and its API from `https://hosted.dormouse.sh`.** The Hono Worker serves Vite assets and pgstencil's request-scoped Better Auth adapter. Requests addressed to another origin receive 421. Marketing remains a separate bundle and deployment; no marketing component is imported. +**Must serve Hosted as three Workers from `hosted/`, one origin each:** -The one-time rendezvous routes and `/connect/` page mount in this app (`docs/specs/one-time.md` -> "Hosted rendezvous", "Phone page"). Both bindings mappers pass its Durable Object and rate-limit bindings; `remote-lib-common` is compiled in from source through `hosted/tsconfig.json` `paths`, which every esbuild bundle and Wrangler honor. +| Worker | Origin | Serves | Holds | +|---|---|---|---| +| `dormouse-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, `/api/ready`, voice tokens | the login cookie, auth secrets, Hyperdrive | +| `dormouse-relay` | `https://relay.dormouse.sh` | the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | `OneTimeRoom`, the one-time rate limits | +| `dormouse-voice` | `https://voice.dormouse.sh` | speak and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | + +Every Worker answers `/api/health`, 404s any other `/api/*`, `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. Only the account falls back to SPA assets. + +- **Must answer 421, before any route, to a request whose URL origin is not the Worker's `APP_ORIGIN`**, a sibling's included. +- **Must hand routes only the bindings the Worker's mapper names**: no auth secret on the relay or voice, no Hyperdrive on the relay, no `ELEVENLABS_API_KEY` on the account. +- **Never let the relay or voice Worker reach auth or the login cookie's origin**: Pocket and `/connect/` render untrusted terminal output (rationale). +- **Must refuse, on every account cookie route, any `Origin` but the account's exactly**: sibling origins are same-site, so a browser sends them the `SameSite=Lax` login cookie. + +Marketing is a separate bundle and deployment. `remote-lib-common` is compiled in from source through `hosted/tsconfig.json` `paths`, which every esbuild bundle and Wrangler honor. **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. The runtime creates and closes its database pool within each request. @@ -15,7 +28,7 @@ The one-time rendezvous routes and `/connect/` page mount in this app (`docs/spe **Must declare every peer dependency of the installed packages in `hosted/package.json`**, so they share Hosted's copy and Renovate updates them. -Source of truth: `auth` in `hosted/server/worker.ts`; `workerApp` in `hosted/server/worker-app.ts`; `migrations` in `hosted/server/migrations.ts`; `verifyPackages` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/artifacts.test.ts`. +Source of truth: `workerApp` in `hosted/server/worker-app.ts`; `accountApp` in `hosted/server/account-app.ts`; `hosted/server/relay-worker.ts`; `voiceApp` in `hosted/server/voice-app.ts`; `hosted/server/bindings.ts`; `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`; `migrations` in `hosted/server/migrations.ts`; `verifyPackages` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts`, and `hosted/server/tests/artifacts.test.ts`. ## Identity and login @@ -47,7 +60,7 @@ Source of truth: `App` in `hosted/src/App.tsx`; `restoreTheme` in `hosted/src/ma ## Managed voice -An admin-only test slice: Dormouse desktop exchanges a pasted voice token for ElevenLabs speech. +An admin-only test slice: Dormouse desktop exchanges a pasted voice token for ElevenLabs speech. The account Worker serves the token routes; the voice Worker serves speak. | Route | Credential | Success | |---|---|---| @@ -71,50 +84,50 @@ Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 5. 429 once the owner's UTC-day counter reaches 500. The increment is atomic and precedes the upstream call, so failed upstream attempts count. 6. 502 when ElevenLabs throws or answers non-2xx. -**Never log the text or forward an upstream body or status.** The upstream URL, model `eleven_flash_v2_5`, and format `mp3_44100_128` are fixed in code; no binding or request field redirects them. `ELEVENLABS_API_KEY` is a Worker secret that production preflight requires; the preview mapper never passes it. Only the local development entry substitutes silent MP3 when the key is unset. Tests fake ElevenLabs in Miniflare's outbound service, so no entry carries an upstream override. +**Never log the text or forward an upstream body or status.** The upstream URL, model `eleven_flash_v2_5`, and format `mp3_44100_128` are fixed in code; no binding or request field redirects them. `ELEVENLABS_API_KEY` is the voice Worker's secret, which production preflight requires there; the voice preview mapper never passes it. Only the local development entry substitutes silent MP3 when the key is unset. Tests fake ElevenLabs in Miniflare's outbound service, so no entry carries an upstream override. -**Must delete ElevenLabs speech history, which keeps each generation's text, from the production Worker only.** A successful speak schedules one sweep about 10 s later in `waitUntil`; a Cron Trigger every 5 minutes sweeps what that missed. No retention bound is guaranteed (rationale). +**Must delete ElevenLabs speech history, which keeps each generation's text, from the production voice Worker only.** A successful speak schedules one sweep about 10 s later in `waitUntil`; a Cron Trigger every 5 minutes sweeps what that missed. No retention bound is guaranteed (rationale). - **Must use an ElevenLabs account dedicated to Dormouse voice.** A sweep deletes the whole account's history. -- **Never touch the database or any binding but `ELEVENLABS_API_KEY` in a sweep**, so an idle deployment lets Postgres suspend. Without the key nothing runs; development and previews never sweep, and the preview config drops `triggers`. +- **Never touch the database or any binding but `ELEVENLABS_API_KEY` in a sweep**, so an idle deployment lets Postgres suspend. Without the key nothing runs; development and previews never sweep, and the preview configs drop `triggers`. - **Must bound each pass**; a backlog waits for the next pass. At most six deletes are in flight; a 404 counts as deleted; a failed delete never aborts the pass. Only counts and statuses are logged or thrown. - **Must fail the cron invocation when its pass cannot list or any delete fails; the after-speech pass only logs** (rationale). -Source of truth: `isAdmin` in `hosted/server/admin.ts`; `voiceRoutes` / `elevenLabs` / `sweepOnCron` / `sweepAfterSpeech` in `hosted/server/voice.ts`; `scheduled` in `hosted/server/worker-app.ts`; `triggers` in `hosted/wrangler.jsonc`; `hosted/server/dormouse-migrations/001_voice_tokens.sql`; `preflight` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/workers.test.ts`, `hosted/scripts/production.test.mjs`, and `hosted/scripts/preview.test.mjs`. +Source of truth: `isAdmin` in `hosted/server/admin.ts`; `voiceTokenRoutes` / `speakRoute` / `elevenLabs` / `sweepOnCron` / `sweepAfterSpeech` in `hosted/server/voice.ts`; `scheduled` in `hosted/server/voice-app.ts`; `triggers` in `hosted/wrangler.voice.jsonc`; `hosted/server/dormouse-migrations/001_voice_tokens.sql`; `preflight` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/production.test.mjs`, and `hosted/scripts/preview.test.mjs`. ## Development and release -**Must run local development with `dor tool hosted` inside Dormouse.** A single `http://localhost:` origin, bound to loopback on an OS-assigned port unless `PORT` pins one, serves Vite and Node auth, with a disposable development database. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; the production entry imports no inbox or test-control handler. `dor tool one-time` runs the rendezvous and phone page on loopback, without a database (`docs/specs/one-time.md` -> "Dev loop"). +**Must run local development with `dor tool hosted` inside Dormouse.** A single `http://localhost:` origin, bound to loopback on an OS-assigned port unless `PORT` pins one, serves Vite and Node auth, with a disposable development database, and mounts speak beside the token routes. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback, without a database (`docs/specs/one-time.md` -> "Dev loop"). -**Must verify the production Worker bundle and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:one-time`, the rendezvous's Miniflare suite, which needs no Docker; the rest of `pnpm test:hosted`'s vitest half needs Docker and is skipped there. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. +**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free Miniflare suites (the rendezvous, and the three Workers' boundary); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts` needing Docker. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. **Must keep production, test, and preview databases and credentials separate.** The development and preview entries are email-only. Production configuration and operator steps live in `hosted/README.md`. -Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `hosted/server/dev.ts`; `hosted/server/tests/workers.test.ts`; `hosted/wrangler.jsonc`. +Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `hosted/server/dev.ts`; `hosted/server/tests/workers.test.ts`; `build` in `hosted/package.json`. ## PR previews **Must deploy only verified same-repository PR merge revisions touching Hosted or its shared build inputs.** Drafts qualify; forks receive no deployment credentials. Changed paths include rename sources and all API pages. Deployment runs serialize per PR without cancellation; close/merge cleanup ignores path filtering and tolerates absent resources. -**Must isolate each PR in a persistent Worker, uncached Hyperdrive, and Neon branch from an empty dedicated preview project.** Reuse `dormouse-hosted-pr-N` until close. The preview config excludes production routes and credentials; runtime bindings cannot enable OAuth or Postmark. **Must give each preview its own Durable Object namespace and preview-only rate-limit namespaces**, and delete its Worker with `force` so the namespace goes with it. +**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive the account and voice share, and a Neon branch from an empty dedicated preview project**, all reused until close. 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 namespace and preview-only rate-limit namespaces**, and delete every preview Worker with `force`. The smoke checks each Worker's revision and runs `oneTimeSmoke` on the relay. **Must run cleanup from the base branch's checkout, never the closed PR's.** **Must capture preview mail in Postgres and expose escaped text only.** The public inbox shows the newest 100 messages from the last 24 hours, prunes expired rows on capture, and accepts only the preview's configured origin. No test clock is deployed. Preview data is disposable; it is not access-controlled. -Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workflows/hosted-preview.yml`; `previewConfig` / `cleanup` in `hosted/scripts/preview.mjs`; `postgresInbox` in `hosted/server/preview-inbox.ts`; `hosted/server/preview-worker.ts`. Pinned by `hosted/scripts/preview.test.mjs`, `hosted/scripts/changed.test.mjs`, and `hosted/server/tests/workers.test.ts`. +Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workflows/hosted-preview.yml`; `previewConfigs` / `cleanup` in `hosted/scripts/preview.mjs`; `postgresInbox` in `hosted/server/preview-inbox.ts`; `hosted/server/preview-worker.ts`; `hosted/server/voice-preview-worker.ts`. Pinned by `hosted/scripts/preview.test.mjs`, `hosted/scripts/changed.test.mjs`, and `hosted/server/tests/workers.test.ts`. ## Production releases -**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and required Worker secret names. Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Production has no public candidate URL. +**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's name, entry, origin, and lone custom domain; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`; the relay has none). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy account, relay, then voice, stopping at a failure. 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 the deploy that adds a class is a rollback floor. A deploy restarts every room, dropping links still waiting or mid-handshake; a session already on its direct path never touches Hosted. Live verification runs `oneTimeSmoke` after the account smoke. +**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. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay starts its own `v1`. **Must set `triggers.crons` to `[]` on a Worker with no schedule**: an absent `triggers` leaves a deployed one in place. A deploy restarts every room, dropping links still waiting or mid-handshake; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay after the account smoke. **Must bound retries.** Health GETs require the selected revision, sharing six five-second retries for transport failures or healthy stale revisions. Retry rate-limited OAuth once; never replay POSTs after transport failures. **Must record an immutable annotated hosted/YYYY-MM-DD tag only after live verification.** Tags identify the deployed commit and verification run/attempt; retries are idempotent and redeployments get new tags. Dating and repeat-deployment suffixes: `recordDeployment`. Code rollback never reverses migrations. -Source of truth: `.github/workflows/hosted-production.yml`; `verifyPackages` / `preflight` in `hosted/scripts/production.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. +Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` in `hosted/scripts/production.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. ## Future diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 69debf871..1b1d261c8 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -9,5 +9,13 @@ History sweep (sources checked 2026-09-22): - `ctx.waitUntil()` extends an HTTP invocation for at most 30 s after the response is sent (https://developers.cloudflare.com/workers/runtime-apis/context/), so a 10 s wait plus one short pass has margin. Every 5 minutes is the backstop because the after-speech pass handles the common case. - Workers limits (https://developers.cloudflare.com/workers/platform/limits/, checked 2026-09-22): 50 subrequests per invocation on Free, and six connections may await response headers at once. The per-pass caps fit the Free limit, so the sweep does not depend on the account's plan; a test pins the arithmetic. - Endpoints: https://elevenlabs.io/docs/api-reference/history/list and https://elevenlabs.io/docs/api-reference/history/delete. -- The cron resolves bindings through the same mapper as a request, so a broken OAuth allowlist in the production mapper also fails the cron run loudly instead of sweeping silently. -- A failed cron pass fails its invocation because the sweep is a privacy control that nothing else watches. A key scoped to text-to-speech alone, or rotated later with that narrower scope, keeps speech working while every list is refused, and spoken text would pile up in ElevenLabs history unseen. `hosted/wrangler.jsonc` disables observability and `productionConfig` in `hosted/scripts/production.mjs` keeps it off (checked 2026-09-29), so production retains no logs; a failed invocation in the Worker's Cron Events is the signal. A failed delete fails the invocation only after the others are attempted, so one stuck item never holds back the rest. The after-speech pass runs in `waitUntil` on a request that already succeeded, and the next cron pass retries what it missed. +- The cron resolves bindings through the same mapper as a request, so a key the voice mapper drops is a key the sweep never sees, the same as for speak. +- A failed cron pass fails its invocation because the sweep is a privacy control that nothing else watches. A key scoped to text-to-speech alone, or rotated later with that narrower scope, keeps speech working while every list is refused, and spoken text would pile up in ElevenLabs history unseen. `hosted/wrangler.voice.jsonc` disables observability and `productionConfig` in `hosted/scripts/production.mjs` requires it off (checked 2026-09-30), so production retains no logs; a failed invocation in the Worker's Cron Events is the signal. A failed delete fails the invocation only after the others are attempted, so one stuck item never holds back the rest. The after-speech pass runs in `waitUntil` on a request that already succeeded, and the next cron pass retries what it missed. + +## Application boundary + +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. +- 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. +- No released desktop build bakes `hosted.dormouse.sh` (v1.1.0, the last release, predates one-time and managed voice), so the routes moved off it with no compatibility shim. diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index d6a64901d..98f595e35 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -245,8 +245,9 @@ Source of truth: `OneTimeClient` in `lib/src/remote/client/one-time-client.ts`; ## Hosted rendezvous -Hosted's Worker serves both routes, and one `OneTimeRoom` Durable Object per -room carries the frames. **Never parse, store, or log a forwarded frame**: the +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). @@ -255,14 +256,14 @@ string verbatim (rationale). | `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` | -- **Must mount after the bindings mapper and the 421 gate, before the account - API**, and never read a cookie, reach Hyperdrive, or call auth. The Worker +- **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.jsonc` sets 10 mints and 30 joins + one as `local` (rationale); `hosted/wrangler.relay.jsonc` sets 10 mints and 30 joins per 60 seconds. - **The room's state is the Burrow socket's hibernation attachment** — `expiresAt`, `joined`, and a count of every frame received — never memory, so @@ -293,12 +294,12 @@ holds nothing. Source of truth: `oneTimeRoutes` / `rateLimitKey` in `hosted/server/one-time.ts`; `OneTimeRoom` in `hosted/server/one-time-room.ts`; -`hosted/wrangler.jsonc`. Pinned by `hosted/server/tests/one-time.test.ts`. +`hosted/wrangler.relay.jsonc`. Pinned by `hosted/server/tests/one-time.test.ts`. ## Phone page -Hosted serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): `OneTimeApp` -on Pocket's screens, chrome, and mobile wall. +The relay Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): +`OneTimeApp` on Pocket's screens, chrome, and mobile wall. | Screen | When | | --- | --- | @@ -315,8 +316,8 @@ on Pocket's screens, chrome, and mobile wall. - **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". -- **Never persist anything** on an origin Hosted's accounts share: no storage, - IndexedDB, worker, push, or cookie. `applyPocketTheme` applies Pocket's +- **Never persist anything** on the relay origin: no storage, IndexedDB, + worker, push, or cookie. `applyPocketTheme` applies Pocket's default theme without reading or writing a stored pick, for the page and its `PocketWall`. `scripts/e2e-lint.mjs` holds the page to the client's store rule. - **Mounts the wall only on `ok`**, through `mountRemoteWall`. End, Cancel, a @@ -333,14 +334,15 @@ on Pocket's screens, chrome, and mobile wall. no module-preload polyfill) into `lib/dist-one-time`, then runs `assertPocketShell --one-time`: scripts under `/connect/assets/`, links under `/connect/`, nothing inline. Hosted's `build` runs it first, and -`hosted/scripts/stage-one-time.mjs`, after Hosted's Vite build, copies it to -`dist/connect/` and checks the copy. +`hosted/scripts/stage-one-time.mjs` empties the relay's assets directory, +`dist-relay/`, copies it to `dist-relay/connect/`, and checks the copy. -**Serving.** The Worker answers `/connect`, `/connect/`, and -`/connect/assets/*` from its assets ahead of the SPA fallback; an HTML answer -under `/connect/assets/` and any other `/connect/*` path is a 404. A hashed -file there is cached as `/assets/` is. Everything under `/connect` carries this -policy, from `APP_ORIGIN` (``; `` with `http` replaced by `ws`): +**Serving.** The relay Worker answers `/connect`, `/connect/`, and +`/connect/assets/*` from its assets, which have no SPA fallback; an HTML answer +under `/connect/assets/` and any other path, under `/connect/` or not, is a 404. +A hashed file there is cached immutably. Everything under `/connect` carries +this policy, from the relay's `APP_ORIGIN` (``; `` with `http` +replaced by `ws`); every other relay response carries `RUNS_NOTHING_POLICY`: ``` default-src 'none'; script-src /connect/assets/ 'wasm-unsafe-eval'; @@ -351,7 +353,7 @@ object-src 'none'; sandbox allow-scripts allow-same-origin ``` - **An `APP_ORIGIN` that is not exactly `scheme://host[:port]` of plain host - characters gets the origin-wide policy instead**: the URL parser admits `;`, + 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). @@ -363,7 +365,7 @@ Source of truth: `OneTimeApp` and `oneTimeDeviceLabel` in `lib/src/remote/pocket-app/pocket-theme.ts`; `assertPocketShell` in `lib/scripts/assert-pocket-worker.mjs`; `stageOneTime` in `hosted/scripts/stage-one-time.mjs`; `oneTimePageRoutes` in -`hosted/server/one-time.ts`; `contentSecurityPolicy` in +`hosted/server/one-time.ts`; `relayPolicy` in `hosted/server/headers.ts`. Pinned by `lib/src/remote/one-time-app/OneTimeApp.test.tsx`, `lib/src/remote/pocket-app/assert-pocket-worker.test.ts`, @@ -374,11 +376,11 @@ Source of truth: `OneTimeApp` and `oneTimeDeviceLabel` in ## Dev loop **`dor tool one-time` (root `pnpm dev:one-time`) runs the rendezvous and page -on loopback, without Postgres**: the production entry under `wrangler dev +on loopback, without Postgres**: the relay Worker's entry under `wrangler dev --local`, bound to `127.0.0.1` on `PORT` or 8787 — fixed, since a Burrow build bakes the origin in. `devConfig` sets `APP_ORIGIN` to `http://localhost:`, -the host a Dor Tool frames, copies the Durable Object, migration, and rate -limits, and carries no route, Hyperdrive, or secret; `vite build --watch` +the host a Dor Tool frames, copies the relay config's Durable Object, +migration, and rate limits, and carries no route or secret; `vite build --watch` rebuilds the page into the folder Wrangler serves. Once the page answers, the loop prints the origin and the dev Burrow build variables (`DORMOUSE_RELAY_ORIGIN`, `DORMOUSE_RELAY_IS_HOSTED=1`; `docs/specs/relay.md` diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index ef8d636f1..870aea68f 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -1,17 +1,19 @@ # Hosted account security > See `docs/specs/glossary.md` for Burrow, Client, and Relay vocabulary. -> Owns the account application's security checks. Defers identity behavior to `docs/specs/hosted.md` and terminal access to `docs/specs/remote-security-model.md`. +> Owns the security checks of Hosted's three Workers, account, relay, and voice. 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; provisioning and real-provider acceptance are pending. ## Origin boundary -- **FAIL IF** Hosted accepts a request URL outside configured `APP_ORIGIN`, grants marketing-origin credentialed CORS, or permits a state-changing auth request without exact Origin and CSRF checks; inspect `hosted/server/worker-app.ts` and the packed adapter. +- **FAIL IF** a Worker routes a request whose URL origin is not its own `APP_ORIGIN`, a sibling's included, rather than answering 421; inspect `workerApp` in `hosted/server/worker-app.ts`. +- **FAIL IF** a cookie route admits any `Origin` but its own exactly, sibling origins under `dormouse.sh` included — they are same-site, so the browser sends them the `SameSite=Lax` login cookie — or a state-changing auth request skips the CSRF check, or any Worker grants credentialed CORS; inspect `voiceTokenRoutes` in `hosted/server/voice.ts` and the packed adapter. +- **FAIL IF** the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the relay's passes Hyperdrive, the account's passes `ELEVENLABS_API_KEY`, or the relay or voice entry imports Better Auth; inspect `hosted/server/bindings.ts` and each entry's import graph. - **FAIL IF** authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. -- **FAIL IF** the production HTML permits third-party scripts, framing, inline script execution, or any worker (`worker-src 'none'` origin-wide), any response but a 101 WebSocket upgrade bypasses `secureHeaders` including a misconfigured deployment's error, or anything but a content-hashed file under `/assets/` or `/connect/assets/` is cacheable, the SPA fallback's shell included; inspect `secureHeaders` in `hosted/server/headers.ts`, binding resolution in `hosted/server/worker-app.ts`, and asset routing in `hosted/wrangler.jsonc`. +- **FAIL IF** 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 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 anything but a content-hashed file under the account's `/assets/` or the relay's `/connect/assets/` is cacheable, the SPA fallback's shell included. Inspect `secureHeaders` 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** 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/workers.test.ts`. +Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts`, and `hosted/server/tests/one-time.test.ts`. ## Account boundary @@ -24,18 +26,18 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. ## Rendezvous boundary -**The one-time room is a counter with two sockets**: `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** 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. - **FAIL IF** `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** 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** 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** 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** the Burrow route admits a request carrying any `Origin` header, or the client route an `Origin` other than exactly `APP_ORIGIN`; inspect `oneTimeRoutes` in `hosted/server/one-time.ts`. +- **FAIL IF** 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** 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** 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** 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** the `/connect/` page's policy admits a source outside `APP_ORIGIN`'s `/connect/`, a script outside `/connect/assets/`, or a connection but the client route; permits inline or off-origin script, framing, forms, or popups; or takes an `APP_ORIGIN` that is not exactly an origin. Inspect `contentSecurityPolicy` in `hosted/server/headers.ts`. -- **FAIL IF** a path under `/connect/` is served but the page and its hashed assets, a missing asset gets the SPA shell, or a shell failing `assertPocketShell`'s one-time mode can ship; `build:one-time` in `lib/package.json` and `stageOneTime` in `hosted/scripts/stage-one-time.mjs` each run it. Inspect `oneTimePageRoutes` in `hosted/server/one-time.ts`. +- **FAIL IF** the `/connect/` page's policy admits a source 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, framing, forms, or popups; or takes an `APP_ORIGIN` that is not exactly an origin. Inspect `relayPolicy` in `hosted/server/headers.ts`. +- **FAIL IF** the relay serves any path but its routes, the page, and the page's hashed assets, a missing asset gets HTML, the relay's 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 `stageOneTime` in `hosted/scripts/stage-one-time.mjs` each run it. Inspect `oneTimePageRoutes` in `hosted/server/one-time.ts` and `hosted/server/relay-worker.ts`. `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-one-time.test.mjs`, `lib/src/remote/pocket-app/assert-pocket-worker.test.ts`, and `hosted/scripts/production.test.mjs`. @@ -43,16 +45,16 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. **Must depend on a released pgstencil** whose installed `dist/provenance.json` names a commit on pgstencil `main` with a passing `security-audit`. The core and auth packages must name the same clean commit. -- **FAIL IF** a production Worker exposes the captured-email inbox or deterministic clock controls, or imports the testing injection module; inspect `hosted/server/worker.ts`, the build configuration, and `hosted/server/tests/worker-entry.ts`. +- **FAIL IF** a production Worker exposes the captured-email inbox or deterministic clock controls, or imports the testing injection module; inspect `hosted/server/worker.ts`, `hosted/server/relay-worker.ts`, `hosted/server/voice-worker.ts`, the build configuration, and `hosted/server/tests/worker-entry.ts`. - **FAIL IF** either installed pgstencil package lacks `dist/provenance.json`, records `dirty`, or names a different commit; `pnpm-lock.yaml` resolves either package from outside npm; or a runtime import depends on a sibling pgstencil checkout. Inspect `verifyPackages` in `hosted/scripts/production.mjs`, `hosted/server/tests/artifacts.test.ts`, and Hosted runtime imports. - **FAIL IF** either installed package lacks a verified npm SLSA provenance attestation whose Fulcio certificate SAN names `diffplug/pgstencil` `.github/workflows/release.yml` on `refs/heads/main`, whose source-repository digest (OID `1.3.6.1.4.1.57264.1.13`) equals `dist/provenance.json`'s commit, or whose signed subject/payload disagrees with the installed package, certificate, or commit. - **FAIL IF** that commit is not on pgstencil `main` (`gh api repos/diffplug/pgstencil/compare/...main`, status `ahead` or `identical`), or its `security-audit` check runs (`gh api repos/diffplug/pgstencil/commits//check-runs`) include no `success`, or any conclusion other than `success` and `cancelled`. pgstencil audits the released code; Dormouse audits only how Hosted configures it. - **FAIL IF** the local email inbox accepts a foreign Host or Origin or cross-site Fetch Metadata; inspect `allowedDevRequest` in `hosted/server/dev-host-guard.ts`, including the upgrade guard in `hosted/server/dev.ts`. -- **FAIL IF** the production deploy can proceed without `preflight` establishing an uncached Hyperdrive, a matching migration/runtime database, and distinct runtime and migration roles; inspect `preflight` in `hosted/scripts/production.mjs` and its ordering ahead of the deploy step in `.github/workflows/hosted-production.yml`. -- **FAIL IF** preview mail or OAuth calls reach external providers, preview configuration copies production routes/bindings, or a preview exposes deterministic time controls; inspect `hosted/server/preview-worker.ts`, `hosted/scripts/preview.mjs`, and `hosted/server/tests/workers.test.ts`. +- **FAIL IF** the production deploy can proceed without `preflight` establishing an uncached Hyperdrive, a matching migration/runtime database, distinct runtime and migration roles, and each Worker's required secrets on that Worker's own script; inspect `preflight` in `hosted/scripts/production.mjs` and its ordering ahead of the deploy step in `.github/workflows/hosted-production.yml`. +- **FAIL IF** preview mail, OAuth, or ElevenLabs calls reach external providers, a preview configuration copies production routes, bindings, or triggers, or a preview exposes deterministic time controls; inspect `hosted/server/preview-worker.ts`, `hosted/server/voice-preview-worker.ts`, `hosted/scripts/preview.mjs`, and `hosted/server/tests/workers.test.ts`. -Pinned by `hosted/server/tests/artifacts.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, `hosted/scripts/production.test.mjs`. +Pinned by `hosted/server/tests/artifacts.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, `hosted/scripts/production.test.mjs`, `hosted/scripts/preview.test.mjs`. ## Future diff --git a/dormouse.yml b/dormouse.yml index b8fa53427..86bbe21fc 100644 --- a/dormouse.yml +++ b/dormouse.yml @@ -54,8 +54,9 @@ tools: port: auto prespawn_dedupe: [relay, $PROJECT_ROOT] - # The Hosted accounts frontend with email sign-in; read the codes at - # /api/dev/emails on the printed origin. Needs Docker running. + # The Hosted accounts frontend with email sign-in, plus speak beside its + # voice-token routes; read the codes at /api/dev/emails on the printed + # origin. Needs Docker running. hosted: run: pnpm dev:hosted # Email sign-in sets a session cookie, which an iframe drops. @@ -65,10 +66,10 @@ tools: port: auto prespawn_dedupe: [hosted, $PROJECT_ROOT] - # The one-time rendezvous and phone page on loopback, for trying a one-time - # connection end to end: build a Burrow with the variables it prints, then - # open a link from its Settings. Port 8787 (or PORT), fixed because that - # Burrow build bakes the origin in, so one checkout at a time. + # The relay Worker's one-time rendezvous and phone page on loopback, for + # trying a one-time connection end to end: build a Burrow with the variables + # it prints, then open a link from its Settings. Port 8787 (or PORT), fixed + # because that Burrow build bakes the origin in, so one checkout at a time. one-time: run: pnpm dev:one-time # A real browser, so an agent can drive the phone page at a phone viewport. diff --git a/hosted/README.md b/hosted/README.md index 649e7a019..be82f11f8 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -1,11 +1,14 @@ # Dormouse Hosted -Account frontend and Hono/Cloudflare Worker for `https://hosted.dormouse.sh`, -which also serves the one-time connection's rendezvous and its `/connect/` -phone page ([its spec](../docs/specs/one-time.md)). The marketing website is a separate -application. Managed voice exists only as an admin-only test slice; the managed -Relay is not implemented. See -[the spec](../docs/specs/hosted.md). +Three Hono/Cloudflare Workers built from this package: the account frontend +and auth at `https://hosted.dormouse.sh` (`dormouse-hosted`), the one-time +connection's rendezvous and `/connect/` phone page at +`https://relay.dormouse.sh` (`dormouse-relay`; [its 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 exists only as +an admin-only test slice; the managed Relay is not implemented. 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 @@ -27,22 +30,25 @@ URL it prints. Request a code for a test address and read it at `/api/dev/emails` on that same origin. No real mail is sent, and the development database is isolated by the worktree path; `docs/specs/hosted.md` -> "Development and release" owns what the local entry serves and what production omits. The port is OS-assigned -unless you set `PORT`. Do not share this local inbox publicly. Set -`ELEVENLABS_API_KEY` in the environment of `pnpm dev:hosted` to hear real -speech; see `docs/specs/hosted.md` -> "Managed voice". +unless you set `PORT`. Do not share this local inbox publicly. The local +origin serves speak beside the token routes, which production splits across +the account and voice Workers; set `ELEVENLABS_API_KEY` in the environment of +`pnpm dev:hosted` to hear real speech (`docs/specs/hosted.md` -> "Managed +voice"). ```sh pnpm test:hosted pnpm build:hosted ``` -Tests run the production composition in real workerd with disposable Postgres -clones and a local OAuth simulator. `pnpm --filter dormouse-hosted test:one-time` -runs the rendezvous suite alone, without Docker. The build includes a Wrangler -dry-run; it does not deploy. +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 and boundary +suites alone, without Docker. The build stages `/connect/` into `dist-relay/` and dry-runs +all three Workers; it does not deploy. -The one-time rendezvous and `/connect/` page run on their own, without Docker -or Postgres: +The relay Worker — the one-time rendezvous and `/connect/` page — runs on its +own, without Docker or Postgres: ```sh dor tool one-time # outside Dormouse: pnpm dev:one-time @@ -74,8 +80,8 @@ branch. | 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` Worker with its own `OneTimeRoom` Durable Object namespace, uncached Hyperdrive, and Neon branch, all reused until close; rate-limit namespaces `1001` and `1002` shared by every preview | -| Production | Dedicated Dormouse Postgres database, separate runtime/migration roles, uncached Hyperdrive, `dormouse-hosted` Worker with its `OneTimeRoom` Durable Object namespace and rate-limit namespaces `1` and `2`, and `hosted.dormouse.sh` custom domain; GitHub `hosted-production` environment | +| Each PR | `dormouse-hosted-pr-N`, `dormouse-relay-pr-N` (with its own `OneTimeRoom` Durable Object namespace), and `dormouse-voice-pr-N` Workers, one uncached Hyperdrive the account and voice share, and a Neon branch, all reused until close; rate-limit namespaces `1001` and `1002` shared by every relay preview | +| Production | Dedicated Dormouse Postgres database, separate runtime/migration roles, uncached Hyperdrive shared by `dormouse-hosted` and `dormouse-voice`; Workers `dormouse-hosted` (`hosted.dormouse.sh`), `dormouse-relay` (`relay.dormouse.sh`, with its `OneTimeRoom` Durable Object namespace and rate-limit namespaces `1` and `2`), and `dormouse-voice` (`voice.dormouse.sh`, with the history-sweep Cron Trigger), 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, an admin identity's repository-scoped Contents-write fine-grained PAT, immutable annotated `hosted/` tags | @@ -92,7 +98,7 @@ remain in the Worker, protected GitHub environments, and Bitwarden. | Service | Dedicated registration | | --- | --- | -| Cloudflare | Account `0a95e814ccf2b6a95d2dc3bea0a4a2b4`; Worker `dormouse-hosted`; Hyperdrive ID in `wrangler.jsonc` | +| Cloudflare | Account `0a95e814ccf2b6a95d2dc3bea0a4a2b4`; Workers `dormouse-hosted`, `dormouse-relay`, `dormouse-voice`; Hyperdrive ID in `wrangler.jsonc` and `wrangler.voice.jsonc` | | Neon | Project `young-dust-56119072`; production branch `br-billowing-brook-b4cuf3y4`; database `neondb`; migration role `neondb_owner`; SQL-created runtime role `dormouse_app` | | Postmark | Server `21034461`, `dormouse-hosted`; sender `signin@dormouse.sh`; return path `pm-bounces.dormouse.sh` | | GitHub OAuth | DiffPlug organization app `3890420`; client ID `Ov23liX1HSz03AAN3Psf` | @@ -161,13 +167,15 @@ 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`. This inbox is public to anyone +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. The database stores messages across Worker restarts. New commits retain the URL and test accounts. Migrations are append-only; to change an already-applied migration, close the PR, wait for successful cleanup, -then reopen. Closing or merging deletes the Worker, Hyperdrive, and Neon branch. +then reopen. Closing or merging deletes the three Workers, the Hyperdrive, and the Neon branch. Rerun failed cleanup. Keep previews enabled until all live previews are removed. Manual cleanup uses `node hosted/scripts/preview.mjs cleanup` with the preview environment's credentials and `PR_NUMBER`. These credentials cannot be downloaded @@ -187,8 +195,8 @@ accounts. caching disabled**, using the runtime role, and keep its connection host and database identical to the direct migration URL. Enter connection credentials directly in Cloudflare; replace the zero Hyperdrive ID in `wrangler.jsonc` - with the resulting public ID for local operator deployment. CI overrides it - with the `HYPERDRIVE_ID` variable. + and `wrangler.voice.jsonc` with the resulting public ID for local operator + deployment. CI overrides both with the `HYPERDRIVE_ID` variable. 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 @@ -204,10 +212,11 @@ accounts. `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. Configure `hosted.dormouse.sh` as the Worker's custom domain. Exclude this - hostname 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`, `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. Authenticate Wrangler to the intended Cloudflare account before provisioning. Inside Dormouse, run `dor ensure -- pnpm exec wrangler login --browser=false --use-keyring` @@ -217,7 +226,8 @@ keychain. Authenticate in your own terminal; account/provider sign-in is operato ## Separate OAuth registrations -Create Dormouse registrations. Register these exact URLs with no trailing slash: +Create Dormouse registrations. Register these exact URLs with no trailing +slash; every callback stays on the account Worker's `hosted.dormouse.sh`: | Provider | Registration | Callback | | --- | --- | --- | @@ -275,8 +285,9 @@ gh secret set HOSTED_TAG_TOKEN --repo diffplug/dormouse --env hosted-release-tag identity with `age-keygen` into private password-manager storage, then enter it at the hidden prompt; preserve that independent copy and old keys on rotation. Use a production Cloudflare token covering Workers deployment (which includes -the Durable Object migration and rate-limit bindings), Hyperdrive read, and the -custom-domain zone permissions required for that hostname. +the Durable Object migrations, rate-limit bindings, and Cron Triggers), +Workers secret listing, Hyperdrive read, and the custom-domain zone +permissions for all three hostnames. The tag PAT belongs to a repository admin and selects only this repository with Contents write. It can bypass the existing tag ruleset and can also write code, @@ -284,11 +295,12 @@ so it is isolated from the deploy job in a separately approved main-only environment (`docs/specs/security-ci.md` -> "Hosted Deployments"). Record its expiry; do not grant tag bypass to the bot or Actions generally. -### Runtime secrets in the Worker +### Runtime secrets in the Workers -Auth, mail, and OAuth secrets live in the Worker, not GitHub. In your own -terminal, from `hosted/`, authenticate Wrangler to the production account and -use its hidden prompt, never a command-line value: +Auth, mail, and OAuth secrets live in the account Worker, and the ElevenLabs +key in the voice Worker, never GitHub; the relay Worker holds none. In your +own terminal, from `hosted/`, authenticate Wrangler to the production account +and use its hidden prompt, never a command-line value: ```sh pnpm exec wrangler secret put AUTH_SECRET @@ -297,9 +309,15 @@ pnpm exec wrangler secret put GITHUB_CLIENT_SECRET pnpm exec wrangler secret put GOOGLE_CLIENT_SECRET pnpm exec wrangler secret put MICROSOFT_CLIENT_SECRET pnpm exec wrangler secret put APPLE_CLIENT_SECRET -pnpm exec wrangler secret put ELEVENLABS_API_KEY +pnpm exec wrangler secret put ELEVENLABS_API_KEY --config wrangler.voice.jsonc ``` +The voice Worker's first secret creates its stub, so set it before the first +release that deploys `dormouse-voice`: preflight reads it there. Once that +release is live, delete the copy the account Worker held before the split +(`pnpm exec wrangler secret delete ELEVENLABS_API_KEY`); its mapper no longer +reads it, but a secret should live only where it is used. + Create `ELEVENLABS_API_KEY` in an ElevenLabs account dedicated to Dormouse voice — the Worker deletes that account's entire speech history on a schedule, so never point it at a shared account. Restrict the key to text-to-speech plus @@ -331,8 +349,13 @@ credential pair do and do not enable. Facebook is outside this milestone. Default `promote=false` verifies and builds only. `promote=true` enters the protected production environment and runs the preflight, backup and restore-test, migration, deployment, and live-verification sequence in - `docs/specs/hosted.md` -> "Production releases". No real mail is sent by its - smoke checks, and passing them is not acceptance. + `docs/specs/hosted.md` -> "Production releases", deploying the account, + relay, and voice Workers in that order. No real mail is sent by its smoke + checks, and passing them is not acceptance. + + The first release after the split deletes the account Worker's + `OneTimeRoom` (its append-only migration `v2`) and creates the relay's + (`v1`): links open at that moment drop, and both become rollback floors. 3. Tagging runs only after live verification, on the terms in `docs/specs/hosted.md` -> "Production releases". If only tagging fails, rerun failed jobs: it records the original deployment without deploying again. @@ -341,9 +364,10 @@ credential pair do and do not enable. Facebook is outside this milestone. ## Acceptance -1. Check `/api/health` and `/api/ready` on the canonical hostname. 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 three hostnames and `/api/ready` on + `hosted.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 @@ -361,9 +385,10 @@ credential pair do and do not enable. Facebook is outside this milestone. 6. Sign in as the admin address, create a voice token, and speak one short phrase with it from Dormouse desktop; revoke it and confirm the next speak fails. Confirm another account sees no Voice tokens section. -7. Confirm the history sweep's Cron Trigger is registered: the deploy log lists - `schedule: */5 * * * *`, and the dashboard shows it under Workers & Pages -> - `dormouse-hosted` -> Settings -> Trigger Events. After the speak in step 6, +7. Confirm the history sweep's Cron Trigger is registered on the voice Worker + alone: the deploy log lists `schedule: */5 * * * *`, 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 @@ -371,11 +396,13 @@ credential pair do and do not enable. Facebook is outside this milestone. 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: `/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 app origin, - crosses a frame each way, and is refused a second phone. Load `/connect/` - in a browser and confirm no injected script or third-party request. +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/` is a 404. 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 @@ -394,9 +421,10 @@ A failed migration or deployment may already have changed production state; workflow failure does not automatically reverse it. Use the Worker's deployment history for code rollback and investigate database compatibility first; `docs/specs/hosted.md` -> "Production releases" owns what a code rollback does -not undo. Cloudflare refuses a rollback past the deploy that added the -`OneTimeRoom` migration, and every deploy or rollback drops the one-time links -still open; established sessions run directly and are unaffected. Restore a backup only after an explicit operator decision, into a +not undo. Cloudflare refuses a rollback of `dormouse-relay` past its +`OneTimeRoom` migration, or of `dormouse-hosted` past the deletion of its own; +every relay deploy or rollback drops the one-time links still open, and +established sessions run directly and are unaffected. Restore a backup only after an explicit operator decision, into a separate database first. Current provisioning status is discoverable with `gh secret list --env NAME`, diff --git a/hosted/package.json b/hosted/package.json index d8fdbdb52..ccdca7774 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -5,15 +5,15 @@ "scripts": { "dev": "tsx server/dev.ts", "typecheck": "tsc --noEmit", - "build": "pnpm --filter dormouse-lib build:one-time && pnpm typecheck && vite build && node scripts/stage-one-time.mjs && wrangler deploy --dry-run --outdir dist-worker", + "build": "pnpm --filter dormouse-lib build:one-time && pnpm typecheck && vite build && node scripts/stage-one-time.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", "test": "pnpm test:deploy && vitest run", "db:migrate": "tsx server/db.ts migrate", "db:validate": "tsx server/db.ts validate", "db:status": "tsx server/db.ts status", - "deploy": "pnpm build && wrangler deploy", + "deploy": "pnpm build && wrangler deploy && wrangler deploy --config wrangler.relay.jsonc && wrangler deploy --config wrangler.voice.jsonc", "preview:worker": "wrangler dev --local --local-protocol https", "test:deploy": "node --test scripts/*.test.mjs", - "test:one-time": "vitest run server/tests/one-time.test.ts", + "test:miniflare": "vitest run server/tests/one-time.test.ts server/tests/boundary.test.ts", "preview:deploy": "node scripts/preview.mjs deploy", "preview:cleanup": "node scripts/preview.mjs cleanup", "preview:smoke": "node scripts/preview-smoke.mjs" diff --git a/hosted/scripts/changed.test.mjs b/hosted/scripts/changed.test.mjs index 86adde50f..cbf6cefef 100644 --- a/hosted/scripts/changed.test.mjs +++ b/hosted/scripts/changed.test.mjs @@ -5,6 +5,10 @@ test("Hosted and shared inputs trigger previews; unrelated application changes d for (const path of [ "hosted/README.md", "hosted/server/worker.ts", + "hosted/server/relay-worker.ts", + "hosted/server/voice-worker.ts", + "hosted/wrangler.relay.jsonc", + "hosted/wrangler.voice.jsonc", "pnpm-lock.yaml", ".github/workflows/hosted-preview.yml", "lib/src/theme-colors.css", diff --git a/hosted/scripts/dev-one-time.mjs b/hosted/scripts/dev-one-time.mjs index 599b9dd4f..d243c1068 100644 --- a/hosted/scripts/dev-one-time.mjs +++ b/hosted/scripts/dev-one-time.mjs @@ -7,9 +7,8 @@ import { ONE_TIME_BASE } from "../../lib/scripts/assert-pocket-worker.mjs"; /** * The one-time rendezvous and phone page on loopback (`docs/specs/one-time.md` - * -> "Dev loop"): the production Worker under `wrangler dev`, with its Durable - * Object and rate limits and no Hyperdrive, serving the page `vite build - * --watch` rebuilds. Root `pnpm dev:one-time`; inside Dormouse, `dor tool + * -> "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. @@ -54,19 +53,19 @@ export function devOrigin(port) { /** * The Wrangler config the loop runs, written beside the staged page. - * Allowlisted from `hosted/wrangler.jsonc` like the preview's: the production + * 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, Hyperdrive, or a - * secret, so it can neither answer for production nor reach a database. + * assets binding over the staging folder — never a route or a secret, so it + * cannot answer for production. */ export function devConfig(base, port) { return { - name: `${base.name}-one-time-dev`, - main: "../../server/worker.ts", + name: `${base.name}-dev`, + main: "../../server/relay-worker.ts", compatibility_date: base.compatibility_date, compatibility_flags: base.compatibility_flags, assets: { ...base.assets, directory: "./assets" }, - vars: { APP_ORIGIN: devOrigin(port), OAUTH_PROVIDERS: "" }, + vars: { APP_ORIGIN: devOrigin(port) }, durable_objects: base.durable_objects, migrations: base.migrations, ratelimits: base.ratelimits, @@ -107,7 +106,7 @@ async function waitFor(what, ready, timeoutMs) { async function main() { const port = devPort(process.env); const origin = devOrigin(port); - const base = JSON.parse(readFileSync(resolve(hosted, "wrangler.jsonc"), "utf8")); + const base = JSON.parse(readFileSync(resolve(hosted, "wrangler.relay.jsonc"), "utf8")); const pageDir = resolve(devDir, "assets", PAGE_PATH.slice(1)); // A page left by an earlier run would satisfy the first wait below. rmSync(devDir, { recursive: true, force: true }); diff --git a/hosted/scripts/dev-one-time.test.mjs b/hosted/scripts/dev-one-time.test.mjs index 2fc827f09..5da243278 100644 --- a/hosted/scripts/dev-one-time.test.mjs +++ b/hosted/scripts/dev-one-time.test.mjs @@ -4,24 +4,22 @@ import { readFile } from "node:fs/promises"; import { DEFAULT_PORT, devConfig, devOrigin, devPort } from "./dev-one-time.mjs"; const base = JSON.parse( - await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"), + await readFile(new URL("../wrangler.relay.jsonc", import.meta.url), "utf8"), ); -test("the dev config runs the production entry on loopback, with the rendezvous and nothing of production's", () => { +test("the dev config runs the relay entry on loopback, with the rendezvous and nothing of production's", () => { const config = devConfig( { ...base, vars: { ...base.vars, GOOGLE_CLIENT_SECRET: "do-not-copy" }, + hyperdrive: [{ binding: "HYPERDRIVE", id: "0".repeat(32) }], d1_databases: [{ production: true }], }, 8787, ); - assert.equal(config.main, "../../server/worker.ts"); + assert.equal(config.main, "../../server/relay-worker.ts"); assert.notEqual(config.name, base.name); - assert.deepEqual(config.vars, { - APP_ORIGIN: "http://localhost:8787", - OAUTH_PROVIDERS: "", - }); + assert.deepEqual(config.vars, { APP_ORIGIN: "http://localhost:8787" }); for (const key of ["routes", "hyperdrive", "d1_databases", "workers_dev"]) assert.equal(config[key], undefined, key); assert.deepEqual(config.durable_objects, base.durable_objects); diff --git a/hosted/scripts/one-time-smoke.mjs b/hosted/scripts/one-time-smoke.mjs index d626bfa16..41924582e 100644 --- a/hosted/scripts/one-time-smoke.mjs +++ b/hosted/scripts/one-time-smoke.mjs @@ -75,7 +75,7 @@ export async function oneTimeSmoke(origin, connect = open, fetchPage = fetch) { */ async function pageSmoke(origin, fetchPage) { const page = await fetchPage(origin + ONE_TIME_PAGE_PATH); - assert.equal(page.status, 200, "Hosted serves the one-time page"); + assert.equal(page.status, 200, "The relay 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 7a5cd162e..7f29d3e88 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -61,6 +61,21 @@ export async function smokeRequest(fetcher, url, options, wait = delay, expected } } +/** + * A Worker that is no account — the relay or voice — reports `sha` live from + * its health route; retries as `smokeRequest` does. + */ +export async function healthSmoke(origin, sha, fetcher = fetch) { + assert.equal( + new URL(origin).origin, + origin, + "Supply an exact origin without a trailing slash", + ); + const health = await smokeRequest(fetcher, origin + "/api/health", {}, delay, sha); + assert.equal(health.status, 200, `${origin} must be healthy`); + assert.deepEqual(await health.json(), { ok: true, revision: sha }); +} + export async function smoke( origin, sha, @@ -262,14 +277,18 @@ if ( process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { - const [origin, sha] = process.argv.slice(2); + const [origin, relayOrigin, voiceOrigin, sha] = process.argv.slice(2); assert.match(sha ?? "", /^[a-f0-9]{40}$/); // A just-uploaded Worker may take a short time to become reachable everywhere. for (let attempt = 1; ; attempt++) { try { await smoke(origin, sha, fetch, true); - await oneTimeSmoke(origin); - console.log(`Preview smoke checks passed: ${origin}/login (${sha})`); + await healthSmoke(relayOrigin, sha); + await oneTimeSmoke(relayOrigin); + await healthSmoke(voiceOrigin, sha); + console.log( + `Preview smoke checks passed: ${origin}/login, ${relayOrigin}/connect/, ${voiceOrigin} (${sha})`, + ); break; } catch (error) { if (attempt === 6) throw error; diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs index b8d3d93fb..d57cf45f9 100644 --- a/hosted/scripts/preview.mjs +++ b/hosted/scripts/preview.mjs @@ -10,37 +10,69 @@ export function required(env, name) { return env[name]; } -export function previewName(pr) { +/** Hosted's three Workers, as their production config files and script names spell them. */ +export const WORKERS = { + account: { config: "wrangler.jsonc", script: "hosted" }, + relay: { config: "wrangler.relay.jsonc", script: "relay" }, + voice: { config: "wrangler.voice.jsonc", script: "voice" }, +}; + +/** A PR's preview Worker, `dormouse-') { const root = mkdtempSync(join(tmpdir(), "stage-one-time-")); const built = join(root, "dist-one-time"); mkdirSync(join(built, "assets"), { recursive: true }); writeFileSync(join(built, "index.html"), `${script}`); writeFileSync(join(built, "assets", "index-abc.js"), "export {};\n"); - const dist = join(root, "dist"); - mkdirSync(dist); - writeFileSync(join(dist, "index.html"), "accounts"); - return { built, dist }; + const assets = join(root, "dist-relay"); + mkdirSync(join(assets, "connect", "assets"), { recursive: true }); + writeFileSync(join(assets, "connect", "assets", "stale.js"), ""); + writeFileSync(join(assets, "index.html"), "stray"); + return { built, assets }; } -test("the built page lands at the page path, replacing any earlier copy", () => { - const { built, dist } = fixture(); - mkdirSync(join(dist, "connect", "assets"), { recursive: true }); - writeFileSync(join(dist, "connect", "assets", "stale.js"), ""); - assert.equal(stageOneTime(built, dist), 1); - assert.ok(existsSync(join(dist, "connect", "index.html"))); - assert.ok(existsSync(join(dist, "connect", "assets", "index-abc.js"))); - assert.ok(!existsSync(join(dist, "connect", "assets", "stale.js"))); - assert.match(readFileSync(join(dist, "index.html"), "utf8"), /accounts/); +test("the built page lands at the page path, and is all the relay's assets hold", () => { + const { built, assets } = fixture(); + assert.equal(stageOneTime(built, assets), 1); + 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), ["connect"]); }); test("a shell the page's policy would refuse is not staged quietly", () => { @@ -36,13 +35,12 @@ test("a shell the page's policy would refuse is not staged quietly", () => { '', "", ]) { - const { built, dist } = fixture(script); - assert.throws(() => stageOneTime(built, dist), undefined, script); + const { built, assets } = fixture(script); + assert.throws(() => stageOneTime(built, assets), undefined, script); } }); -test("staging refuses to run before either build", () => { - const { built, dist } = fixture(); - assert.throws(() => stageOneTime(join(built, "missing"), dist), /build:one-time/); - assert.throws(() => stageOneTime(built, join(dist, "missing")), /Vite build/); +test("staging refuses to run before the page's build", () => { + const { built, assets } = fixture(); + assert.throws(() => stageOneTime(join(built, "missing"), assets), /build:one-time/); }); diff --git a/hosted/server/account-app.ts b/hosted/server/account-app.ts new file mode 100644 index 000000000..29ce1e32f --- /dev/null +++ b/hosted/server/account-app.ts @@ -0,0 +1,51 @@ +import type { ExecutionContext, Hono } from "hono"; +import { queryDatabase } from "pgstencil/postgres"; +import type { AccountEnv } from "./bindings"; +import { ACCOUNT_POLICY } from "./headers"; +import { voiceTokenRoutes } from "./voice"; +import { workerApp } from "./worker-app"; + +/** + * The account Worker (`hosted.dormouse.sh`): auth, providers, readiness, + * voice-token minting, and the frontend. The production and preview entries + * differ only in `fetchAuth`'s mail and in `bindings`. + */ +export function accountApp( + fetchAuth: ( + request: Request, + env: AccountEnv, + ctx: ExecutionContext, + ) => Response | Promise, + bindings: (env: AccountEnv) => AccountEnv, + configure?: (app: Hono<{ Bindings: AccountEnv }>) => void, +) { + return workerApp({ + bindings, + policy: () => ACCOUNT_POLICY, + unavailable: "Sign-in is temporarily unavailable. Please try again.", + routes(app) { + configure?.(app); + app.get("/api/ready", async (c) => { + const ok = await queryDatabase( + c.env.HYPERDRIVE.connectionString, + 'SELECT "singleSession", "emailAuthenticated" FROM "session" LIMIT 0', + ).then( + () => true, + () => false, + ); + return c.json({ ok }, ok ? 200 : 503); + }); + app.all("/api/auth/*", (c) => + fetchAuth(c.req.raw, c.env, c.executionCtx), + ); + app.get("/api/providers", (c) => + fetchAuth(c.req.raw, c.env, c.executionCtx), + ); + voiceTokenRoutes(app, (c) => ({ + databaseUrl: c.env.HYPERDRIVE.connectionString, + auth: (request) => fetchAuth(request, c.env, c.executionCtx), + })); + }, + fallback: (app) => app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw)), + }); +} diff --git a/hosted/server/bindings.ts b/hosted/server/bindings.ts new file mode 100644 index 000000000..9017d92cd --- /dev/null +++ b/hosted/server/bindings.ts @@ -0,0 +1,87 @@ +import type { BetterAuthWorkerBindings } from "@pgstencil/auth/better-auth-workers"; +import { providerBindings } from "./policy"; + +// Each Worker's bindings mapper (`docs/specs/hosted.md` -> "Application +// boundary"): the only bindings that reach its routes, whatever else the +// deployment carries. A mapper names what its Worker uses and nothing more, so +// a secret set on the wrong Worker, or left on a preview, stays unread. + +/** Every Worker's: its own origin, which the 421 gate holds requests to. */ +interface WorkerEnv { + APP_ORIGIN: string; + BUILD_SHA?: string; +} + +interface Assets { + fetch(request: Request): Promise; +} + +/** `hosted.dormouse.sh`: the account frontend, auth, and voice-token minting. */ +export interface AccountEnv extends BetterAuthWorkerBindings, WorkerEnv { + ASSETS: Assets; + EMAIL_FROM: string; + POSTMARK_SERVER_TOKEN: string; + OAUTH_PROVIDERS?: string; +} + +/** `relay.dormouse.sh`: the one-time rendezvous and its phone page. */ +export interface RelayEnv extends WorkerEnv { + ASSETS: Assets; + ONE_TIME_ROOM: DurableObjectNamespace; + ONE_TIME_MINT_LIMIT: RateLimit; + ONE_TIME_JOIN_LIMIT: RateLimit; +} + +/** `voice.dormouse.sh`: managed-voice speech and its history sweep. */ +export interface VoiceEnv extends WorkerEnv { + HYPERDRIVE: { connectionString: string }; + ELEVENLABS_API_KEY?: string; +} + +/** A rejected provider allowlist throws inside the request, where `onError` answers it. */ +export const accountBindings = (env: AccountEnv): AccountEnv => ({ + HYPERDRIVE: env.HYPERDRIVE, + ASSETS: env.ASSETS, + APP_ORIGIN: env.APP_ORIGIN, + AUTH_SECRET: env.AUTH_SECRET, + EMAIL_FROM: env.EMAIL_FROM, + POSTMARK_SERVER_TOKEN: env.POSTMARK_SERVER_TOKEN, + BUILD_SHA: env.BUILD_SHA, + ...providerBindings(env as unknown as Record), +}); + +/** Ignores stale production, OAuth, and mail bindings on an existing preview Worker. */ +export const accountPreviewBindings = (env: AccountEnv): AccountEnv => ({ + HYPERDRIVE: env.HYPERDRIVE, + ASSETS: env.ASSETS, + APP_ORIGIN: env.APP_ORIGIN, + AUTH_SECRET: env.AUTH_SECRET, + BUILD_SHA: env.BUILD_SHA, + EMAIL_FROM: "", + POSTMARK_SERVER_TOKEN: "", +}); + +/** No auth secret, no Hyperdrive: the rendezvous reaches neither. Production and preview alike. */ +export const relayBindings = (env: RelayEnv): RelayEnv => ({ + ASSETS: env.ASSETS, + APP_ORIGIN: env.APP_ORIGIN, + BUILD_SHA: env.BUILD_SHA, + ONE_TIME_ROOM: env.ONE_TIME_ROOM, + ONE_TIME_MINT_LIMIT: env.ONE_TIME_MINT_LIMIT, + 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, + APP_ORIGIN: env.APP_ORIGIN, + BUILD_SHA: env.BUILD_SHA, + ELEVENLABS_API_KEY: env.ELEVENLABS_API_KEY, +}); + +/** A preview never speaks or sweeps: the ElevenLabs key never reaches it. */ +export const voicePreviewBindings = (env: VoiceEnv): VoiceEnv => ({ + HYPERDRIVE: env.HYPERDRIVE, + APP_ORIGIN: env.APP_ORIGIN, + BUILD_SHA: env.BUILD_SHA, +}); diff --git a/hosted/server/dev.ts b/hosted/server/dev.ts index 8624b4f28..267dcab8f 100644 --- a/hosted/server/dev.ts +++ b/hosted/server/dev.ts @@ -10,7 +10,12 @@ import { EmailDev, SystemTime } from "pgstencil"; import { authPolicy } from "./policy"; import { migrations } from "./migrations"; import { allowedDevRequest } from "./dev-host-guard"; -import { elevenLabs, voiceRoutes, type Synthesize } from "./voice"; +import { + elevenLabs, + speakRoute, + voiceTokenRoutes, + type Synthesize, +} from "./voice"; // Bind first, then derive the origin from the port actually bound, so an unset // PORT runs beside another checkout's server. `localhost`, not `127.0.0.1`: it @@ -68,13 +73,17 @@ const silence: Synthesize = async () => { return new Response(audio, { headers: { "content-type": "audio/mpeg" } }); }; const elevenLabsKey = process.env.ELEVENLABS_API_KEY; -const voice = { +const app = new Hono(); +voiceTokenRoutes(app, () => ({ databaseUrl, auth: (request: Request) => auth.app.fetch(request), +})); +// Deployed, speak is the voice Worker's alone; locally it sits beside the +// token routes on this one origin, so a minted token can be tried with curl. +speakRoute(app, () => ({ + databaseUrl, synthesize: elevenLabsKey ? elevenLabs(elevenLabsKey) : silence, -}; -const app = new Hono(); -voiceRoutes(app, () => voice); +})); app.all("*", (c) => auth.app.fetch(c.req.raw)); const vite = await createViteServer({ server: { diff --git a/hosted/server/headers.ts b/hosted/server/headers.ts index b703377da..20a65c448 100644 --- a/hosted/server/headers.ts +++ b/hosted/server/headers.ts @@ -1,10 +1,17 @@ import type { Hono } from "hono"; import { ONE_TIME_PAGE_PATH, ONE_TIME_WS_ROUTES } from "remote-lib-common"; -/** The origin-wide policy: the account frontend's own files and nothing else. */ -const ORIGIN_POLICY = +/** The account origin's policy: the account frontend's own files and nothing else. */ +export const ACCOUNT_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; worker-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'"; +/** + * The policy of every response that is no page: the voice origin's, and the + * relay origin's outside `/connect/`. It runs nothing at all. + */ +export const RUNS_NOTHING_POLICY = + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; + /** Whether a path is the one-time page's: `/connect` itself or anything under `/connect/`. */ function isOneTimePagePath(pathname: string) { return ( @@ -14,12 +21,11 @@ function isOneTimePagePath(pathname: string) { } /** - * The one-time page's own policy (`docs/specs/one-time.md` -> "Phone page"), - * narrower than the origin's where it can be and looser only where the page - * needs it: scripts from `/connect/assets/` alone (plus WebAssembly for xterm's - * image decoder), styles from there too plus inline ones for the shell and - * React, and one socket — the rendezvous client route. Nothing names `'self'`, - * which would admit the account frontend's files. Sandboxed, so it opens no + * The one-time page's own policy (`docs/specs/one-time.md` -> "Phone page"): + * scripts from `/connect/assets/` alone (plus WebAssembly for xterm's image + * decoder), styles from there too plus inline ones for the shell and React, + * and one socket — the rendezvous client route. Nothing names `'self'`, which + * would admit whatever else the relay origin serves. Sandboxed, so it opens no * popup and submits no form. */ export function oneTimePagePolicy(appOrigin: string) { @@ -42,26 +48,29 @@ export function oneTimePagePolicy(appOrigin: string) { } /** - * The policy a response to `pathname` carries. The page's needs `APP_ORIGIN`, - * and a value that is not exactly an origin could write a directive of its - * own into the header: the origin-wide policy, which runs none of the page, - * answers instead. + * The policy a relay response to `pathname` carries: the page's under + * `/connect`, and one that runs nothing everywhere else. The page's needs + * `APP_ORIGIN`, and a value that is not exactly an origin could write a + * directive of its own into the header: the runs-nothing policy answers instead. */ -export function contentSecurityPolicy(pathname: string, appOrigin: unknown) { - if (!isOneTimePagePath(pathname)) return ORIGIN_POLICY; +export function relayPolicy(pathname: string, appOrigin: unknown) { + if (!isOneTimePagePath(pathname)) return RUNS_NOTHING_POLICY; return typeof appOrigin === "string" && /^https?:\/\/[a-z0-9.:[\]-]+$/i.test(appOrigin) && URL.canParse(appOrigin) && new URL(appOrigin).origin === appOrigin ? oneTimePagePolicy(appOrigin) - : ORIGIN_POLICY; + : RUNS_NOTHING_POLICY; } -/** Vite's content-hashed output: the account frontend's, and the one-time page's. */ +/** What a Worker's `secureHeaders` asks for each response's policy. */ +export type PolicyFor = (pathname: string, appOrigin: unknown) => string; + +/** Vite's content-hashed output: the account frontend's, and the one-time page's on the relay. */ const HASHED_ASSETS = ["/assets/", `${ONE_TIME_PAGE_PATH}assets/`]; // Applied to the HTML shell as well as APIs: auth's own middleware only covers its routes. -export function secureHeaders(app: Hono) { +export function secureHeaders(app: Hono, policy: PolicyFor) { app.use("*", async (c, next) => { await next(); // A WebSocket upgrade carries no document, and its headers are the runtime's. @@ -85,9 +94,6 @@ export function secureHeaders(app: Hono) { c.header("X-Robots-Tag", "noindex, nofollow"); c.header("Strict-Transport-Security", "max-age=31536000"); // `c.env` is the mapped bindings once the mapper ran, and the raw ones if it threw. - c.header( - "Content-Security-Policy", - contentSecurityPolicy(pathname, c.env?.APP_ORIGIN), - ); + c.header("Content-Security-Policy", policy(pathname, c.env?.APP_ORIGIN)); }); } diff --git a/hosted/server/one-time.ts b/hosted/server/one-time.ts index a4b447486..ec8a11f09 100644 --- a/hosted/server/one-time.ts +++ b/hosted/server/one-time.ts @@ -7,9 +7,9 @@ import { ONE_TIME_WS_ROUTES, toBase64Url, } from "remote-lib-common"; -import type { Env } from "./worker"; +import type { RelayEnv } from "./bindings"; -type OneTimeContext = Context<{ Bindings: Env }>; +type OneTimeContext = Context<{ Bindings: RelayEnv }>; /** * The one-time rendezvous routes (`docs/specs/one-time.md` -> "Hosted @@ -17,7 +17,7 @@ type OneTimeContext = Context<{ Bindings: Env }>; * room's Durable Object; neither reads a cookie, reaches Hyperdrive, or asks * auth anything. */ -export function oneTimeRoutes(app: Hono<{ Bindings: Env }>) { +export function oneTimeRoutes(app: Hono<{ Bindings: RelayEnv }>) { app.get(ONE_TIME_WS_ROUTES.burrow, async (c) => { if (!upgrade(c)) return upgradeRequired(c); // Every browser sends Origin on a WebSocket handshake and the Burrow's @@ -45,18 +45,17 @@ export function oneTimeRoutes(app: Hono<{ Bindings: Env }>) { /** * The one-time phone page (`docs/specs/one-time.md` -> "Phone page"), staged - * into the assets under `/connect/`: its shell and its content-hashed assets, - * and nothing else under the path, so the SPA fallback never answers there. - * Mounted ahead of that fallback. + * 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. */ -export function oneTimePageRoutes(app: Hono<{ Bindings: Env }>) { +export function oneTimePageRoutes(app: Hono<{ Bindings: RelayEnv }>) { const assets = (c: OneTimeContext) => c.env.ASSETS.fetch(c.req.raw); app.get(ONE_TIME_PAGE_PATH.slice(0, -1), assets); app.get(ONE_TIME_PAGE_PATH, assets); app.get(`${ONE_TIME_PAGE_PATH}assets/*`, async (c) => { const response = await assets(c); - // A missing file comes back as the SPA fallback's shell, which is never an - // answer to a script or stylesheet request. + // An HTML document is never an answer to a script or stylesheet request. return (response.headers.get("content-type") ?? "").includes("text/html") ? c.notFound() : response; diff --git a/hosted/server/preview-worker.ts b/hosted/server/preview-worker.ts index 3e1f4e82e..eb13e3f14 100644 --- a/hosted/server/preview-worker.ts +++ b/hosted/server/preview-worker.ts @@ -1,47 +1,33 @@ import { createBetterAuthWorker } from "@pgstencil/auth/better-auth-workers"; +import { accountApp } from "./account-app"; +import { accountPreviewBindings, type AccountEnv } from "./bindings"; import { authPolicy } from "./policy"; -import { workerApp } from "./worker-app"; import { postgresInbox, inboxPage, messagePage } from "./preview-inbox"; -import type { Env } from "./worker"; -const auth = createBetterAuthWorker({ +/** The account Worker's PR preview: mail lands in the preview database's inbox. */ +const auth = createBetterAuthWorker({ ...authPolicy, email: (env) => postgresInbox(env.HYPERDRIVE.connectionString), }); -export default workerApp( +export default accountApp( (request, env, ctx) => auth.fetch(request, env, ctx), - // Ignore stale production/OAuth/ElevenLabs bindings on an existing preview Worker. - (env) => ({ - HYPERDRIVE: env.HYPERDRIVE, - ASSETS: env.ASSETS, - APP_ORIGIN: env.APP_ORIGIN, - AUTH_SECRET: env.AUTH_SECRET, - BUILD_SHA: env.BUILD_SHA, - ONE_TIME_ROOM: env.ONE_TIME_ROOM, - ONE_TIME_MINT_LIMIT: env.ONE_TIME_MINT_LIMIT, - ONE_TIME_JOIN_LIMIT: env.ONE_TIME_JOIN_LIMIT, - EMAIL_FROM: "", - POSTMARK_SERVER_TOKEN: "", - }), - { - configure: (app) => { - app.get("/api/dev/emails", async (c) => - c.json(await postgresInbox(c.env.HYPERDRIVE.connectionString).all()), + accountPreviewBindings, + (app) => { + app.get("/api/dev/emails", async (c) => + c.json(await postgresInbox(c.env.HYPERDRIVE.connectionString).all()), + ); + app.get("/dev/emails", async (c) => + c.html( + inboxPage(await postgresInbox(c.env.HYPERDRIVE.connectionString).all()), + ), + ); + app.get("/dev/emails/:id", async (c) => { + const id = c.req.param("id"); + if (!/^[1-9]\d{0,17}$/.test(id)) return c.notFound(); + const mail = await postgresInbox(c.env.HYPERDRIVE.connectionString).get( + id, ); - app.get("/dev/emails", async (c) => - c.html( - inboxPage(await postgresInbox(c.env.HYPERDRIVE.connectionString).all()), - ), - ); - app.get("/dev/emails/:id", async (c) => { - const id = c.req.param("id"); - if (!/^[1-9]\d{0,17}$/.test(id)) return c.notFound(); - const mail = await postgresInbox(c.env.HYPERDRIVE.connectionString).get( - id, - ); - return mail ? c.html(messagePage(mail)) : c.notFound(); - }); - }, + return mail ? c.html(messagePage(mail)) : c.notFound(); + }); }, ); -export { OneTimeRoom } from "./one-time-room"; diff --git a/hosted/server/relay-worker.ts b/hosted/server/relay-worker.ts new file mode 100644 index 000000000..62ca87bf6 --- /dev/null +++ b/hosted/server/relay-worker.ts @@ -0,0 +1,22 @@ +import { relayBindings, type RelayEnv } from "./bindings"; +import { relayPolicy } from "./headers"; +import { oneTimePageRoutes, oneTimeRoutes } from "./one-time"; +import { workerApp } from "./worker-app"; + +/** + * The relay Worker, `dormouse-relay` on `relay.dormouse.sh`: the one-time + * rendezvous and its `/connect/` page, and nothing else — no SPA fallback, so + * every other path is a 404. It holds no auth secret and reaches no database. + * Its PR previews run this entry too: the mapper passes nothing a preview lacks. + */ +export default workerApp({ + bindings: relayBindings, + policy: relayPolicy, + unavailable: "The relay is temporarily unavailable. Please try again.", + routes(app) { + oneTimeRoutes(app); + oneTimePageRoutes(app); + }, +}); + +export { OneTimeRoom } from "./one-time-room"; diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts new file mode 100644 index 000000000..05f419c3f --- /dev/null +++ b/hosted/server/tests/boundary.test.ts @@ -0,0 +1,289 @@ +import { test, expect, beforeAll, afterAll } from "vitest"; +import { + Miniflare, + convertV4MiniflareOptions, + Response as WorkerResponse, +} from "miniflare"; +import { ONE_TIME_PAGE_PATH, ONE_TIME_WS_ROUTES } from "remote-lib-common"; +import { + accountBindings, + accountPreviewBindings, + relayBindings, + voiceBindings, + voicePreviewBindings, +} from "../bindings"; +import { ACCOUNT_POLICY, RUNS_NOTHING_POLICY } from "../headers"; +import { bundleWorker, wrangler } from "./bundle"; + +// The partition between Hosted's three Workers (`docs/specs/hosted.md` -> +// "Application boundary"), each production bundle in real workerd without +// Postgres: nothing here gets past a 421, a 404, or a missing bearer token, so +// no route reaches the database. + +const ORIGINS = { + account: "https://hosted.dormouse.sh", + relay: "https://relay.dormouse.sh", + voice: "https://voice.dormouse.sh", +} as const; +type Name = keyof typeof ORIGINS; +const NAMES = Object.keys(ORIGINS) as Name[]; +const ENTRIES: Record = { + account: "server/worker.ts", + relay: "server/relay-worker.ts", + voice: "server/voice-worker.ts", +}; +const sha = "a".repeat(40); + +/** Every binding any Worker reads, given to each, so only its mapper decides. */ +const everything = { + BUILD_SHA: sha, + AUTH_SECRET: "dormouse-test-secret-with-at-least-32-characters", + EMAIL_FROM: "signin@example.test", + POSTMARK_SERVER_TOKEN: "test-postmark-token", + ELEVENLABS_API_KEY: "test-elevenlabs-key", + OAUTH_PROVIDERS: "github", + GITHUB_CLIENT_ID: "test-github-id", + GITHUB_CLIENT_SECRET: "test-github-secret", +}; + +/** Every request any Worker sent upstream. */ +const outbound: string[] = []; +const workers = {} as Record; + +beforeAll(async () => { + await Promise.all( + NAMES.map(async (name) => { + // The relay's own Durable Object; the others implement none to bind. + const relay = name === "relay"; + workers[name] = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: (await bundleWorker(ENTRIES[name])).outputFiles[0].text, + compatibilityDate: wrangler[name].compatibility_date, + compatibilityFlags: wrangler[name].compatibility_flags, + bindings: { ...everything, APP_ORIGIN: ORIGINS[name] }, + // Nothing listens here, so any database access would fail the request. + hyperdrives: { HYPERDRIVE: "postgres://user:pass@127.0.0.1:9/none" }, + ...(relay + ? { + durableObjects: { + ONE_TIME_ROOM: { className: "OneTimeRoom", useSQLite: true }, + }, + } + : {}), + ratelimits: Object.fromEntries( + wrangler.relay.ratelimits!.map(({ name, ...limit }) => [name, limit]), + ), + // An SPA fallback answering every path, so a route that reached the + // assets shows as a 200 rather than a 404. + serviceBindings: { + ASSETS: () => + new WorkerResponse("", { + headers: { "content-type": "text/html" }, + }), + }, + outboundService(request) { + outbound.push(request.url); + if (new URL(request.url).pathname === "/v1/history") + return WorkerResponse.json({ history: [] }); + return new WorkerResponse("{}", { status: 500 }); + }, + }), + ); + await workers[name].ready; + }), + ); +}); +afterAll(async () => { + await Promise.all(NAMES.map((name) => workers[name]?.dispose())); +}); + +const send = (name: Name, url: string, method = "GET") => + workers[name].dispatchFetch(url, { method, redirect: "manual" }); + +/** A route each Worker serves, as method and path. */ +const SERVED: Record = { + account: [ + ["GET", "/api/auth/csrf"], + ["GET", "/api/providers"], + ["GET", "/api/voice/tokens"], + ["POST", "/api/voice/tokens"], + ["GET", "/login"], + ], + relay: [ + ["GET", ONE_TIME_WS_ROUTES.burrow], + ["GET", ONE_TIME_WS_ROUTES.client], + ["GET", ONE_TIME_PAGE_PATH], + ], + voice: [["POST", "/api/voice/speak"]], +}; + +test.for(NAMES)( + "%s: a foreign origin, each sibling's included, is refused with 421 before any route", + async (name) => { + const foreign = [ + ...NAMES.filter((other) => other !== name).map((other) => ORIGINS[other]), + "https://dormouse.sh", + `http://${new URL(ORIGINS[name]).host}`, + ]; + for (const origin of foreign) + for (const [method, path] of [...SERVED[name], ["GET", "/api/health"]]) { + const response = await send(name, origin + path, method); + expect(response.status, `${method} ${origin}${path}`).toBe(421); + } + const health = await send(name, ORIGINS[name] + "/api/health"); + expect(await health.json()).toEqual({ ok: true, revision: sha }); + }, +); + +/** What each Worker must not serve: every other Worker's routes, and anything unknown. */ +const ABSENT: Record = { + account: [ + ["GET", ONE_TIME_WS_ROUTES.burrow], + ["GET", ONE_TIME_WS_ROUTES.client], + ["POST", "/api/voice/speak"], + ], + relay: [ + ["GET", "/api/auth/csrf"], + ["GET", "/api/auth/get-session"], + ["POST", "/api/auth/sign-out"], + ["GET", "/api/providers"], + ["GET", "/api/ready"], + ["GET", "/api/voice/tokens"], + ["POST", "/api/voice/tokens"], + ["DELETE", "/api/voice/tokens/00000000-0000-4000-8000-000000000000"], + ["POST", "/api/voice/speak"], + ["GET", "/"], + ["GET", "/login"], + ["GET", "/account"], + ["GET", "/assets/app-abc123.js"], + ], + voice: [ + ["GET", "/api/auth/csrf"], + ["GET", "/api/auth/get-session"], + ["GET", "/api/providers"], + ["GET", "/api/ready"], + ["GET", "/api/voice/tokens"], + ["POST", "/api/voice/tokens"], + ["GET", "/api/voice/speak"], + ["GET", ONE_TIME_WS_ROUTES.burrow], + ["GET", ONE_TIME_PAGE_PATH], + ["GET", "/"], + ["GET", "/login"], + ], +}; + +test.for(NAMES)("%s: serves only its own routes", async (name) => { + for (const [method, path] of ABSENT[name]) { + const response = await send(name, ORIGINS[name] + path, method); + expect(response.status, `${method} ${path}`).toBe(404); + expect( + response.headers.get("content-security-policy"), + `${method} ${path}`, + ).toBe(name === "account" ? ACCOUNT_POLICY : RUNS_NOTHING_POLICY); + } + expect(outbound).toEqual([]); +}); + +test("the account answers /connect/ with its own shell and policy, never the phone page", async () => { + for (const path of [ONE_TIME_PAGE_PATH, `${ONE_TIME_PAGE_PATH}assets/x.js`]) { + const response = await send("account", ORIGINS.account + path); + expect(response.headers.get("content-security-policy"), path).toBe( + ACCOUNT_POLICY, + ); + } +}); + +test("speak is the voice Worker's, bearer-only", async () => { + const response = await send("voice", ORIGINS.voice + "/api/voice/speak", "POST"); + expect(response.status).toBe(401); + expect(response.headers.get("content-security-policy")).toBe( + RUNS_NOTHING_POLICY, + ); +}); + +test("the ElevenLabs history sweep runs on the voice Worker alone", async () => { + outbound.length = 0; + for (const name of ["account", "relay"] as const) { + // Without @cloudflare/workers-types, the Fetcher's scheduled() is untyped. + const fetcher = (await workers[name].getWorker()) as unknown as { + scheduled(options: { cron: string }): Promise<{ outcome: string }>; + }; + await fetcher.scheduled({ cron: "*/5 * * * *" }).catch(() => undefined); + expect(outbound, name).toEqual([]); + } + const voice = (await workers.voice.getWorker()) as unknown as { + scheduled(options: { cron: string }): Promise<{ outcome: string }>; + }; + expect((await voice.scheduled({ cron: "*/5 * * * *" })).outcome).toBe("ok"); + expect(outbound).toEqual([ + "https://api.elevenlabs.io/v1/history?page_size=40", + ]); + outbound.length = 0; +}); + +test("each bindings mapper passes only what its Worker uses", () => { + const env = { + ...everything, + APP_ORIGIN: "https://example.test", + HYPERDRIVE: { connectionString: "postgres://example.test/none" }, + ASSETS: { fetch: async () => new Response() }, + ONE_TIME_ROOM: {} as DurableObjectNamespace, + ONE_TIME_MINT_LIMIT: {} as RateLimit, + ONE_TIME_JOIN_LIMIT: {} as RateLimit, + }; + const keys = (bindings: object) => Object.keys(bindings).sort(); + expect(keys(accountBindings(env))).toEqual( + [ + "APP_ORIGIN", + "ASSETS", + "AUTH_SECRET", + "BUILD_SHA", + "EMAIL_FROM", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "HYPERDRIVE", + "POSTMARK_SERVER_TOKEN", + ].sort(), + ); + expect(accountPreviewBindings(env)).toEqual({ + APP_ORIGIN: env.APP_ORIGIN, + ASSETS: env.ASSETS, + AUTH_SECRET: env.AUTH_SECRET, + BUILD_SHA: sha, + HYPERDRIVE: env.HYPERDRIVE, + EMAIL_FROM: "", + POSTMARK_SERVER_TOKEN: "", + }); + // No auth secret, and no database: the rendezvous reaches neither. + expect(keys(relayBindings(env))).toEqual( + [ + "APP_ORIGIN", + "ASSETS", + "BUILD_SHA", + "ONE_TIME_JOIN_LIMIT", + "ONE_TIME_MINT_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(), + ); + expect(keys(voicePreviewBindings(env))).toEqual( + ["APP_ORIGIN", "BUILD_SHA", "HYPERDRIVE"].sort(), + ); +}); + +test("neither the relay nor the voice bundle carries Better Auth", async () => { + for (const entry of [ENTRIES.relay, ENTRIES.voice, "server/voice-preview-worker.ts"]) { + const inputs = Object.keys((await bundleWorker(entry)).metafile.inputs); + expect( + inputs.filter((input) => /better-auth|postmark/.test(input)), + entry, + ).toEqual([]); + } + // The pattern finds it where it is. + const account = Object.keys((await bundleWorker(ENTRIES.account)).metafile.inputs); + expect(account.some((input) => /better-auth/.test(input))).toBe(true); +}); diff --git a/hosted/server/tests/bundle.ts b/hosted/server/tests/bundle.ts index 4cf32c556..efa5b191f 100644 --- a/hosted/server/tests/bundle.ts +++ b/hosted/server/tests/bundle.ts @@ -2,19 +2,29 @@ import { build } from "esbuild"; import { readFileSync } from "node:fs"; import { builtinModules } from "node:module"; -/** `hosted/wrangler.jsonc`, which is strict JSON so scripts can parse it. */ -export const wrangler = JSON.parse(readFileSync("wrangler.jsonc", "utf8")) as { +interface WranglerConfig { compatibility_date: string; compatibility_flags: string[]; - durable_objects: { bindings: { name: string; class_name: string }[] }; - migrations: { tag: string; new_sqlite_classes?: string[] }[]; - ratelimits: { + durable_objects?: { bindings: { name: string; class_name: string }[] }; + migrations?: { tag: string; new_sqlite_classes?: string[] }[]; + ratelimits?: { name: string; namespace_id: string; simple: { limit: number; period: 10 | 60 }; }[]; +} + +/** Hosted's three Wrangler configs, which are strict JSON so scripts can parse them. */ +export const wrangler = { + account: read("wrangler.jsonc"), + relay: read("wrangler.relay.jsonc"), + voice: read("wrangler.voice.jsonc"), }; +function read(file: string) { + return JSON.parse(readFileSync(file, "utf8")) as WranglerConfig; +} + /** A Worker entry bundled for workerd the way Wrangler bundles it. */ export function bundleWorker(entry: string, inject: string[] = []) { return build({ @@ -22,6 +32,7 @@ export function bundleWorker(entry: string, inject: string[] = []) { inject, bundle: true, write: false, + metafile: true, format: "esm", platform: "node", conditions: ["workerd", "worker"], diff --git a/hosted/server/tests/one-time-entry.ts b/hosted/server/tests/one-time-entry.ts index fa549f737..56ba20c84 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 "../worker"; +import worker, { OneTimeRoom as ProductionRoom } from "../relay-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 d251f509f..6673dfc46 100644 --- a/hosted/server/tests/one-time.test.ts +++ b/hosted/server/tests/one-time.test.ts @@ -30,17 +30,19 @@ import { type OneTimeRoomFrame, } from "remote-lib-common"; import * as smoke from "../../scripts/one-time-smoke.mjs"; -import { contentSecurityPolicy, oneTimePagePolicy } from "../headers"; +import { oneTimePagePolicy, relayPolicy, RUNS_NOTHING_POLICY } from "../headers"; import { rateLimitKey } from "../one-time"; import { bundleWorker, wrangler } from "./bundle"; import { TEST_ROOM_LIMITS } from "./one-time-limits"; import { rawUpgrade, type RawSocket } from "./raw-socket"; -// The rendezvous and the phone page in real workerd, without Postgres: the -// one-time routes never reach Hyperdrive, so this suite runs in the root +// The relay Worker's rendezvous and phone page in real workerd, without +// Postgres: the relay reaches no Hyperdrive, so this suite runs in the root // `pnpm test`. -const origin = "https://hosted.dormouse.sh"; +const origin = "https://relay.dormouse.sh"; +/** The sibling Workers' origins, which share the account's login cookie as same-site. */ +const SIBLINGS = ["https://hosted.dormouse.sh", "https://voice.dormouse.sh"]; const PAGE_SCRIPT = `${ONE_TIME_PAGE_PATH}assets/page-abc123.js`; const PAGE_SHELL = ``; @@ -49,27 +51,27 @@ async function start(entry: string) { convertV4MiniflareOptions({ modules: true, script: (await bundleWorker(entry)).outputFiles[0].text, - compatibilityDate: wrangler.compatibility_date, - compatibilityFlags: wrangler.compatibility_flags, - bindings: { APP_ORIGIN: origin, OAUTH_PROVIDERS: "" }, + compatibilityDate: wrangler.relay.compatibility_date, + compatibilityFlags: wrangler.relay.compatibility_flags, + bindings: { APP_ORIGIN: origin }, durableObjects: Object.fromEntries( - wrangler.durable_objects.bindings.map(({ name, class_name }) => [ + wrangler.relay.durable_objects!.bindings.map(({ name, class_name }) => [ name, { className: class_name, - useSQLite: wrangler.migrations.some((migration) => + useSQLite: wrangler.relay.migrations!.some((migration) => migration.new_sqlite_classes?.includes(class_name), ), }, ]), ), ratelimits: Object.fromEntries( - wrangler.ratelimits.map(({ name, ...limit }) => [name, limit]), + wrangler.relay.ratelimits!.map(({ name, ...limit }) => [name, limit]), ), serviceBindings: { - // The staged page and its hashed script, with the SPA fallback answering - // every other path — an unknown one under /connect/assets/ included — - // with the account shell. + // 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) @@ -91,7 +93,7 @@ let production: Awaited>; let short: Awaited>; beforeAll(async () => { [production, short] = await Promise.all([ - start("server/worker.ts"), + start("server/relay-worker.ts"), start("server/tests/one-time-entry.ts"), ]); }); @@ -162,16 +164,17 @@ test("a Burrow mints a fresh room and hears its room frame first", async () => { }); test("no Origin may mint, and only the app origin may join", async () => { - for (const value of [origin, "https://dormouse.sh", "null"]) + for (const value of [origin, ...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, + ...SIBLINGS.map((sibling) => ({ origin: sibling })), { origin: "https://dormouse.sh" }, - { origin: "https://hosted.dormouse.sh.example" }, - { origin: "http://hosted.dormouse.sh" }, + { origin: "https://relay.dormouse.sh.example" }, + { origin: "http://relay.dormouse.sh" }, ]) expect((await upgrade(joinPath(frame.roomId), headers)).status).toBe(403); // None of those spent the room's one join. @@ -204,18 +207,19 @@ test("both routes require a WebSocket upgrade", async () => { ).toBe(426); }); -test("a foreign host is refused before any route", async () => { - for (const path of [ONE_TIME_WS_ROUTES.burrow, joinPath("A".repeat(E2E_ID_LENGTH))]) - expect( - (await upgrade(path, { origin }, production, "https://dormouse.sh" + path)) - .status, - ).toBe(421); +test("a foreign host, a sibling Worker's included, is refused before any route", async () => { + for (const host of [...SIBLINGS, "https://dormouse.sh"]) + for (const path of [ONE_TIME_WS_ROUTES.burrow, joinPath("A".repeat(E2E_ID_LENGTH))]) + expect( + (await upgrade(path, { origin: host }, production, host + path)).status, + host + path, + ).toBe(421); }); test("refusals carry the secure headers; an upgrade carries none of ours", async () => { const refused = await upgrade(ONE_TIME_WS_ROUTES.burrow, { origin }); - expect(refused.headers.get("content-security-policy")).toContain( - "worker-src 'none'", + expect(refused.headers.get("content-security-policy")).toBe( + RUNS_NOTHING_POLICY, ); expect(refused.headers.get("cache-control")).toBe("no-store"); const minted = await upgrade(ONE_TIME_WS_ROUTES.burrow); @@ -407,7 +411,7 @@ test("a joined room that outlives the deadline closes both ends", async () => { }); const limitOf = (binding: string) => - wrangler.ratelimits.find(({ name }) => name === binding)!.simple.limit; + wrangler.relay.ratelimits!.find(({ name }) => name === binding)!.simple.limit; test("minting is limited per address, and per /64 for IPv6", async () => { const mintFrom = (ip: string) => @@ -462,27 +466,26 @@ test("rate-limit keys", () => { expect(rateLimitKey("::203.0.113.9")).toBe("0:0:0:0::/64"); }); -test("the preview Worker serves the room, and the deployment smoke passes against it", async () => { - const preview = await start("server/preview-worker.ts"); - try { - const local = preview.url.href.replace(/^http/, "ws"); - // Node's own WebSocket, as the smoke runs in CI, aimed at Miniflare. - await smoke.oneTimeSmoke( - origin, - (url: string, headers: Record) => { - const { pathname, search } = new URL(url); - // Node's WebSocket takes an init with headers, which the DOM typing lacks. - const init = { - headers: { ...headers, "mf-original-url": url.replace(/^ws/, "http") }, - } as unknown as string[]; - 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) => preview.mf.dispatchFetch(url)) as unknown as typeof fetch, - ); - } finally { - await preview.mf.dispose(); - } +test("the deployment smoke passes against the relay Worker, which its 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( + origin, + (url: string, headers: Record) => { + const { pathname, search } = new URL(url); + // Node's WebSocket takes an init with headers, which the DOM typing lacks. + const init = { + headers: { + ...headers, + "mf-original-url": url.replace(/^ws/, "http"), + "cf-connecting-ip": freshIp(), + }, + } as unknown as string[]; + 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) => production.mf.dispatchFetch(url)) as unknown as typeof fetch, + ); }); test("the smoke's copies of the contract match remote-lib-common", () => { @@ -495,18 +498,18 @@ 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://hosted.dormouse.sh/connect/assets/ 'wasm-unsafe-eval'; " + - "style-src https://hosted.dormouse.sh/connect/assets/ 'unsafe-inline'; " + - "img-src https://hosted.dormouse.sh/connect/ data: blob:; " + - "font-src https://hosted.dormouse.sh/connect/; " + + "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/; " + "media-src blob:; " + - "connect-src wss://hosted.dormouse.sh/api/one-time/client; " + + "connect-src wss://relay.dormouse.sh/api/one-time/client; " + "worker-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'; " + "object-src 'none'; sandbox allow-scripts allow-same-origin"; test("the page's policy admits no source outside /connect/ but its one socket", () => { // `docs/specs/security-hosted.md`'s FAIL IF, directive by directive: `'self'` - // would admit the account frontend's own files. + // would admit whatever else the relay origin serves. const socket = `${origin.replace(/^http/, "ws")}${ONE_TIME_WS_ROUTES.client}`; for (const directive of oneTimePagePolicy(origin).split("; ")) { const [name, ...sources] = directive.split(" "); @@ -556,7 +559,7 @@ test("the page's hashed assets are immutable, and a missing one is a 404, not th } }); -test("nothing else under /connect/ is served, and the rest of the origin keeps its policy", async () => { +test("nothing else under /connect/ is served, and nothing else on the origin at all", async () => { for (const path of [ `${ONE_TIME_PAGE_PATH}index.html`, `${ONE_TIME_PAGE_PATH}other`, @@ -567,19 +570,18 @@ test("nothing else under /connect/ is served, and the rest of the origin keeps i expect(response.headers.get("content-security-policy"), path).toBe(PAGE_POLICY); } expect((await get(ONE_TIME_PAGE_PATH, { method: "POST" })).status).toBe(404); - for (const path of ["/", "/connected", "/assets/connect/x.js"]) { + // No SPA fallback: every other path is a 404 under the policy that runs nothing. + for (const path of ["/", "/connected", "/login", "/assets/connect/x.js"]) { const response = await get(path); - expect(response.headers.get("content-security-policy"), path).toContain( - "script-src 'self'", - ); - expect(response.headers.get("content-security-policy"), path).not.toContain( - "sandbox", + expect(response.status, path).toBe(404); + expect(response.headers.get("content-security-policy"), path).toBe( + RUNS_NOTHING_POLICY, ); } }); -test("a malformed APP_ORIGIN falls back to the origin's policy on the page", () => { - expect(contentSecurityPolicy("/connect/", "http://localhost:8787")).toBe( +test("a malformed APP_ORIGIN falls back to the policy that runs nothing on the page", () => { + expect(relayPolicy("/connect/", "http://localhost:8787")).toBe( oneTimePagePolicy("http://localhost:8787"), ); expect(oneTimePagePolicy("http://localhost:8787")).toContain( @@ -588,16 +590,16 @@ test("a malformed APP_ORIGIN falls back to the origin's policy on the page", () for (const bad of [ undefined, "", - "https://hosted.dormouse.sh/", - "https://hosted.dormouse.sh; script-src *", - "https://hosted.dormouse.sh 'unsafe-inline'", + "https://relay.dormouse.sh/", + "https://relay.dormouse.sh; script-src *", + "https://relay.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)", ]) - expect(contentSecurityPolicy("/connect/", bad), String(bad)).toBe( - contentSecurityPolicy("/", origin), + expect(relayPolicy("/connect/", bad), String(bad)).toBe( + RUNS_NOTHING_POLICY, ); }); diff --git a/hosted/server/tests/voice-entry.ts b/hosted/server/tests/voice-entry.ts new file mode 100644 index 000000000..f2283dc20 --- /dev/null +++ b/hosted/server/tests/voice-entry.ts @@ -0,0 +1,26 @@ +import { voiceBindings } from "../bindings"; +import { voiceApp } from "../voice-app"; +// The after-speech sweep runs within the test instead of 10 s later. +const worker = voiceApp(voiceBindings, { sweepDelayMs: 0 }); +// Counted as each request schedules it, so a test reads it without waiting. +let waitUntilCalls = 0; +export default { + fetch( + request: Request, + env: Parameters[1], + ctx: Parameters[2], + ) { + if (new URL(request.url).pathname === "/__test/wait-until") + return new Response(String(waitUntilCalls)); + const counted: typeof ctx = { + waitUntil(promise) { + waitUntilCalls++; + ctx.waitUntil(promise); + }, + passThroughOnException: () => ctx.passThroughOnException(), + props: ctx.props, + }; + return worker.fetch(request, env, counted); + }, + scheduled: worker.scheduled, +}; diff --git a/hosted/server/tests/worker-entry.ts b/hosted/server/tests/worker-entry.ts index ba5556485..9dd5ad4ef 100644 --- a/hosted/server/tests/worker-entry.ts +++ b/hosted/server/tests/worker-entry.ts @@ -1,38 +1,22 @@ import { DevTime, DevRandom } from "pgstencil"; import { deterministicScope } from "@pgstencil/auth/better-auth-testing"; -import { hostedWorker } from "../worker"; -export { OneTimeRoom } from "../worker"; -// The after-speech sweep runs within the test instead of 10 s later. -const worker = hostedWorker({ sweepDelayMs: 0 }); +import worker from "../worker"; const time = new DevTime(); const scope = { time, random: new DevRandom("dormouse-hosted-test") }; -// Counted as each request schedules it, so a test reads it without waiting. -let waitUntilCalls = 0; export default { fetch( request: Request, env: Parameters[1], ctx: Parameters[2], ) { - const { pathname } = new URL(request.url); - if (pathname === "/__test/time") { + if (new URL(request.url).pathname === "/__test/time") { return request.text().then((value) => { time.set(value); return new Response("ok"); }); } - if (pathname === "/__test/wait-until") - return new Response(String(waitUntilCalls)); - const counted: typeof ctx = { - waitUntil(promise) { - waitUntilCalls++; - ctx.waitUntil(promise); - }, - passThroughOnException: () => ctx.passThroughOnException(), - props: ctx.props, - }; return deterministicScope.run(scope, () => - worker.fetch(request, env, counted), + worker.fetch(request, env, ctx), ); }, }; diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index 69af8bc05..771f1dab1 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -25,6 +25,9 @@ import { CRON_SWEEP_CAP, SPEECH_SWEEP_CAP, VOICE_DAILY_CAP } from "../voice"; import { bundleWorker, wrangler } from "./bundle"; const origin = "https://hosted.dormouse.sh"; +const voiceOrigin = "https://voice.dormouse.sh"; +/** The sibling Workers' origins: same-site, so a browser sends them the login cookie. */ +const SAME_SITE = ["https://dormouse.sh", "https://relay.dormouse.sh", voiceOrigin]; const bundle = (production: boolean | "preview") => bundleWorker( production === "preview" @@ -43,6 +46,11 @@ const bundle = (production: boolean | "preview") => const testBundle = bundle(false); const productionBundle = bundle(true); const previewBundle = bundle("preview"); +const voiceBundles = { + test: bundleWorker("server/tests/voice-entry.ts"), + production: bundleWorker("server/voice-worker.ts"), + preview: bundleWorker("server/voice-preview-worker.ts"), +}; type Result = Awaited>; type Handler = ( request: WorkerRequest, @@ -57,17 +65,17 @@ const workerOptions = ({ }: { script: string; database: string; - assets: Handler; + assets?: Handler; bindings: Record; outboundService: Handler; }) => convertV4MiniflareOptions({ modules: true, script, - compatibilityDate: wrangler.compatibility_date, - compatibilityFlags: wrangler.compatibility_flags, + compatibilityDate: wrangler.account.compatibility_date, + compatibilityFlags: wrangler.account.compatibility_flags, hyperdrives: { HYPERDRIVE: database }, - serviceBindings: { ASSETS: assets }, + ...(assets ? { serviceBindings: { ASSETS: assets } } : {}), ...rest, }); @@ -107,6 +115,61 @@ async function fixture( bindings[`${id.toUpperCase()}_CLIENT_SECRET`] = credentials.clientSecret; } Object.assign(bindings, overrides); + const outboundService: Handler = async (request) => { + if (production === "preview") + throw new Error( + "Preview must never send external mail or OAuth requests", + ); + const url = new URL(request.url); + if (url.href === "https://api.postmarkapp.com/email") { + expect(request.headers.get("x-postmark-server-token")).toBe( + "test-token", + ); + const mail = (await request.json()) as { + To: string; + From: string; + Subject: string; + HtmlBody: string; + TextBody: string; + }; + await context.email.send({ + to: [mail.To], + from: mail.From, + subject: mail.Subject, + html: mail.HtmlBody, + text: mail.TextBody, + }); + return new WorkerResponse(JSON.stringify({ ErrorCode: 0 }), { + headers: { "content-type": "application/json" }, + }); + } + if (url.href.startsWith("https://api.elevenlabs.io/v1/history?")) { + elevenLabs.sweeps.push(url.href); + return WorkerResponse.json({ history: [] }); + } + if (url.origin === "https://api.elevenlabs.io") { + elevenLabs.requests.push({ + url: url.href, + key: request.headers.get("xi-api-key"), + body: await request.json(), + }); + return elevenLabs.respond(); + } + const path = endpointPaths[url.origin + url.pathname]; + if (!path) throw new Error(`Unexpected outbound host: ${url.hostname}`); + const response = await fetch(provider.origin + path + url.search, { + method: request.method, + headers: Object.fromEntries(request.headers), + ...(request.method === "POST" ? { body: await request.text() } : {}), + }); + return new WorkerResponse(await response.arrayBuffer(), { + status: response.status, + headers: { + "content-type": + response.headers.get("content-type") ?? "application/json", + }, + }); + }; const worker = new Miniflare( workerOptions({ script: ( @@ -131,63 +194,33 @@ async function fixture( headers: { "content-type": "text/html" }, }, ), - async outboundService(request) { - if (production === "preview") - throw new Error( - "Preview must never send external mail or OAuth requests", - ); - const url = new URL(request.url); - if (url.href === "https://api.postmarkapp.com/email") { - expect(request.headers.get("x-postmark-server-token")).toBe( - "test-token", - ); - const mail = (await request.json()) as { - To: string; - From: string; - Subject: string; - HtmlBody: string; - TextBody: string; - }; - await context.email.send({ - to: [mail.To], - from: mail.From, - subject: mail.Subject, - html: mail.HtmlBody, - text: mail.TextBody, - }); - return new WorkerResponse(JSON.stringify({ ErrorCode: 0 }), { - headers: { "content-type": "application/json" }, - }); - } - if (url.href.startsWith("https://api.elevenlabs.io/v1/history?")) { - elevenLabs.sweeps.push(url.href); - return WorkerResponse.json({ history: [] }); - } - if (url.origin === "https://api.elevenlabs.io") { - elevenLabs.requests.push({ - url: url.href, - key: request.headers.get("xi-api-key"), - body: await request.json(), - }); - return elevenLabs.respond(); - } - const path = endpointPaths[url.origin + url.pathname]; - if (!path) throw new Error(`Unexpected outbound host: ${url.hostname}`); - const response = await fetch(provider.origin + path + url.search, { - method: request.method, - headers: Object.fromEntries(request.headers), - ...(request.method === "POST" ? { body: await request.text() } : {}), - }); - return new WorkerResponse(await response.arrayBuffer(), { - status: response.status, - headers: { - "content-type": - response.headers.get("content-type") ?? "application/json", - }, - }); - }, + outboundService, }), ); + // The voice Worker on the same database, with every binding the account + // has (its mapper drops what it does not use), started on first use. + let voiceWorker: Promise | undefined; + const voice = () => + (voiceWorker ??= (async () => { + const started = new Miniflare( + workerOptions({ + script: ( + await voiceBundles[ + production === "preview" + ? "preview" + : production + ? "production" + : "test" + ] + ).outputFiles![0].text, + bindings: { ...bindings, APP_ORIGIN: voiceOrigin }, + database: context.database.url, + outboundService, + }), + ); + await started.ready; + return started; + })()); try { await worker.ready; } catch (error) { @@ -290,11 +323,12 @@ async function fixture( } return { path, result: await request(path) }; } - // Token management is same-origin JSON; speak is bearer-only, with no cookie. - const voice = (method: string, path = "") => + // Token management is same-origin JSON on the account; speak is + // bearer-only on the voice Worker, with no cookie. + const tokens = (method: string, path = "") => request("/api/voice/tokens" + path, { method, headers: { origin } }); - const speak = (token: string | undefined, body: unknown) => - worker.dispatchFetch(origin + "/api/voice/speak", { + const speak = async (token: string | undefined, body: unknown) => + (await voice()).dispatchFetch(voiceOrigin + "/api/voice/speak", { method: "POST", headers: { "content-type": "application/json", @@ -303,8 +337,8 @@ async function fixture( body: typeof body === "string" ? body : JSON.stringify(body), }); const mint = async () => - (await (await voice("POST")).json()) as { id: string; token: string }; - return { request, post, session, email, oauth, voice, speak, mint }; + (await (await tokens("POST")).json()) as { id: string; token: string }; + return { request, post, session, email, oauth, tokens, speak, mint }; } return { ...context, @@ -317,15 +351,16 @@ async function fixture( method: "POST", body: time, }), - /** Background passes scheduled so far; the test entry only. */ + /** Background passes the voice Worker scheduled so far; the test entry only. */ waitUntilCalls: async () => Number( await ( - await worker.dispatchFetch(origin + "/__test/wait-until") + await (await voice()).dispatchFetch(voiceOrigin + "/__test/wait-until") ).text(), ), close: async () => { await worker.dispose(); + await (await voiceWorker)?.dispose(); await provider.close(); await context.close(); }, @@ -408,7 +443,7 @@ test.for(providerIds)( }, ); -test("same-site marketing requests fail; production excludes dev endpoints and unconfigured providers", async ({ +test("same-site requests fail, the sibling Workers' included; production excludes dev endpoints and unconfigured providers", async ({ onTestFinished, }) => { const f = await fixture(true, "github"); @@ -417,15 +452,17 @@ test("same-site marketing requests fail; production excludes dev endpoints and u expect(await (await browser.request("/api/providers")).json()).toEqual([ "github", ]); - expect( - ( - await browser.post( - "email-otp/send-verification-otp", - { email: "x@example.test", type: "sign-in" }, - "https://dormouse.sh", - ) - ).status, - ).toBe(403); + for (const sameSite of SAME_SITE) + expect( + ( + await browser.post( + "email-otp/send-verification-otp", + { email: "x@example.test", type: "sign-in" }, + sameSite, + ) + ).status, + sameSite, + ).toBe(403); const shell = await browser.request("/login"); expect(shell.headers.get("content-security-policy")).toContain( "script-src 'self'", @@ -459,9 +496,11 @@ test("same-site marketing requests fail; production excludes dev endpoints and u "/api/auth/revoke-sessions", ]) expect((await browser.request(path)).status).toBe(404); - expect( - (await f.worker.dispatchFetch("https://dormouse.sh/api/auth/csrf")).status, - ).toBe(421); + for (const sameSite of SAME_SITE) + expect( + (await f.worker.dispatchFetch(sameSite + "/api/auth/csrf")).status, + sameSite, + ).toBe(421); await browser.email("real-clock@example.test"); expect( Math.abs( @@ -569,23 +608,25 @@ test("managed voice: only the verified admin mints, speaks, and revokes", async onTestFinished(f.close); const admin = f.browser(), other = f.browser(); - expect((await admin.voice("GET")).status).toBe(401); + expect((await admin.tokens("GET")).status).toBe(401); await other.email("other@example.test"); - expect((await other.voice("GET")).status).toBe(403); - expect((await other.voice("POST")).status).toBe(403); + expect((await other.tokens("GET")).status).toBe(403); + expect((await other.tokens("POST")).status).toBe(403); await admin.email(ADMIN_EMAIL); - expect(await (await admin.voice("GET")).json()).toEqual({ tokens: [] }); - // A same-site page carries the cookie but not this origin. - expect( - ( - await admin.request("/api/voice/tokens", { - method: "POST", - headers: { origin: "https://dormouse.sh" }, - }) - ).status, - ).toBe(403); + expect(await (await admin.tokens("GET")).json()).toEqual({ tokens: [] }); + // A same-site page — a sibling Worker's included — carries the cookie but not this origin. + for (const sameSite of SAME_SITE) + expect( + ( + await admin.request("/api/voice/tokens", { + method: "POST", + headers: { origin: sameSite }, + }) + ).status, + sameSite, + ).toBe(403); - const minted = await admin.voice("POST"); + const minted = await admin.tokens("POST"); expect(minted.status).toBe(201); const { id, token } = (await minted.json()) as { id: string; token: string }; expect(token).toMatch(/^dmv_[A-Za-z0-9_-]{43}$/); @@ -620,7 +661,7 @@ test("managed voice: only the verified admin mints, speaks, and revokes", async ]), ); const [listed] = ( - (await (await admin.voice("GET")).json()) as { + (await (await admin.tokens("GET")).json()) as { tokens: { id: string; lastUsedAt: string | null; revokedAt: null }[]; } ).tokens; @@ -689,11 +730,21 @@ test("managed voice: only the verified admin mints, speaks, and revokes", async // No refused or failed speech (400, 401, 403, 429, 502) scheduled a sweep. expect(await f.waitUntilCalls()).toBe(1); - expect((await admin.voice("DELETE", "/" + id)).status).toBe(204); + for (const sameSite of SAME_SITE) + expect( + ( + await admin.request("/api/voice/tokens/" + id, { + method: "DELETE", + headers: { origin: sameSite }, + }) + ).status, + sameSite, + ).toBe(403); + expect((await admin.tokens("DELETE", "/" + id)).status).toBe(204); expect((await admin.speak(token, hi)).status).toBe(401); - expect((await admin.voice("DELETE", "/not-a-token")).status).toBe(404); + expect((await admin.tokens("DELETE", "/not-a-token")).status).toBe(404); expect( - (await admin.voice("DELETE", "/00000000-0000-4000-8000-000000000000")) + (await admin.tokens("DELETE", "/00000000-0000-4000-8000-000000000000")) .status, ).toBe(404); @@ -704,8 +755,8 @@ test("managed voice: only the verified admin mints, speaks, and revokes", async `UPDATE "user" SET "emailVerified" = false WHERE email = $1`, [ADMIN_EMAIL], ); - expect((await admin.voice("GET")).status).toBe(403); - expect((await admin.voice("POST")).status).toBe(403); + expect((await admin.tokens("GET")).status).toBe(403); + expect((await admin.tokens("POST")).status).toBe(403); expect( (await admin.speak(second.token, hi)).status, ).toBe(403); @@ -730,7 +781,7 @@ test("sweep caps fit Workers Free's 50 subrequests per invocation", () => { expect(1 + 1 + 1 + SPEECH_SWEEP_CAP).toBeLessThanOrEqual(50); }); -test("production cron sweeps ElevenLabs history with only its key and no database", async ({ +test("the voice Worker's cron sweeps ElevenLabs history with only its key and no database", async ({ onTestFinished, }) => { // Simulated history, newest first. @@ -747,12 +798,11 @@ test("production cron sweeps ElevenLabs history with only its key and no databas const sweeper = async (bindings: Record) => { const worker = new Miniflare( workerOptions({ - script: (await productionBundle).outputFiles![0].text, - // No AUTH_SECRET, APP_ORIGIN, or mail and OAuth credentials. + script: (await voiceBundles.production).outputFiles![0].text, + // No APP_ORIGIN. bindings, // Nothing listens here, so any database access would fail the run. database: "postgres://user:pass@127.0.0.1:9/none", - assets: () => new WorkerResponse("", { status: 500 }), async outboundService(request) { const url = new URL(request.url); expect(url.origin).toBe("https://api.elevenlabs.io"); diff --git a/hosted/server/voice-app.ts b/hosted/server/voice-app.ts new file mode 100644 index 000000000..3b55a54a7 --- /dev/null +++ b/hosted/server/voice-app.ts @@ -0,0 +1,45 @@ +import type { ExecutionContext } from "hono"; +import type { VoiceEnv } from "./bindings"; +import { RUNS_NOTHING_POLICY } from "./headers"; +import { elevenLabs, speakRoute, sweepOnCron } from "./voice"; +import { workerApp } from "./worker-app"; + +/** + * The voice Worker (`voice.dormouse.sh`): bearer-token speech, and the Cron + * Trigger's ElevenLabs history sweep. No cookie, no auth, no assets. + * `sweepDelayMs` exists for the test entry; production keeps the default. + */ +export function voiceApp( + bindings: (env: VoiceEnv) => VoiceEnv, + { sweepDelayMs }: { sweepDelayMs?: number } = {}, +) { + const app = workerApp({ + bindings, + policy: () => RUNS_NOTHING_POLICY, + unavailable: "Managed voice is temporarily unavailable. Please try again.", + routes(app) { + speakRoute(app, (c) => { + const key = c.env.ELEVENLABS_API_KEY; + return { + databaseUrl: c.env.HYPERDRIVE.connectionString, + synthesize: key + ? elevenLabs( + key, + (pass) => c.executionCtx.waitUntil(pass), + sweepDelayMs, + ) + : undefined, + }; + }); + }, + }); + return { + fetch: (request: Request, env: VoiceEnv, ctx: ExecutionContext) => + app.fetch(request, env, ctx), + // The Cron Trigger: the same mapper decides whether a key reaches the sweep. + async scheduled(_controller: unknown, env: VoiceEnv) { + const key = bindings(env).ELEVENLABS_API_KEY; + if (key) await sweepOnCron(key); + }, + }; +} diff --git a/hosted/server/voice-preview-worker.ts b/hosted/server/voice-preview-worker.ts new file mode 100644 index 000000000..82d5dc1a1 --- /dev/null +++ b/hosted/server/voice-preview-worker.ts @@ -0,0 +1,5 @@ +import { voicePreviewBindings } from "./bindings"; +import { voiceApp } from "./voice-app"; + +/** The voice Worker's PR preview: no ElevenLabs key, so speak answers 503 and nothing sweeps. */ +export default voiceApp(voicePreviewBindings); diff --git a/hosted/server/voice-worker.ts b/hosted/server/voice-worker.ts new file mode 100644 index 000000000..39799b2fa --- /dev/null +++ b/hosted/server/voice-worker.ts @@ -0,0 +1,5 @@ +import { voiceBindings } from "./bindings"; +import { voiceApp } from "./voice-app"; + +/** The voice Worker, `dormouse-voice` on `voice.dormouse.sh`. */ +export default voiceApp(voiceBindings); diff --git a/hosted/server/voice.ts b/hosted/server/voice.ts index 991f94e08..6b9f1c82b 100644 --- a/hosted/server/voice.ts +++ b/hosted/server/voice.ts @@ -40,11 +40,16 @@ export function elevenLabs( }; } -/** What one request's deployment provides. */ -export interface VoiceHost { +/** What one request's account deployment provides to the token routes. */ +export interface TokenHost { databaseUrl: string; /** The Better Auth handler, asked for the cookie's login. */ auth(request: Request): Response | Promise; +} + +/** What one request's voice deployment provides to speak. */ +export interface SpeakHost { + databaseUrl: string; /** Undefined when this deployment has no upstream. */ synthesize: Synthesize | undefined; } @@ -61,10 +66,17 @@ const notAdmin = (c: Context) => const badBody = (c: Context) => fail(c, 400, "Send JSON with text and voiceId."); -/** Registers /api/voice/*; call before any /api/* catch-all. */ -export function voiceRoutes(app: Hono, host: (c: Context) => VoiceHost) { - // Cookie routes: same-site pages share the login cookie, so only this origin - // may change tokens. Sets `voiceUser` to the admin's user ID. +/** + * Registers the account's /api/voice/tokens routes; call before any /api/* + * catch-all. + */ +export function voiceTokenRoutes( + app: Hono, + host: (c: Context) => TokenHost, +) { + // Cookie routes: same-site pages — the relay and voice origins among them — + // share the login cookie, so only this origin may change tokens. Sets + // `voiceUser` to the admin's user ID. const cookieAdmin: MiddlewareHandler<{ Variables: { voiceUser: string }; }> = async (c, next) => { @@ -133,7 +145,13 @@ export function voiceRoutes(app: Hono, host: (c: Context) => VoiceHost) { ).length > 0; return revoked ? c.body(null, 204) : fail(c, 404, "Token not found."); }); +} +/** + * Registers the voice origin's bearer-only POST /api/voice/speak; call before + * any /api/* catch-all. It reads no cookie and never asks auth. + */ +export function speakRoute(app: Hono, host: (c: Context) => SpeakHost) { app.post("/api/voice/speak", async (c) => { const bearer = /^Bearer (\S+)$/.exec(c.req.header("authorization") ?? ""); if (!bearer || !TOKEN.test(bearer[1])) return tokenRequired(c); diff --git a/hosted/server/worker-app.ts b/hosted/server/worker-app.ts index a21c46c7a..1bfe31c6c 100644 --- a/hosted/server/worker-app.ts +++ b/hosted/server/worker-app.ts @@ -1,90 +1,50 @@ -import { Hono, type ExecutionContext } from "hono"; -import type { Env } from "./worker"; -import { queryDatabase } from "pgstencil/postgres"; -import { secureHeaders } from "./headers"; -import { oneTimePageRoutes, oneTimeRoutes } from "./one-time"; -import { elevenLabs, sweepOnCron, voiceRoutes } from "./voice"; +import { Hono } from "hono"; +import { secureHeaders, type PolicyFor } from "./headers"; -export function workerApp( - fetchAuth: ( - request: Request, - env: Env, - ctx: ExecutionContext, - ) => Response | Promise, - bindings: (env: Env) => Env, - { - configure, - sweepDelayMs, - }: { - configure?: (app: Hono<{ Bindings: Env }>) => void; - /** Delay of the history sweep after a successful speech. */ - sweepDelayMs?: number; - } = {}, -) { - const app = new Hono<{ Bindings: Env }>(); - secureHeaders(app); - // The mapper alone decides which bindings reach auth and the routes; resolving - // it in the request keeps a misconfigured deployment on onError, headers and all. +/** + * What all three Workers share (`docs/specs/hosted.md` -> "Application + * boundary"): the secure headers, the bindings mapper, the 421 gate, `/api/health`, + * the 404 tail, and `onError`. Each entry mounts only its own routes, between + * the gate and the tail, and its `fallback` after the tail. + */ +export function workerApp({ + bindings, + policy, + routes, + fallback, + unavailable, +}: { + /** The only bindings that reach the routes. */ + bindings: (env: E) => E; + policy: PolicyFor; + routes: (app: Hono<{ Bindings: E }>) => void; + /** Answers what nothing else did; without one, Hono's 404. */ + fallback?: (app: Hono<{ Bindings: E }>) => void; + /** `onError`'s message. */ + unavailable: string; +}) { + const app = new Hono<{ Bindings: E }>(); + secureHeaders(app, policy); + // The mapper alone decides which bindings reach the routes; resolving it in + // the request keeps a misconfigured deployment on onError, headers and all. app.use("*", async (c, next) => { c.env = bindings(c.env); await next(); }); app.use("*", async (c, next) => { - // A candidate/preview hostname must never act as an alias for production auth. + // A candidate/preview hostname, or a sibling Worker's, must never act as an alias for this one. if (new URL(c.req.url).origin !== c.env.APP_ORIGIN) return c.json({ message: "Unknown origin." }, 421); await next(); }); - oneTimeRoutes(app); - oneTimePageRoutes(app); - configure?.(app); + routes(app); app.get("/api/health", (c) => c.json({ ok: true, revision: c.env.BUILD_SHA ?? null }), ); - app.get("/api/ready", async (c) => { - const ok = await queryDatabase( - c.env.HYPERDRIVE.connectionString, - 'SELECT "singleSession", "emailAuthenticated" FROM "session" LIMIT 0', - ).then( - () => true, - () => false, - ); - return c.json({ ok }, ok ? 200 : 503); - }); - app.all("/api/auth/*", (c) => fetchAuth(c.req.raw, c.env, c.executionCtx)); - app.get("/api/providers", (c) => fetchAuth(c.req.raw, c.env, c.executionCtx)); - voiceRoutes(app, (c) => { - const key = c.env.ELEVENLABS_API_KEY; - return { - databaseUrl: c.env.HYPERDRIVE.connectionString, - auth: (request) => fetchAuth(request, c.env, c.executionCtx), - synthesize: key - ? elevenLabs( - key, - (pass) => c.executionCtx.waitUntil(pass), - sweepDelayMs, - ) - : undefined, - }; - }); app.all("/api/*", (c) => c.json({ message: "Not found." }, 404)); app.all("/dev/*", (c) => c.notFound()); app.all("/__test/*", (c) => c.notFound()); - app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw)); - app.onError((_error, c) => - c.json( - { message: "Sign-in is temporarily unavailable. Please try again." }, - 503, - ), - ); - - return { - fetch: (request: Request, env: Env, ctx: ExecutionContext) => - app.fetch(request, env, ctx), - // The Cron Trigger: the same mapper decides whether a key reaches the sweep. - async scheduled(_controller: unknown, env: Env) { - const key = bindings(env).ELEVENLABS_API_KEY; - if (key) await sweepOnCron(key); - }, - }; + fallback?.(app); + app.onError((_error, c) => c.json({ message: unavailable }, 503)); + return app; } diff --git a/hosted/server/worker.ts b/hosted/server/worker.ts index baf9ba954..8a2de7bd8 100644 --- a/hosted/server/worker.ts +++ b/hosted/server/worker.ts @@ -1,51 +1,16 @@ -import { - createBetterAuthWorker, - type BetterAuthWorkerBindings, -} from "@pgstencil/auth/better-auth-workers"; +import { createBetterAuthWorker } from "@pgstencil/auth/better-auth-workers"; import { postmarkEmail } from "@pgstencil/auth/postmark"; -import { authPolicy, providerBindings } from "./policy"; -import { workerApp } from "./worker-app"; +import { accountApp } from "./account-app"; +import { accountBindings, type AccountEnv } from "./bindings"; +import { authPolicy } from "./policy"; -export interface Env extends BetterAuthWorkerBindings { - ASSETS: { fetch(request: Request): Promise }; - EMAIL_FROM: string; - POSTMARK_SERVER_TOKEN: string; - ELEVENLABS_API_KEY?: string; - OAUTH_PROVIDERS?: string; - BUILD_SHA?: string; - ONE_TIME_ROOM: DurableObjectNamespace; - ONE_TIME_MINT_LIMIT: RateLimit; - ONE_TIME_JOIN_LIMIT: RateLimit; -} - -const auth = createBetterAuthWorker({ +/** The account Worker, `dormouse-hosted` on `hosted.dormouse.sh`. */ +const auth = createBetterAuthWorker({ ...authPolicy, email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM), }); -/** `sweepDelayMs` exists for the test entry; production keeps the default. */ -export function hostedWorker({ sweepDelayMs }: { sweepDelayMs?: number } = {}) { - return workerApp( - (request, env, ctx) => auth.fetch(request, env, ctx), - // A rejected allowlist throws inside the request path, where app.onError - // answers it, and fails the cron run. - (env) => ({ - HYPERDRIVE: env.HYPERDRIVE, - ASSETS: env.ASSETS, - APP_ORIGIN: env.APP_ORIGIN, - AUTH_SECRET: env.AUTH_SECRET, - EMAIL_FROM: env.EMAIL_FROM, - POSTMARK_SERVER_TOKEN: env.POSTMARK_SERVER_TOKEN, - ELEVENLABS_API_KEY: env.ELEVENLABS_API_KEY, - BUILD_SHA: env.BUILD_SHA, - ONE_TIME_ROOM: env.ONE_TIME_ROOM, - ONE_TIME_MINT_LIMIT: env.ONE_TIME_MINT_LIMIT, - ONE_TIME_JOIN_LIMIT: env.ONE_TIME_JOIN_LIMIT, - ...providerBindings(env as unknown as Record), - }), - { sweepDelayMs }, - ); -} - -export { OneTimeRoom } from "./one-time-room"; -export default hostedWorker(); +export default accountApp( + (request, env, ctx) => auth.fetch(request, env, ctx), + accountBindings, +); diff --git a/hosted/wrangler.jsonc b/hosted/wrangler.jsonc index e47da114a..c5afa26f9 100644 --- a/hosted/wrangler.jsonc +++ b/hosted/wrangler.jsonc @@ -31,44 +31,22 @@ "id": "5bfbeba081b4456f9c3cf36edbb20c1d" } ], - "durable_objects": { - "bindings": [ - { - "name": "ONE_TIME_ROOM", - "class_name": "OneTimeRoom" - } - ] - }, "migrations": [ { "tag": "v1", "new_sqlite_classes": [ "OneTimeRoom" ] - } - ], - "ratelimits": [ - { - "name": "ONE_TIME_MINT_LIMIT", - "namespace_id": "1", - "simple": { - "limit": 10, - "period": 60 - } }, { - "name": "ONE_TIME_JOIN_LIMIT", - "namespace_id": "2", - "simple": { - "limit": 30, - "period": 60 - } + "tag": "v2", + "deleted_classes": [ + "OneTimeRoom" + ] } ], "triggers": { - "crons": [ - "*/5 * * * *" - ] + "crons": [] }, "observability": { "enabled": false diff --git a/hosted/wrangler.relay.jsonc b/hosted/wrangler.relay.jsonc new file mode 100644 index 000000000..c336dcb1e --- /dev/null +++ b/hosted/wrangler.relay.jsonc @@ -0,0 +1,63 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "dormouse-relay", + "main": "server/relay-worker.ts", + "compatibility_date": "2026-09-08", + "compatibility_flags": [ + "nodejs_compat" + ], + "workers_dev": false, + "preview_urls": false, + "routes": [ + { + "pattern": "relay.dormouse.sh", + "custom_domain": true + } + ], + "assets": { + "directory": "./dist-relay", + "binding": "ASSETS", + "not_found_handling": "none", + "run_worker_first": true + }, + "vars": { + "APP_ORIGIN": "https://relay.dormouse.sh" + }, + "durable_objects": { + "bindings": [ + { + "name": "ONE_TIME_ROOM", + "class_name": "OneTimeRoom" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": [ + "OneTimeRoom" + ] + } + ], + "ratelimits": [ + { + "name": "ONE_TIME_MINT_LIMIT", + "namespace_id": "1", + "simple": { + "limit": 10, + "period": 60 + } + }, + { + "name": "ONE_TIME_JOIN_LIMIT", + "namespace_id": "2", + "simple": { + "limit": 30, + "period": 60 + } + } + ], + "observability": { + "enabled": false + } +} diff --git a/hosted/wrangler.voice.jsonc b/hosted/wrangler.voice.jsonc new file mode 100644 index 000000000..7e39b402c --- /dev/null +++ b/hosted/wrangler.voice.jsonc @@ -0,0 +1,34 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "dormouse-voice", + "main": "server/voice-worker.ts", + "compatibility_date": "2026-09-08", + "compatibility_flags": [ + "nodejs_compat" + ], + "workers_dev": false, + "preview_urls": false, + "routes": [ + { + "pattern": "voice.dormouse.sh", + "custom_domain": true + } + ], + "vars": { + "APP_ORIGIN": "https://voice.dormouse.sh" + }, + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "5bfbeba081b4456f9c3cf36edbb20c1d" + } + ], + "triggers": { + "crons": [ + "*/5 * * * *" + ] + }, + "observability": { + "enabled": false + } +} diff --git a/package.json b/package.json index 9786a606a..8322730c3 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build:hosted": "pnpm --filter dormouse-hosted build", "test:hosted": "pnpm --filter dormouse-hosted test", "build": "pnpm run build:vscode && pnpm --filter dormouse-lib build:pocket && pnpm --filter dormouse-website build && pnpm build:hosted", - "test": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs && node scripts/public-docs-lint.mjs && node scripts/xterm-lint.mjs && node --test scripts/xterm-bump.test.mjs && node scripts/loopback-lint.mjs && node scripts/loopback-lint-selftest.mjs && node scripts/deploy-lint.mjs && node scripts/deploy-lint-selftest.mjs && node scripts/installer-verify-test.mjs && node scripts/ps1-cmdlet-lint.mjs && node scripts/ps1-cmdlet-lint-selftest.mjs && node scripts/e2e-lint.mjs && node scripts/e2e-lint-selftest.mjs && node scripts/clamp-issue-body-selftest.mjs && node --test scripts/sign-and-deploy.test.mjs && node --test scripts/workflow-audit.test.mjs && node --test scripts/pairing-walkthrough/proc.test.mjs && node --test scripts/security-audit.test.mjs && pnpm --filter dormouse-hosted test:deploy && pnpm --filter dormouse-hosted test:one-time && pnpm -r --filter \"!dormouse-hosted\" run test", + "test": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs && node scripts/public-docs-lint.mjs && node scripts/xterm-lint.mjs && node --test scripts/xterm-bump.test.mjs && node scripts/loopback-lint.mjs && node scripts/loopback-lint-selftest.mjs && node scripts/deploy-lint.mjs && node scripts/deploy-lint-selftest.mjs && node scripts/installer-verify-test.mjs && node scripts/ps1-cmdlet-lint.mjs && node scripts/ps1-cmdlet-lint-selftest.mjs && node scripts/e2e-lint.mjs && node scripts/e2e-lint-selftest.mjs && node scripts/clamp-issue-body-selftest.mjs && node --test scripts/sign-and-deploy.test.mjs && node --test scripts/workflow-audit.test.mjs && node --test scripts/pairing-walkthrough/proc.test.mjs && node --test scripts/security-audit.test.mjs && pnpm --filter dormouse-hosted test:deploy && pnpm --filter dormouse-hosted test:miniflare && pnpm -r --filter \"!dormouse-hosted\" run test", "lint:specs": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs", "lint:public-docs": "node scripts/public-docs-lint.mjs", "audit:prose": "node scripts/prose-audit.mjs", From 8a90072abae1407cc074d26ce74bcaf37f2be6a2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 22:21:55 -0700 Subject: [PATCH 2/7] Bake relay.dormouse.sh as the Hosted relay origin, voice at voice.dormouse.sh Co-Authored-By: Claude Opus 5.5 --- SELF_HOST.md | 4 +- docs/specs/alert.md | 2 +- docs/specs/relay.md | 40 ++++++++++-------- docs/specs/relay.rationale.md | 2 + docs/specs/remote-network.md | 5 +-- docs/specs/remote-network.rationale.md | 2 +- docs/specs/security-local.md | 4 +- docs/specs/security-remote.md | 5 ++- docs/stories/pairing.mdx | 4 +- .../components/ManagedVoiceSection.test.tsx | 2 +- lib/src/components/ManagedVoiceSection.tsx | 2 +- .../components/RemoteControlSection.test.tsx | 8 ++-- lib/src/host/managed-voice-host.test.ts | 12 ++++-- lib/src/host/managed-voice-host.ts | 6 +-- lib/src/host/relay-origin.test.ts | 41 +++++++++++++------ lib/src/host/relay-origin.ts | 30 ++++++++++---- lib/src/host/remote/service.test.ts | 2 +- .../remote/one-time-app/OneTimeApp.test.tsx | 2 +- remote-lib-common/test/one-time-link.test.mjs | 20 ++++----- scripts/relay-origin.mjs | 4 +- scripts/spec-word-budgets.json | 6 +-- vscode-ext/test/burrow.test.ts | 4 +- 22 files changed, 124 insertions(+), 83 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index 7ad9aee4a..28f173599 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -110,8 +110,8 @@ installed release, which the installer and `manage status` both print. DORMOUSE_RELAY_ORIGIN=https://..ts.net pnpm dogfood:vscode ``` - That is a self-host build: it sends nothing to `dormouse.sh` or - `hosted.dormouse.sh` on its own, so it has no one-time connection, no managed + That is a self-host build: it sends nothing to `dormouse.sh` or any host + under it on its own, so it has no one-time connection, no managed voice, and no auto-update — update it by rebuilding (`docs/specs/relay.md` → "Relay origin"). diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 5d4586f4d..f7db43165 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -353,7 +353,7 @@ Source of truth: `toSpokenText` / `startAlertSpeech` in `lib/src/lib/alert-speec Every rule above holds for both engines. - **Must try managed voice first while the adapter's `managedVoice` status says a token is saved** (`docs/specs/transport.md` → "Managed voice"); otherwise the utterance goes straight to Web Speech. -- **A self-host build's host has no Hosted origin** and makes no request (`docs/specs/relay.md` → "Relay origin"), **nor does any host under the network policy's `nothing`** (`docs/specs/remote-network.md` → "Policy"). +- **A Hosted build's host speaks at `https://voice.dormouse.sh`; a self-host build's has no voice origin** and makes no request (`docs/specs/relay.md` → "Relay origin"), **nor does any host under the network policy's `nothing`** (`docs/specs/remote-network.md` → "Policy"). - **Must fall back to Web Speech for the same utterance, inside the same attempt, on any failure before managed audio starts** — `unconfigured`, offline, non-2xx, host timeout, undecodable or refused playback. **Never play both**: audio that started and then failed ends the attempt instead (rationale). Nothing is retried. - `speaking` / `spoken` follow the audio element's `playing` / `ended`. **Cut-off and teardown must stop the audio**; a request still in flight runs out in the host and its answer is ignored. - **Never let the voice token reach a renderer**; the host adds it to the request (rationale). Where it may go: `docs/specs/security-local.md` → "Persisted state". diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 3491a33d9..65f67b48e 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -119,14 +119,15 @@ webview CSPs carry no relay sources** (`docs/specs/vscode.md` → "CSP policy"; `standalone/scripts/tauri-conf.test.mjs`). The origin sets the build's mode (rationale): -| `DORMOUSE_RELAY_ORIGIN` | Mode | Relay | One-time connection, managed voice | Standalone auto-update | -| --- | --- | --- | --- | --- | -| unset, or `https://hosted.dormouse.sh` | Hosted | Hosted's, which runs none yet | at this origin | on | -| any other accepted origin | self-host | exactly this origin | off | off | +| `DORMOUSE_RELAY_ORIGIN` | Mode | Relay | One-time connection | Managed voice | Standalone auto-update | +| --- | --- | --- | --- | --- | --- | +| unset, or `https://relay.dormouse.sh` | Hosted | Hosted's, which runs none yet | at this origin | 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 `hosted.dormouse.sh` or `dormouse.sh` +- **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` and does nothing on `null`**: no + feature takes the nullable `hostedOrigin` or `hostedVoiceOrigin` and does + nothing on `null`**: no rendezvous (`docs/specs/one-time.md` → "Service and hosts"), no managed voice (`docs/specs/alert.md` → "Managed voice"). The standalone webview never checks for updates (`docs/specs/auto-update.md` → "How it works"), and its binary has @@ -148,6 +149,18 @@ webview CSPs carry no relay sources** (`docs/specs/vscode.md` → "CSP policy"; - **A retired variable set non-blank fails the build**: `DORMOUSE_REMOTE_CONNECT_SRC`, `DORMOUSE_HOSTED_ORIGIN`, `DORMOUSE_ONE_TIME_ORIGIN`. +- **Managed voice's origin is the constant `HOSTED_VOICE_ORIGIN`, never baked + or overridden**, a loopback dev Hosted build included (rationale). +- **No build bakes `hosted.dormouse.sh`, the account's origin**; the desktop + reaches it only by a user's click. +- **Never serve the account from an origin serving Pocket or `/connect/`**, + which render terminal output. Reserved: the Hosted Relay serves + `relay.dormouse.sh` over TLS with Pocket at its root, never a tailnet or + per-tenant host, passkeys binding to the served origin ("Scope: + saas-multitenant"). +- **Sibling `dormouse.sh` origins are same-site**: the account's `SameSite=Lax` + cookie rides requests from `relay.` and `voice.`, which only the cookie + routes' exact-`Origin` check refuses (`docs/specs/security-hosted.md`). **The Burrow composes every Relay URL from the baked origin and takes none as input**: `enroll` and `enrollOffer` post to it, carrying no Relay URL, and **a @@ -181,7 +194,8 @@ origin. Source of truth: `resolveRelayOrigin` and `assertRelayOriginBaked` in `scripts/relay-origin.mjs`; `isAcceptedRelayOrigin` in `remote-lib-common/src/security/one-time-link.ts`; `standalone/vite.config.ts`; `bakedRelay`, -`bakedRelayMode`, and `hostedOrigin` in `lib/src/host/relay-origin.ts`; +`bakedRelayMode`, `hostedOrigin`, `hostedVoiceOrigin`, and `HOSTED_VOICE_ORIGIN` in +`lib/src/host/relay-origin.ts`; `BurrowService`, `canEnroll`, and `loadEnrollmentFor` in `lib/src/host/remote/service.ts`. Pinned by `lib/src/host/relay-origin.test.ts` and `lib/src/host/remote/service.test.ts`. @@ -1109,7 +1123,7 @@ Unstaged but adjacent: origin migration (re-binding the passkey and enrollments after a Tailscale node rename), and the revocation UI staged in [remote-security-model.md](./remote-security-model.md) `## Future`. -**Scope: saas-multitenant** — Hosted accounts, Burrow enrollment, and tenant isolation for the managed Relay on `hosted.dormouse.sh`. Hosted identity belongs to [hosted.md](./hosted.md). The **remote-network** scope in [remote-network.md](./remote-network.md) owns the deployment, transport, and network restriction design; Pocket serving remains staged in [pocket-app.md](./pocket-app.md) `## Future`. +**Scope: saas-multitenant** — Hosted accounts, Burrow enrollment, and tenant isolation for the managed Relay on `relay.dormouse.sh` ("Relay origin"). Hosted identity belongs to [hosted.md](./hosted.md). The **remote-network** scope in [remote-network.md](./remote-network.md) owns the deployment, transport, and network restriction design; Pocket serving remains staged in [pocket-app.md](./pocket-app.md) `## Future`. ### From single-owner to multi-tenant @@ -1128,13 +1142,3 @@ that lifts each single-tenant simplification, every one chosen to be liftable: merely unauthorized. Defense-in-depth: the Burrow still authorizes, but the relay must not be the weak point. * **Hosted transport.** Follow the **remote-network** scope in [remote-network.md](./remote-network.md) for routing and lifecycle. - -### The one-origin pin — the constraint everything obeys - -Two things above the fold combine into one hard constraint: the stock Burrow -reaches exactly `https://hosted.dormouse.sh` ("Relay origin"), and passkeys bind -to the served origin with Pocket served same-origin at its root -([pocket-app.md](./pocket-app.md)). The SaaS Relay and its Pocket must therefore -serve that origin over TLS, whose root Hosted's account app holds today. A raw -`100.x` tailnet IP, a `*.ts.net` MagicDNS name, or a per-tenant subdomain is a -different origin, breaking both the pin and the passkey binding. diff --git a/docs/specs/relay.rationale.md b/docs/specs/relay.rationale.md index 8b4fffda6..75a79f810 100644 --- a/docs/specs/relay.rationale.md +++ b/docs/specs/relay.rationale.md @@ -32,6 +32,8 @@ **Why a release build refuses the Hosted flag and a loopback origin.** The flag turns on Hosted behavior — the voice token, the rendezvous — for an origin Dormouse may not operate; that is useful against a local `pnpm dev:hosted` or a PR preview and wrong in anything installed. A loopback `http:` origin in an installed binary points it at whatever listens on that port of the user's own machine. +**Why managed voice speaks at a fixed origin rather than a baked one.** A second baked value would be the drift `DORMOUSE_HOSTED_ORIGIN` was (above): a variable that must agree with the relay origin, or a feature goes dark. Voice has no self-host counterpart, so nothing needs to point it elsewhere; a dev Hosted build on loopback speaks to the real service. It is its own origin, not the relay's, so the Worker holding `ELEVENLABS_API_KEY` serves nothing else. + **Why an enrollment for another origin is kept rather than deleted.** The `burrowToken` in it cannot be re-minted without the setup password, so a user moving between a stock build and a self-host build — or between two dogfood builds — would lose every pairing on each switch. Reading it as none is enough: nothing connects to an origin the build was not baked with. **The build-time guards.** A lost esbuild `define` compiles fine, surfacing only as a Burrow quietly using the shipped default — Hosted — instead of the self-hoster's Relay: a build that looks correct and has no Relay at all, so `assertRelayOriginBaked` greps the emitted bundle for the value. An origin outside the accepted rule would never match what the runtime composes, and a retired variable left over from older instructions would build a stock Hosted binary without a word, so `resolveRelayOrigin` fails the build on both. diff --git a/docs/specs/remote-network.md b/docs/specs/remote-network.md index e1488f7df..d809e22f4 100644 --- a/docs/specs/remote-network.md +++ b/docs/specs/remote-network.md @@ -80,13 +80,13 @@ The Settings dialog's Network topic (`docs/specs/alert.md` -> "Settings dialog") | Row | Listed when | |---|---| -| the Hosted origin: only while a one-time link is open, handshakes and never terminal traffic | `opensOneTimeLinks` (`local`, a network allowed; `anywhere`) | +| the relay origin: only while a one-time link is open, handshakes and never terminal traffic | `opensOneTimeLinks` (`local`, a network allowed; `anywhere`) | | `stun.cloudflare.com`, as a phone connects | `burrowUsesStun` (`anywhere`) | | your phone, on any network where `phoneOnAnyNetwork` (`anywhere`), else an allowed one | `opensOneTimeLinks` | | the Relay origin: always (unenrolled, "once this computer is enrolled") | `relay` | | your phone, directly | `relay` | | the Relay origin to the phone's push service, "where push is on" | `relay`, a phone paired (rationale) | -| the Hosted origin, speaking in the managed voice | a Hosted build, a voice token saved | +| `voice.dormouse.sh`, speaking in the managed voice | a Hosted build, a voice token saved | | `dormouse.sh`, each launch | `autoUpdate` on, in a build that updates itself ("Updates" below), in every window | - **Allowed networks**, under `local`: one switch per interface, on when all its prefixes are allowed. **Must list every allowed range no switch reading On covers**, with Remove, naming the interface a partly allowed one belongs to; **switching one off keeps a range another switch reading On needs**. A typed range goes to the service, which saves its canonical form; more than 32 is refused in the panel. **With nothing allowed it says no phone can connect.** @@ -115,5 +115,4 @@ Source of truth: `NetworkSettings`, `NetworkPhones`, `NetworkUpdates`, `connecti - **Must choose Pocket's direct-peer factory by deployment** ("Anywhere"), one bundle serving both; `lib/src/remote/pocket-app/App.tsx` hard-codes `selfHostDirectPeer`. - **Must start `BurrowRuntime` on the level's `directPeeringFor`, restarting it on any change `samePaths` sees** ("Anywhere"). - **Must accept sealed push independently of terminal transport**, under `docs/specs/remote-security-model.md` -> "Push sealing". -- **Must resolve the one-origin pin first** (`docs/specs/relay.md` -> "The one-origin pin"): Pocket and the Relay share `hosted.dormouse.sh`, whose root holds the account app. - **Never enroll Hosted into a customer's tailnet** or mint per-customer hostnames. diff --git a/docs/specs/remote-network.rationale.md b/docs/specs/remote-network.rationale.md index 64405858e..427a37833 100644 --- a/docs/specs/remote-network.rationale.md +++ b/docs/specs/remote-network.rationale.md @@ -166,7 +166,7 @@ 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 `hosted.dormouse.sh` has +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, diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index b363c132b..c32cb4569 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -191,7 +191,7 @@ behind do carry transcripts (rationale). state root, owner-only: one rebuilt agent-resume invocation per Surface, never a buffer, unlinked as it is read (`docs/compatible-agents.md` -> "Recovery record"). -**The managed-voice token is a bearer credential at rest** — `/managed-voice.json` beside the Burrow's enrollment, written by `writeJsonAtomic` (`0700`/`0600`; on Windows the owner-only DACL `burrow_state_dir` applies before the sidecar spawns), with the voice id (rationale); `docs/specs/alert.md` → "Managed voice" keeps it from any webview. **The token must go only to a Hosted build's baked relay origin**, never following a redirect (`redirect: 'error'`); a self-host build sends it nowhere (`docs/specs/relay.md` -> "Relay origin"). +**The managed-voice token is a bearer credential at rest** — `/managed-voice.json` beside the Burrow's enrollment, written by `writeJsonAtomic` (`0700`/`0600`; on Windows the owner-only DACL `burrow_state_dir` applies before the sidecar spawns), with the voice id (rationale); `docs/specs/alert.md` → "Managed voice" keeps it from any webview. **The token must go only to `https://voice.dormouse.sh`, from a Hosted build**, never following a redirect (`redirect: 'error'`); a self-host build sends it nowhere (`docs/specs/relay.md` -> "Relay origin"). **VS Code persists pane structure in VS Code's own storage** — `workspaceState` under `dormouse.session`, and `vscode.setState()`, a WebviewPanel's only store — @@ -218,7 +218,7 @@ does. A gap, not an accepted risk. Source of truth: `SESSION_STATE_KEY` in `vscode-ext/src/session-state.ts`, `ensureToken` in `vscode-ext/src/peer-link.ts`, `default_log_path` in `standalone/src-tauri/src/lib.rs`, `createManagedVoiceHost` in -`lib/src/host/managed-voice-host.ts`, `hostedOrigin` in +`lib/src/host/managed-voice-host.ts`, `hostedVoiceOrigin` in `lib/src/host/relay-origin.ts`. ## Terminal context directory actions diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index eb07e50ee..47b18cbaf 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -65,10 +65,11 @@ phone, or fabricate a request (rationale). The only path back out is `docs/specs/relay.md` -> "Relay origin" owns the rule these checks audit. -- **FAIL IF** `DEFAULT_RELAY_ORIGIN` is not exactly `https://hosted.dormouse.sh` in **both** `scripts/relay-origin.mjs` and `lib/src/host/relay-origin.ts` (`lib/src/host/relay-origin.test.ts` pins both), or `.github/workflows/release.yml` sets `DORMOUSE_RELAY_ORIGIN` — either changes what every shipped binary talks to. +- **FAIL IF** `DEFAULT_RELAY_ORIGIN` is not exactly `https://relay.dormouse.sh` in **both** `scripts/relay-origin.mjs` and `lib/src/host/relay-origin.ts` (`lib/src/host/relay-origin.test.ts` pins both), or `.github/workflows/release.yml` sets `DORMOUSE_RELAY_ORIGIN` — either changes what every shipped binary talks to. +- **FAIL IF** `HOSTED_VOICE_ORIGIN` in `lib/src/host/relay-origin.ts` is not exactly `https://voice.dormouse.sh` or is read from anything a build or user sets, or `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` sends the voice token anywhere but `hostedVoiceOrigin`'s answer. Pinned by `lib/src/host/relay-origin.test.ts` and `lib/src/host/managed-voice-host.test.ts`. - **FAIL IF** `assertRelayOriginBaked` is no longer called on the built bundle by both `standalone/scripts/build-sidecar-proxy.mjs` and `vscode-ext/scripts/esbuild.mjs` — including the **watch** branch of the VS Code script — or `resolveRelayOrigin` stops failing the build on any case `docs/specs/relay.md` -> "Relay origin" lists (rationale). - **FAIL IF** a Burrow reaches a Relay at any origin but its `relay` option — `bakedRelay()`, passed by `lib/src/host/remote/sidecar-entry.ts` and `vscode-ext/src/burrow.ts` — taking one from a command, the offer file, or a stored enrollment, connects on an enrollment whose Relay URL or `origin` names another rather than reading it as none (`loadEnrollmentFor`), or saves one whose Relay reports another `origin` (`BurrowService` in `lib/src/host/remote/service.ts`); or if `POST /api/burrow/enroll` in `relay/src/app.ts` reads the credential or touches `burrows.json` for a request naming another origin. -- **FAIL IF** a self-host build can reach `hosted.dormouse.sh` or `dormouse.sh` unless the user clicks a link to it. `hostedOrigin` in `lib/src/host/relay-origin.ts` must answer `null` there, and every Hosted-reaching host feature must take that nullable origin and do nothing on `null`: `BurrowService` builds no `OneTimeRuntime`, and `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` reads no token and sends no request. In standalone, `standalone/vite.config.ts` must bake the webview through `resolveRelayOrigin`; `startUpdateCheck` in `standalone/src/updater.ts` must return before `check()` unless `bakedRelayMode()` is `'hosted'`; `managedVoicePortForBuild` in `standalone/src/managed-voice-port.ts` must give a self-host webview no port; and `standalone/scripts/tauri.mjs` must overlay a self-host `tauri build` with no updater endpoint. Search the rest of `lib/src/host/`, `standalone/`, and `vscode-ext/src/` for any other request to either host. +- **FAIL IF** a self-host build can reach `dormouse.sh` or any host under it unless the user clicks a link to it. `hostedOrigin` and `hostedVoiceOrigin` in `lib/src/host/relay-origin.ts` must answer `null` there, and every Hosted-reaching host feature must take one of them and do nothing on `null`: `BurrowService` builds no `OneTimeRuntime`, and `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` reads no token and sends no request. In standalone, `standalone/vite.config.ts` must bake the webview through `resolveRelayOrigin`; `startUpdateCheck` in `standalone/src/updater.ts` must return before `check()` unless `bakedRelayMode()` is `'hosted'`; `managedVoicePortForBuild` in `standalone/src/managed-voice-port.ts` must give a self-host webview no port; and `standalone/scripts/tauri.mjs` must overlay a self-host `tauri build` with no updater endpoint. Search the rest of `lib/src/host/`, `standalone/`, and `vscode-ext/src/` for any other request to a `dormouse.sh` host. - **FAIL IF** the enrollment exchange in `lib/src/remote/burrow/enrollment.ts` or the shared `burrowFetch` in `lib/src/remote/burrow/burrow-fetch.ts` — the transport behind both push delivery and the setup-token mint — drops `redirect: 'error'`. **Every new Burrow→Relay call goes through `burrowFetch`** (rationale). ### Credentials at rest diff --git a/docs/stories/pairing.mdx b/docs/stories/pairing.mdx index 024474e9a..f11bf1447 100644 --- a/docs/stories/pairing.mdx +++ b/docs/stories/pairing.mdx @@ -124,7 +124,7 @@ passkey. `show-password` is the enrollment fallback. ## 2. Build a Burrow pointed at it Shipped Dormouse builds bake in exactly one relay origin, -`https://hosted.dormouse.sh`, enforced inside the Node process that holds the +`https://relay.dormouse.sh`, enforced inside the Node process that holds the relay socket rather than by any webview CSP. A self-host Relay needs a local build baked with its origin, byte for byte: @@ -157,7 +157,7 @@ Relay** leaves nothing to type: > The other button, **One-time connection**, is disabled in a self-host build -> like this one: its links are made at `hosted.dormouse.sh`. In a stock build, +> like this one: its links are made at `relay.dormouse.sh`. In a stock build, > under **Local networks** or **Anywhere**, it needs none of this walkthrough — > no Relay, no enrollment, no passkey, no pairing. It shows a link good for one > phone, for one session, confirmed with the same two digits and saving nothing; diff --git a/lib/src/components/ManagedVoiceSection.test.tsx b/lib/src/components/ManagedVoiceSection.test.tsx index 5a1ff4f31..f94058fb0 100644 --- a/lib/src/components/ManagedVoiceSection.test.tsx +++ b/lib/src/components/ManagedVoiceSection.test.tsx @@ -98,7 +98,7 @@ describe('ManagedVoiceSection', () => { it('states what is sent before a token is configured, and never echoes the token back', async () => { const adapter = Object.assign(new FakePtyAdapter(), { managedVoice: makePort() }); await render(adapter); - expect(text()).toContain('Only the spoken pane label and voice id are sent to hosted.dormouse.sh'); + expect(text()).toContain('Only the spoken pane label and voice id are sent to voice.dormouse.sh'); expect(text()).toContain("ElevenLabs' copy is usually deleted within seconds"); await act(async () => type(input('password'), TOKEN)); diff --git a/lib/src/components/ManagedVoiceSection.tsx b/lib/src/components/ManagedVoiceSection.tsx index 81fd8c684..79c42078e 100644 --- a/lib/src/components/ManagedVoiceSection.tsx +++ b/lib/src/components/ManagedVoiceSection.tsx @@ -114,7 +114,7 @@ export function ManagedVoiceSection() {
Managed voice

- Only the spoken pane label and voice id are sent to hosted.dormouse.sh, + Only the spoken pane label and voice id are sent to voice.dormouse.sh, which has ElevenLabs speak it; ElevenLabs' copy is usually deleted within seconds, always within minutes. If it cannot answer, the alarm uses your system voice. diff --git a/lib/src/components/RemoteControlSection.test.tsx b/lib/src/components/RemoteControlSection.test.tsx index bd34e6e63..83230a06c 100644 --- a/lib/src/components/RemoteControlSection.test.tsx +++ b/lib/src/components/RemoteControlSection.test.tsx @@ -1482,7 +1482,7 @@ function oneTimeService( if (openError) throw new Error(openError); service.set({ status: 'opening' }); links += 1; - service.set(oneTimeWaiting({ url: `https://hosted.dormouse.sh/connect/#link-${links}` })); + service.set(oneTimeWaiting({ url: `https://relay.dormouse.sh/connect/#link-${links}` })); return state; case 'oneTimeEnd': if (state.status === 'ended') service.set({ status: 'idle' }); @@ -1532,7 +1532,7 @@ describe('One-time connection', () => { // The origin is this build's, never the webview's to name. expect(oneTimeCalls(service.link, 'oneTimeOpen')).toEqual([['oneTimeOpen']]); expect(oneTimeCode()).toBeTruthy(); - expect(text()).toContain('https://hosted.dormouse.sh/connect/#link-1'); + expect(text()).toContain('https://relay.dormouse.sh/connect/#link-1'); expect(text()).toContain('Good for one phone. Expires in 5 min.'); expect(buttonLabelled('Copy link')).toBeTruthy(); expect(buttonLabelled('New link')).toBeTruthy(); @@ -1621,13 +1621,13 @@ describe('One-time connection', () => { }); it('replaces a waiting link only on New link', async () => { - const service = oneTimeService(oneTimeWaiting({ url: 'https://hosted.dormouse.sh/connect/#old' })); + const service = oneTimeService(oneTimeWaiting({ url: 'https://relay.dormouse.sh/connect/#old' })); await renderOneTime(service); await act(async () => buttonLabelled('New link')!.click()); await settleQrChunk(); expect(oneTimeCalls(service.link, 'oneTimeOpen')).toHaveLength(1); - expect(text()).toContain('https://hosted.dormouse.sh/connect/#link-1'); + expect(text()).toContain('https://relay.dormouse.sh/connect/#link-1'); expect(text()).not.toContain('#old'); }); diff --git a/lib/src/host/managed-voice-host.test.ts b/lib/src/host/managed-voice-host.test.ts index 83da57df0..6dfb31b4c 100644 --- a/lib/src/host/managed-voice-host.test.ts +++ b/lib/src/host/managed-voice-host.test.ts @@ -10,7 +10,11 @@ import { import { DEFAULT_MANAGED_VOICE_ID } from '../lib/platform/managed-voice-types'; import { DEFAULT_RELAY_ORIGIN } from './relay-origin'; -const MANAGED_VOICE_SPEAK_URL = `${DEFAULT_RELAY_ORIGIN}/api/voice/speak`; +/** + * A fixed origin, never the relay origin (`docs/specs/relay.md` → "Relay + * origin"): changing it changes where every shipped binary sends the token. + */ +const MANAGED_VOICE_SPEAK_URL = 'https://voice.dormouse.sh/api/voice/speak'; /** The next `readFile` fails with this, once: a Windows antivirus lock, say. */ const readFault = vi.hoisted(() => ({ next: null as NodeJS.ErrnoException | null })); @@ -171,7 +175,7 @@ describe('speak', () => { }); it('reports a network failure', async () => { - fetchMock.mockRejectedValue(new TypeError('getaddrinfo ENOTFOUND hosted.dormouse.sh')); + fetchMock.mockRejectedValue(new TypeError('getaddrinfo ENOTFOUND voice.dormouse.sh')); expect(await host.handle({ op: 'speak', text: 'x' })).toEqual({ ok: false, reason: 'network' }); }); @@ -194,7 +198,7 @@ describe('speak', () => { }); describe('the build it runs in', () => { - it('speaks to a Hosted build’s relay origin, a dev build’s local Hosted included', async () => { + it('speaks to the voice origin from any Hosted build, a dev build’s local Hosted included', async () => { const local = createManagedVoiceHost({ stateDir: dir, onStatus: () => {}, @@ -206,7 +210,7 @@ describe('the build it runs in', () => { fetchMock.mockResolvedValue(audioResponse()); expect(await local.handle({ op: 'speak', text: 'build finished' })).toMatchObject({ ok: true }); - expect(fetchMock.mock.calls[0]![0]).toBe('http://localhost:8787/api/voice/speak'); + expect(fetchMock.mock.calls[0]![0]).toBe(MANAGED_VOICE_SPEAK_URL); }); it('sends nothing from a self-host build, whatever a Hosted build saved', async () => { diff --git a/lib/src/host/managed-voice-host.ts b/lib/src/host/managed-voice-host.ts index 2f1c1282d..8dd3cd734 100644 --- a/lib/src/host/managed-voice-host.ts +++ b/lib/src/host/managed-voice-host.ts @@ -7,7 +7,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { writeJsonAtomic } from './atomic-json-file'; -import { hostedOrigin, type RelayBuild } from './relay-origin'; +import { hostedVoiceOrigin, type RelayBuild } from './relay-origin'; import { createSerialQueue } from './remote/serial-queue'; import { DEFAULT_MANAGED_VOICE_ID, @@ -69,8 +69,8 @@ export function createManagedVoiceHost(options: { // The only place the token may go (`docs/specs/security-local.md` -> "Persisted state"), // and `null` in a self-host build: no token is read, nothing is sent, and // every edit and speak is refused. - const hosted = hostedOrigin(options.relay); - const speakUrl = hosted === null ? null : hosted + MANAGED_VOICE_SPEAK_PATH; + const voice = hostedVoiceOrigin(options.relay); + const speakUrl = voice === null ? null : voice + MANAGED_VOICE_SPEAK_PATH; let loaded: Promise | null = null; // Each edit reads, then rewrites the whole file: two at once would drop a field. const serialize = createSerialQueue(); diff --git a/lib/src/host/relay-origin.test.ts b/lib/src/host/relay-origin.test.ts index bed6786e1..e33182c2a 100644 --- a/lib/src/host/relay-origin.test.ts +++ b/lib/src/host/relay-origin.test.ts @@ -12,7 +12,14 @@ import { relayOriginDefine, resolveRelayOrigin, } from '../../../scripts/relay-origin.mjs'; -import { DEFAULT_RELAY_ORIGIN, bakedRelay, bakedRelayMode, hostedOrigin, isRelayOrigin } from './relay-origin'; +import { + DEFAULT_RELAY_ORIGIN, + bakedRelay, + bakedRelayMode, + hostedOrigin, + hostedVoiceOrigin, + isRelayOrigin, +} from './relay-origin'; /** * An `https://` origin of exactly `length` characters under `example`, built @@ -30,22 +37,22 @@ function originOfLength(length: number): string { } const CANDIDATES = [ - 'https://hosted.dormouse.sh', - 'https://hosted.dormouse.sh:8443', + 'https://relay.dormouse.sh', + 'https://relay.dormouse.sh:8443', 'https://relay.example.ts.net', - 'https://hosted.dormouse.sh/', - 'https://hosted.dormouse.sh/connect', - 'https://user@hosted.dormouse.sh', - 'https://hosted.dormouse.sh?x=1', - 'HTTPS://hosted.dormouse.sh', - 'http://hosted.dormouse.sh', + 'https://relay.dormouse.sh/', + 'https://relay.dormouse.sh/connect', + 'https://user@relay.dormouse.sh', + 'https://relay.dormouse.sh?x=1', + 'HTTPS://relay.dormouse.sh', + 'http://relay.dormouse.sh', 'http://localhost:3000', 'http://127.0.0.1:8787', 'http://[::1]:8787', 'http://127.0.0.2:8787', 'ws://127.0.0.1:8787', - 'wss://hosted.dormouse.sh', - 'hosted.dormouse.sh', + 'wss://relay.dormouse.sh', + 'relay.dormouse.sh', 'not an origin', '', originOfLength(MAX_RELAY_ORIGIN_LENGTH), @@ -57,7 +64,7 @@ describe('the baked relay origin', () => { // Changing this changes what every shipped binary talks to // (docs/specs/security-remote.md → "Relay origin"). The build scripts read // the `.mjs` and the hosts read the `.ts`. - expect(DEFAULT_RELAY_ORIGIN).toBe('https://hosted.dormouse.sh'); + expect(DEFAULT_RELAY_ORIGIN).toBe('https://relay.dormouse.sh'); expect(BUILD_DEFAULT).toBe(DEFAULT_RELAY_ORIGIN); }); @@ -71,6 +78,14 @@ describe('the baked relay origin', () => { expect(hostedOrigin({ origin: 'https://relay.example.ts.net', mode: 'self-host' })).toBeNull(); }); + it('speaks managed voice at the fixed voice origin, in a Hosted build only', () => { + // Never the relay origin, and never baked: a dev Hosted build on loopback + // still speaks to the real voice origin (docs/specs/relay.md → "Relay origin"). + expect(hostedVoiceOrigin({ origin: DEFAULT_RELAY_ORIGIN, mode: 'hosted' })).toBe('https://voice.dormouse.sh'); + expect(hostedVoiceOrigin({ origin: 'http://localhost:8787', mode: 'hosted' })).toBe('https://voice.dormouse.sh'); + expect(hostedVoiceOrigin({ origin: 'https://relay.example.ts.net', mode: 'self-host' })).toBeNull(); + }); + it('matches a stored Relay URL by origin, and nothing else', () => { expect(isRelayOrigin('https://relay.example.ts.net', 'https://relay.example.ts.net')).toBe(true); expect(isRelayOrigin('https://relay.example.ts.net/', 'https://relay.example.ts.net')).toBe(true); @@ -187,7 +202,7 @@ describe('assertRelayOriginBaked', () => { writeFileSync(bundle, `const a = "https://relay.example.ts.net"; const b = ${placeholder};`); expect(() => assertRelayOriginBaked(bundle, relay), placeholder).toThrow(/survived/); } - writeFileSync(bundle, 'const origin = "https://hosted.dormouse.sh", mode = "hosted";'); + writeFileSync(bundle, 'const origin = "https://relay.dormouse.sh", mode = "hosted";'); expect(() => assertRelayOriginBaked(bundle, relay)).toThrow(/does not contain/); writeFileSync(bundle, 'const origin = "https://relay.example.ts.net", mode = "self-host";'); expect(() => assertRelayOriginBaked(bundle, relay)).not.toThrow(); diff --git a/lib/src/host/relay-origin.ts b/lib/src/host/relay-origin.ts index 515ef3dbc..6aa507c0c 100644 --- a/lib/src/host/relay-origin.ts +++ b/lib/src/host/relay-origin.ts @@ -1,7 +1,8 @@ /** * 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 and managed voice go too. + * Hosted build — where the one-time rendezvous goes too. Managed voice speaks + * at {@link HOSTED_VOICE_ORIGIN} instead. * * Baked by `scripts/relay-origin.mjs` into both host bundles. **Never webview * input** — no command carries an origin. @@ -10,12 +11,19 @@ import { normalizeOrigin } from 'remote-lib-common'; /** - * The origin a stock build reaches: Hosted. Kept equal to + * The relay origin a stock build reaches: Hosted's. Kept equal to * `scripts/relay-origin.mjs` by `relay-origin.test.ts` — the build scripts read * the `.mjs`, the service reads this, and a drift would ship a binary whose * mode disagrees with its origin. */ -export const DEFAULT_RELAY_ORIGIN = 'https://hosted.dormouse.sh'; +export const DEFAULT_RELAY_ORIGIN = 'https://relay.dormouse.sh'; + +/** + * Where a Hosted build's managed voice speaks. A fixed constant, never baked + * and never overridden: a dev Hosted build whose relay origin is loopback still + * speaks here (`docs/specs/relay.md` → "Relay origin"). + */ +export const HOSTED_VOICE_ORIGIN = 'https://voice.dormouse.sh'; /** * `hosted`: the default origin, or a dev build's `DORMOUSE_RELAY_IS_HOSTED=1`. @@ -62,15 +70,23 @@ export function bakedRelayMode(): RelayMode { } /** - * Where this build may reach Hosted: the relay origin in a Hosted build, and - * `null` in a self-host one, which reaches nothing of Dormouse's in the - * background. Every Hosted-reaching feature takes this and does nothing on - * `null`. + * Where this build may reach Hosted's one-time rendezvous: the relay origin in + * a Hosted build, and `null` in a self-host one, which reaches nothing of + * Dormouse's in the background. Every Hosted-reaching feature takes this or + * {@link hostedVoiceOrigin} and does nothing on `null`. */ export function hostedOrigin(relay: RelayBuild): string | null { return relay.mode === 'hosted' ? relay.origin : null; } +/** + * 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. + */ +export function hostedVoiceOrigin(relay: RelayBuild): string | null { + return relay.mode === 'hosted' ? HOSTED_VOICE_ORIGIN : null; +} + /** * Whether a stored Relay URL names `relayOrigin`, compared as origins. An * enrollment for which this is false **reads as none** — it stays on disk, but diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 755e4579e..6b949aac3 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -1605,7 +1605,7 @@ describe('one-time connection', () => { const waiting = result as Waiting; expect(waiting.status).toBe('waiting'); expect(rendezvous.rooms).toHaveLength(1); - expect(rendezvous.room().burrowUrl).toBe('wss://hosted.dormouse.sh/api/one-time/burrow'); + expect(rendezvous.room().burrowUrl).toBe('wss://relay.dormouse.sh/api/one-time/burrow'); expect((await parseOneTimeLinkUrl(waiting.url, HOSTED_ORIGIN))?.roomId).toBe( rendezvous.room().roomId, ); diff --git a/lib/src/remote/one-time-app/OneTimeApp.test.tsx b/lib/src/remote/one-time-app/OneTimeApp.test.tsx index bf3bee47b..a4c37f7e6 100644 --- a/lib/src/remote/one-time-app/OneTimeApp.test.tsx +++ b/lib/src/remote/one-time-app/OneTimeApp.test.tsx @@ -266,7 +266,7 @@ describe('the gate', () => { for (const candidate of [ null, `${location.origin}/connect/#1.not-a-link`, - url.replace(location.origin, 'https://hosted.dormouse.sh'), + url.replace(location.origin, 'https://relay.dormouse.sh'), url.replace('/connect/', '/connect/x/'), ]) { act(() => root.unmount()); diff --git a/remote-lib-common/test/one-time-link.test.mjs b/remote-lib-common/test/one-time-link.test.mjs index 2175667e2..1a0c4e806 100644 --- a/remote-lib-common/test/one-time-link.test.mjs +++ b/remote-lib-common/test/one-time-link.test.mjs @@ -201,8 +201,8 @@ test('one character more is refused at mint time and by the parser', async () => test('a build bakes only a bare link-scheme origin that fits a link', () => { for (const origin of [ - 'https://hosted.dormouse.sh', - 'https://hosted.dormouse.sh:8443', + 'https://relay.dormouse.sh', + 'https://relay.dormouse.sh:8443', 'http://localhost:3000', 'http://127.0.0.1:8787', 'http://[::1]:8787', @@ -211,15 +211,15 @@ test('a build bakes only a bare link-scheme origin that fits a link', () => { assert.equal(isAcceptedRelayOrigin(origin), true, origin); } for (const origin of [ - 'https://hosted.dormouse.sh/', - 'https://hosted.dormouse.sh/connect', - 'https://user@hosted.dormouse.sh', - 'https://hosted.dormouse.sh?x=1', - 'HTTPS://hosted.dormouse.sh', - 'http://hosted.dormouse.sh', + 'https://relay.dormouse.sh/', + 'https://relay.dormouse.sh/connect', + 'https://user@relay.dormouse.sh', + 'https://relay.dormouse.sh?x=1', + 'HTTPS://relay.dormouse.sh', + 'http://relay.dormouse.sh', 'http://127.0.0.2:8787', - 'wss://hosted.dormouse.sh', - 'hosted.dormouse.sh', + 'wss://relay.dormouse.sh', + 'relay.dormouse.sh', '', undefined, originOfLength(MAX_RELAY_ORIGIN_LENGTH + 1), diff --git a/scripts/relay-origin.mjs b/scripts/relay-origin.mjs index 7edd4567a..c0a07bd1b 100644 --- a/scripts/relay-origin.mjs +++ b/scripts/relay-origin.mjs @@ -19,8 +19,8 @@ import { export const RELAY_ORIGIN_PLACEHOLDER = '__DORMOUSE_RELAY_ORIGIN__'; export const RELAY_MODE_PLACEHOLDER = '__DORMOUSE_RELAY_MODE__'; -/** The origin a stock build reaches: Hosted. */ -export const DEFAULT_RELAY_ORIGIN = 'https://hosted.dormouse.sh'; +/** The relay origin a stock build reaches: Hosted's. */ +export const DEFAULT_RELAY_ORIGIN = 'https://relay.dormouse.sh'; /** * Variables that once chose an origin `DORMOUSE_RELAY_ORIGIN` now chooses. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index b3e6ae343..5829b9532 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,19 +12,19 @@ "docs/specs/dor-tools-builtin.md": 1050, "docs/specs/dor-tools-lib.md": 400, "docs/specs/glossary.md": 3000, - "docs/specs/hosted.md": 1900, + "docs/specs/hosted.md": 2150, "docs/specs/layout.md": 11500, "docs/specs/mobile-terminal-ui.md": 2300, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/one-time.md": 3800, "docs/specs/pocket-app.md": 5050, - "docs/specs/relay.md": 10050, + "docs/specs/relay.md": 10100, "docs/specs/remote-api.md": 5250, "docs/specs/remote-network.md": 2350, "docs/specs/remote-security-model.md": 5450, "docs/specs/security-audit.md": 2100, "docs/specs/security-ci.md": 2950, - "docs/specs/security-hosted.md": 1150, + "docs/specs/security-hosted.md": 1300, "docs/specs/security-local.md": 3850, "docs/specs/security-remote.md": 7000, "docs/specs/security-supply-chain.md": 1250, diff --git a/vscode-ext/test/burrow.test.ts b/vscode-ext/test/burrow.test.ts index c769e696a..b4bcec25f 100644 --- a/vscode-ext/test/burrow.test.ts +++ b/vscode-ext/test/burrow.test.ts @@ -84,7 +84,7 @@ vi.mock('../../lib/src/host/remote/native-direct-peer', () => ({ * a case about a self-host build sets these (docs/specs/relay.md → "Relay origin"). */ const relayBuild = vi.hoisted(() => ({ - origin: 'https://hosted.dormouse.sh', + origin: 'https://relay.dormouse.sh', mode: 'hosted' as 'hosted' | 'self-host', })); vi.mock('../../lib/src/host/relay-origin', async (importOriginal) => ({ @@ -720,7 +720,7 @@ describe('burrow service glue', () => { result: { status: 'waiting' }, }); // On the baked origin, through the same factory the relay socket uses. - expect(rendezvous.room().burrowUrl).toBe('wss://hosted.dormouse.sh/api/one-time/burrow'); + expect(rendezvous.room().burrowUrl).toBe('wss://relay.dormouse.sh/api/one-time/burrow'); }); it('bootstraps the contention on setNetworkPolicy, so the service is its one writer', async () => { From c78201779aa66d78cacfbb7d48a6faf195f4dc13 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 22:40:38 -0700 Subject: [PATCH 3/7] Derive Hosted's three Workers from one registry and their Wrangler configs Simplify pass on the split: one Worker registry and deploy loop for preview and production, one smoke sequence, the bindings mapper applied to cron handlers, per-Worker hashed-asset prefixes, dist// output, tests derived from the configs, and specs that state each rule once. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/hosted-preview.yml | 14 +- .github/workflows/hosted-production.yml | 8 +- .gitignore | 1 - docs/specs/alert.md | 2 +- docs/specs/hosted.md | 21 +-- docs/specs/one-time.md | 2 +- docs/specs/relay.md | 12 +- docs/specs/security-hosted.md | 2 +- docs/specs/security-local.md | 2 +- hosted/README.md | 5 +- hosted/package.json | 1 - hosted/scripts/dev-one-time.mjs | 3 +- hosted/scripts/preview-smoke.mjs | 85 ++++++---- hosted/scripts/preview.mjs | 211 +++++++++--------------- hosted/scripts/preview.test.mjs | 88 +++++++++- hosted/scripts/production.mjs | 134 +++++---------- hosted/scripts/production.test.mjs | 48 +++++- hosted/scripts/stage-one-time.mjs | 4 +- hosted/scripts/stage-one-time.test.mjs | 2 +- hosted/scripts/workers.mjs | 98 +++++++++++ hosted/server/account-app.ts | 3 +- hosted/server/bindings.ts | 4 +- hosted/server/headers.ts | 21 ++- hosted/server/preview-worker.ts | 2 +- hosted/server/relay-worker.ts | 3 +- hosted/server/tests/boundary.test.ts | 132 +++++++++------ hosted/server/tests/bundle.ts | 62 ++++++- hosted/server/tests/one-time.test.ts | 34 +--- hosted/server/tests/voice-entry.ts | 3 +- hosted/server/tests/workers.test.ts | 52 +++--- hosted/server/voice-app.ts | 16 +- hosted/server/worker-app.ts | 27 ++- hosted/server/worker.ts | 5 +- hosted/vite.config.ts | 3 +- hosted/wrangler.jsonc | 4 +- hosted/wrangler.relay.jsonc | 2 +- lib/src/host/relay-origin.test.ts | 14 +- 37 files changed, 661 insertions(+), 469 deletions(-) create mode 100644 hosted/scripts/workers.mjs diff --git a/.github/workflows/hosted-preview.yml b/.github/workflows/hosted-preview.yml index e824efaa3..078bcc5a4 100644 --- a/.github/workflows/hosted-preview.yml +++ b/.github/workflows/hosted-preview.yml @@ -62,13 +62,11 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test:hosted - run: pnpm build:hosted - # The account's assets and the relay's; the artifact keeps both folder names. + # Every Worker's static files, each under its own `dist//`. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: preview-assets - path: | - hosted/dist - hosted/dist-relay + path: hosted/dist retention-days: 3 - if: vars.HOSTED_PREVIEWS_ENABLED != 'true' run: echo '::notice::Cloud previews are not configured yet. Follow hosted/README.md -> Provision PR previews, set HOSTED_PREVIEWS_ENABLED=true, then rerun this workflow.' @@ -105,7 +103,7 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: preview-assets - path: hosted + path: hosted/dist - name: Check preview settings before creating resources run: | node --input-type=module <<'JS' @@ -143,11 +141,9 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} PREVIEW_AUTH_SECRET: ${{ secrets.PREVIEW_AUTH_SECRET }} - name: Check deployed revisions, database, cookies, routes and the rendezvous - run: pnpm --filter dormouse-hosted preview:smoke "$PREVIEW_ORIGIN" "$RELAY_ORIGIN" "$VOICE_ORIGIN" "$BUILD_SHA" + run: pnpm --filter dormouse-hosted preview:smoke "$PREVIEW_ORIGINS" "$BUILD_SHA" env: - PREVIEW_ORIGIN: ${{ steps.deploy.outputs.url }} - RELAY_ORIGIN: ${{ steps.deploy.outputs.relay-url }} - VOICE_ORIGIN: ${{ steps.deploy.outputs.voice-url }} + PREVIEW_ORIGINS: ${{ steps.deploy.outputs.origins }} cleanup: if: >- diff --git a/.github/workflows/hosted-production.yml b/.github/workflows/hosted-production.yml index d31d7f5dc..1792bf2e3 100644 --- a/.github/workflows/hosted-production.yml +++ b/.github/workflows/hosted-production.yml @@ -30,13 +30,11 @@ jobs: - run: pnpm build:hosted - name: Require accepted package provenance run: node --input-type=module -e 'import { verifyPackages } from "./hosted/scripts/production.mjs"; await verifyPackages();' - # The account's assets and the relay's; the artifact keeps both folder names. + # Every Worker's static files, each under its own `dist//`. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: hosted-production-assets - path: | - hosted/dist - hosted/dist-relay + path: hosted/dist if-no-files-found: error retention-days: 3 deploy: @@ -67,7 +65,7 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: hosted-production-assets - path: hosted + path: hosted/dist - name: Validate production identities, uncached Hyperdrive and each Worker's secrets run: node hosted/scripts/production.mjs preflight env: diff --git a/.gitignore b/.gitignore index 2cf487d49..a3a861408 100644 --- a/.gitignore +++ b/.gitignore @@ -71,7 +71,6 @@ hosted/.dev.vars* hosted/.wrangler/ hosted/.pgstencil/ hosted/dist-worker/ -hosted/dist-relay/ # Storybook / Chromatic / Argos storybook-static/ diff --git a/docs/specs/alert.md b/docs/specs/alert.md index f7db43165..d9374e52c 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -353,7 +353,7 @@ Source of truth: `toSpokenText` / `startAlertSpeech` in `lib/src/lib/alert-speec Every rule above holds for both engines. - **Must try managed voice first while the adapter's `managedVoice` status says a token is saved** (`docs/specs/transport.md` → "Managed voice"); otherwise the utterance goes straight to Web Speech. -- **A Hosted build's host speaks at `https://voice.dormouse.sh`; a self-host build's has no voice origin** and makes no request (`docs/specs/relay.md` → "Relay origin"), **nor does any host under the network policy's `nothing`** (`docs/specs/remote-network.md` → "Policy"). +- **A host speaks only at its build's voice origin; a self-host build's has none** and makes no request (`docs/specs/relay.md` → "Relay origin"), **nor does any host under the network policy's `nothing`** (`docs/specs/remote-network.md` → "Policy"). - **Must fall back to Web Speech for the same utterance, inside the same attempt, on any failure before managed audio starts** — `unconfigured`, offline, non-2xx, host timeout, undecodable or refused playback. **Never play both**: audio that started and then failed ends the attempt instead (rationale). Nothing is retried. - `speaking` / `spoken` follow the audio element's `playing` / `ended`. **Cut-off and teardown must stop the audio**; a request still in flight runs out in the host and its answer is ignored. - **Never let the voice token reach a renderer**; the host adds it to the request (rationale). Where it may go: `docs/specs/security-local.md` → "Persisted state". diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 1cab29c78..bb17a5143 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -13,12 +13,7 @@ | `dormouse-relay` | `https://relay.dormouse.sh` | the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | `OneTimeRoom`, the one-time rate limits | | `dormouse-voice` | `https://voice.dormouse.sh` | speak and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | -Every Worker answers `/api/health`, 404s any other `/api/*`, `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. Only the account falls back to SPA assets. - -- **Must answer 421, before any route, to a request whose URL origin is not the Worker's `APP_ORIGIN`**, a sibling's included. -- **Must hand routes only the bindings the Worker's mapper names**: no auth secret on the relay or voice, no Hyperdrive on the relay, no `ELEVENLABS_API_KEY` on the account. -- **Never let the relay or voice Worker reach auth or the login cookie's origin**: Pocket and `/connect/` render untrusted terminal output (rationale). -- **Must refuse, on every account cookie route, any `Origin` but the account's exactly**: sibling origins are same-site, so a browser sends them the `SameSite=Lax` login cookie. +Every Worker answers `/api/health`, 404s any other `/api/*`, `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. Only the account falls back to SPA assets. The 421 origin gate, each Worker's bindings mapper, and the cookie routes' exact-`Origin` check are `docs/specs/security-hosted.md` -> "Origin boundary" (rationale). Marketing is a separate bundle and deployment. `remote-lib-common` is compiled in from source through `hosted/tsconfig.json` `paths`, which every esbuild bundle and Wrangler honor. @@ -28,7 +23,7 @@ Marketing is a separate bundle and deployment. `remote-lib-common` is compiled i **Must declare every peer dependency of the installed packages in `hosted/package.json`**, so they share Hosted's copy and Renovate updates them. -Source of truth: `workerApp` in `hosted/server/worker-app.ts`; `accountApp` in `hosted/server/account-app.ts`; `hosted/server/relay-worker.ts`; `voiceApp` in `hosted/server/voice-app.ts`; `hosted/server/bindings.ts`; `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`; `migrations` in `hosted/server/migrations.ts`; `verifyPackages` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts`, and `hosted/server/tests/artifacts.test.ts`. +Source of truth: `workerApp` in `hosted/server/worker-app.ts`; `accountApp` in `hosted/server/account-app.ts`; `hosted/server/relay-worker.ts`; `voiceApp` in `hosted/server/voice-app.ts`; `hosted/server/bindings.ts`; `WORKERS` in `hosted/scripts/workers.mjs`; `hosted/wrangler.jsonc`, `hosted/wrangler.relay.jsonc`, `hosted/wrangler.voice.jsonc`; `migrations` in `hosted/server/migrations.ts`; `verifyPackages` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts`, and `hosted/server/tests/artifacts.test.ts`. ## Identity and login @@ -93,7 +88,7 @@ Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 - **Must bound each pass**; a backlog waits for the next pass. At most six deletes are in flight; a 404 counts as deleted; a failed delete never aborts the pass. Only counts and statuses are logged or thrown. - **Must fail the cron invocation when its pass cannot list or any delete fails; the after-speech pass only logs** (rationale). -Source of truth: `isAdmin` in `hosted/server/admin.ts`; `voiceTokenRoutes` / `speakRoute` / `elevenLabs` / `sweepOnCron` / `sweepAfterSpeech` in `hosted/server/voice.ts`; `scheduled` in `hosted/server/voice-app.ts`; `triggers` in `hosted/wrangler.voice.jsonc`; `hosted/server/dormouse-migrations/001_voice_tokens.sql`; `preflight` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/production.test.mjs`, and `hosted/scripts/preview.test.mjs`. +Source of truth: `isAdmin` in `hosted/server/admin.ts`; `voiceTokenRoutes` / `speakRoute` / `elevenLabs` / `sweepOnCron` / `sweepAfterSpeech` in `hosted/server/voice.ts`; `scheduled` in `hosted/server/voice-app.ts` and `hosted/server/worker-app.ts`; `triggers` in `hosted/wrangler.voice.jsonc`; `hosted/server/dormouse-migrations/001_voice_tokens.sql`; `preflight` in `hosted/scripts/production.mjs`. Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/production.test.mjs`, and `hosted/scripts/preview.test.mjs`. ## Development and release @@ -109,25 +104,25 @@ 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.** Drafts qualify; forks receive no deployment credentials. Changed paths include rename sources and all API pages. Deployment runs serialize per PR without cancellation; close/merge cleanup ignores path filtering and tolerates absent resources. -**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive the account and voice share, and a Neon branch from an empty dedicated preview project**, all reused until close. 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 namespace and preview-only rate-limit namespaces**, and delete every preview Worker with `force`. The smoke checks each Worker's revision and runs `oneTimeSmoke` on the relay. +**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive the account and voice share, and a Neon branch from an empty dedicated preview project**, all reused until close. 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 namespace and preview-only rate-limit namespaces**, and delete every preview Worker with `force`. The smoke checks what production's does ("Production releases"), with the captured-mail login in place of providers, retrying each part on its own. **Must run cleanup from the base branch's checkout, never the closed PR's.** **Must capture preview mail in Postgres and expose escaped text only.** The public inbox shows the newest 100 messages from the last 24 hours, prunes expired rows on capture, and accepts only the preview's configured origin. No test clock is deployed. Preview data is disposable; it is not access-controlled. -Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workflows/hosted-preview.yml`; `previewConfigs` / `cleanup` in `hosted/scripts/preview.mjs`; `postgresInbox` in `hosted/server/preview-inbox.ts`; `hosted/server/preview-worker.ts`; `hosted/server/voice-preview-worker.ts`. Pinned by `hosted/scripts/preview.test.mjs`, `hosted/scripts/changed.test.mjs`, and `hosted/server/tests/workers.test.ts`. +Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workflows/hosted-preview.yml`; `previewConfig` / `prepare` / `cleanup` in `hosted/scripts/preview.mjs`; `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `postgresInbox` in `hosted/server/preview-inbox.ts`; `hosted/server/preview-worker.ts`; `hosted/server/voice-preview-worker.ts`. Pinned by `hosted/scripts/preview.test.mjs`, `hosted/scripts/changed.test.mjs`, and `hosted/server/tests/workers.test.ts`. ## Production releases -**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's name, entry, origin, and lone custom domain; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`; the relay has none). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy account, relay, then voice, stopping at a failure. Production has no public candidate URL. +**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`; the relay has none). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy account, relay, then voice, stopping at a failure. 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. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay starts its own `v1`. **Must set `triggers.crons` to `[]` on a Worker with no schedule**: an absent `triggers` leaves a deployed one in place. A deploy restarts every room, dropping links still waiting or mid-handshake; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay after the account smoke. +**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. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay starts its own `v1`. A deploy restarts every room, dropping links still waiting or mid-handshake; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay after the account smoke and the relay's revision check. **Must bound retries.** Health GETs require the selected revision, sharing six five-second retries for transport failures or healthy stale revisions. Retry rate-limited OAuth once; never replay POSTs after transport failures. **Must record an immutable annotated hosted/YYYY-MM-DD tag only after live verification.** Tags identify the deployed commit and verification run/attempt; retries are idempotent and redeployments get new tags. Dating and repeat-deployment suffixes: `recordDeployment`. Code rollback never reverses migrations. -Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` in `hosted/scripts/production.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. +Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` in `hosted/scripts/production.mjs`; `deployWorkers` in `hosted/scripts/workers.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` / `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. ## Future diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index 98f595e35..b066548c1 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -335,7 +335,7 @@ no module-preload polyfill) into `lib/dist-one-time`, then runs `assertPocketShell --one-time`: scripts under `/connect/assets/`, links under `/connect/`, nothing inline. Hosted's `build` runs it first, and `hosted/scripts/stage-one-time.mjs` empties the relay's assets directory, -`dist-relay/`, copies it to `dist-relay/connect/`, and checks the copy. +`hosted/dist/relay/`, copies it to `hosted/dist/relay/connect/`, and checks the copy. **Serving.** The relay Worker answers `/connect`, `/connect/`, and `/connect/assets/*` from its assets, which have no SPA fallback; an HTML answer diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 65f67b48e..ec1a24a6d 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -153,14 +153,10 @@ webview CSPs carry no relay sources** (`docs/specs/vscode.md` → "CSP policy"; or overridden**, a loopback dev Hosted build included (rationale). - **No build bakes `hosted.dormouse.sh`, the account's origin**; the desktop reaches it only by a user's click. -- **Never serve the account from an origin serving Pocket or `/connect/`**, - which render terminal output. Reserved: the Hosted Relay serves - `relay.dormouse.sh` over TLS with Pocket at its root, never a tailnet or - per-tenant host, passkeys binding to the served origin ("Scope: - saas-multitenant"). -- **Sibling `dormouse.sh` origins are same-site**: the account's `SameSite=Lax` - cookie rides requests from `relay.` and `voice.`, which only the cookie - routes' exact-`Origin` check refuses (`docs/specs/security-hosted.md`). +- Reserved: the Hosted Relay serves `relay.dormouse.sh` over TLS with Pocket at + its root, never a tailnet or per-tenant host, passkeys binding to the served + origin ("Scope: saas-multitenant"). 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, carrying no Relay URL, and **a diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 870aea68f..abf31ffc4 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -10,7 +10,7 @@ - **FAIL IF** a cookie route admits any `Origin` but its own exactly, sibling origins under `dormouse.sh` included — they are same-site, so the browser sends them the `SameSite=Lax` login cookie — or a state-changing auth request skips the CSRF check, or any Worker grants credentialed CORS; inspect `voiceTokenRoutes` in `hosted/server/voice.ts` and the packed adapter. - **FAIL IF** the relay or voice Worker's bindings mapper passes an auth secret (`AUTH_SECRET`, a provider credential, or `POSTMARK_SERVER_TOKEN`), the relay's passes Hyperdrive, the account's passes `ELEVENLABS_API_KEY`, or the relay or voice entry imports Better Auth; inspect `hosted/server/bindings.ts` and each entry's import graph. - **FAIL IF** authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. -- **FAIL IF** 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 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 anything but a content-hashed file under the account's `/assets/` or the relay's `/connect/assets/` is cacheable, the SPA fallback's shell included. Inspect `secureHeaders` 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** 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 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 anything but a content-hashed file under the account's own `/assets/` or the relay's own `/connect/assets/` is cacheable, the SPA fallback's shell included. Inspect `secureHeaders` / `ACCOUNT_HASHED_ASSETS` / `RELAY_HASHED_ASSETS` 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** 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`. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index c32cb4569..adb4a94e6 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -191,7 +191,7 @@ behind do carry transcripts (rationale). state root, owner-only: one rebuilt agent-resume invocation per Surface, never a buffer, unlinked as it is read (`docs/compatible-agents.md` -> "Recovery record"). -**The managed-voice token is a bearer credential at rest** — `/managed-voice.json` beside the Burrow's enrollment, written by `writeJsonAtomic` (`0700`/`0600`; on Windows the owner-only DACL `burrow_state_dir` applies before the sidecar spawns), with the voice id (rationale); `docs/specs/alert.md` → "Managed voice" keeps it from any webview. **The token must go only to `https://voice.dormouse.sh`, from a Hosted build**, never following a redirect (`redirect: 'error'`); a self-host build sends it nowhere (`docs/specs/relay.md` -> "Relay origin"). +**The managed-voice token is a bearer credential at rest** — `/managed-voice.json` beside the Burrow's enrollment, written by `writeJsonAtomic` (`0700`/`0600`; on Windows the owner-only DACL `burrow_state_dir` applies before the sidecar spawns), with the voice id (rationale); `docs/specs/alert.md` → "Managed voice" keeps it from any webview. **The token must go only to `hostedVoiceOrigin`'s answer**, never following a redirect (`redirect: 'error'`); a self-host build, answered `null`, sends it nowhere (`docs/specs/relay.md` -> "Relay origin"). **VS Code persists pane structure in VS Code's own storage** — `workspaceState` under `dormouse.session`, and `vscode.setState()`, a WebviewPanel's only store — diff --git a/hosted/README.md b/hosted/README.md index be82f11f8..4f8aa08a4 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -44,8 +44,9 @@ 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 and boundary -suites alone, without Docker. The build stages `/connect/` into `dist-relay/` and dry-runs -all three Workers; it does not deploy. +suites alone, without Docker. The build writes each Worker's static files under `dist//` +(`/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 — the one-time rendezvous and `/connect/` page — runs on its own, without Docker or Postgres: diff --git a/hosted/package.json b/hosted/package.json index ccdca7774..9cb146b18 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -10,7 +10,6 @@ "db:migrate": "tsx server/db.ts migrate", "db:validate": "tsx server/db.ts validate", "db:status": "tsx server/db.ts status", - "deploy": "pnpm build && wrangler deploy && wrangler deploy --config wrangler.relay.jsonc && wrangler deploy --config wrangler.voice.jsonc", "preview:worker": "wrangler dev --local --local-protocol https", "test:deploy": "node --test scripts/*.test.mjs", "test:miniflare": "vitest run server/tests/one-time.test.ts server/tests/boundary.test.ts", diff --git a/hosted/scripts/dev-one-time.mjs b/hosted/scripts/dev-one-time.mjs index d243c1068..90519cfe7 100644 --- a/hosted/scripts/dev-one-time.mjs +++ b/hosted/scripts/dev-one-time.mjs @@ -4,6 +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"; /** * The one-time rendezvous and phone page on loopback (`docs/specs/one-time.md` @@ -106,7 +107,7 @@ async function waitFor(what, ready, timeoutMs) { async function main() { const port = devPort(process.env); const origin = devOrigin(port); - const base = JSON.parse(readFileSync(resolve(hosted, "wrangler.relay.jsonc"), "utf8")); + const base = parseConfig(readFileSync(resolve(hosted, "wrangler.relay.jsonc"), "utf8")); const pageDir = resolve(devDir, "assets", PAGE_PATH.slice(1)); // A page left by an earlier run would satisfy the first wait below. rmSync(devDir, { recursive: true, force: true }); diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs index 7f29d3e88..d86b715eb 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -61,10 +61,7 @@ export async function smokeRequest(fetcher, url, options, wait = delay, expected } } -/** - * A Worker that is no account — the relay or voice — reports `sha` live from - * its health route; retries as `smokeRequest` does. - */ +/** A Worker reports `sha` live from its health route; retries as `smokeRequest` does. */ export async function healthSmoke(origin, sha, fetcher = fetch) { assert.equal( new URL(origin).origin, @@ -84,23 +81,13 @@ export async function smoke( expectedProviders = [], authOrigin = origin, ) { - assert.equal( - new URL(origin).origin, - origin, - "Supply an exact origin without a trailing slash", - ); assert.equal(new URL(origin).protocol, "https:"); + await healthSmoke(origin, sha, fetcher); const request = (path, options = {}) => smokeRequest(fetcher, origin + path, { redirect: "manual", ...options, }, delay, sha); - const health = await request("/api/health"); - assert.equal(health.status, 200); - assert.deepEqual(await health.json(), { - ok: true, - revision: sha, - }); assert.equal( (await request("/api/ready")).status, 200, @@ -273,29 +260,59 @@ async function emailLoginSmoke(request, origin) { ); } +/** + * Every Worker's smoke: the account's auth boundary, each other Worker's + * revision, and the relay's rendezvous once both the account and the relay + * passed. Independent parts run concurrently, and each retries on its own up + * to `attempts` times, `retryMs` apart, so a passed part never runs again. + */ +export async function smokeAll( + { account, relay, voice }, + sha, + { + fetcher = fetch, + preview = false, + providers = [], + oneTime = (origin) => oneTimeSmoke(origin), + attempts = 1, + retryMs = 10_000, + wait = delay, + } = {}, +) { + const retried = async (what, check) => { + for (let attempt = 1; ; attempt++) { + try { + return await check(); + } catch (error) { + if (attempt >= attempts) throw error; + console.log( + `${what} not ready (attempt ${attempt}/${attempts}); retrying in ${retryMs / 1000} seconds`, + ); + await wait(retryMs); + } + } + }; + const accountSmoke = retried(account, () => + smoke(account, sha, fetcher, preview, providers), + ); + const relayHealth = retried(relay, () => healthSmoke(relay, sha, fetcher)); + const voiceHealth = retried(voice, () => healthSmoke(voice, sha, fetcher)); + const rendezvous = Promise.all([accountSmoke, relayHealth]).then(() => + retried(`${relay} one-time`, () => oneTime(relay)), + ); + await Promise.all([accountSmoke, relayHealth, voiceHealth, rendezvous]); +} + if ( process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { - const [origin, relayOrigin, voiceOrigin, sha] = process.argv.slice(2); + const [origins, sha] = process.argv.slice(2); assert.match(sha ?? "", /^[a-f0-9]{40}$/); + const { account, relay, voice } = JSON.parse(origins); // A just-uploaded Worker may take a short time to become reachable everywhere. - for (let attempt = 1; ; attempt++) { - try { - await smoke(origin, sha, fetch, true); - await healthSmoke(relayOrigin, sha); - await oneTimeSmoke(relayOrigin); - await healthSmoke(voiceOrigin, sha); - console.log( - `Preview smoke checks passed: ${origin}/login, ${relayOrigin}/connect/, ${voiceOrigin} (${sha})`, - ); - break; - } catch (error) { - if (attempt === 6) throw error; - console.log( - `Preview not ready (attempt ${attempt}/6); retrying in 10 seconds`, - ); - await new Promise((resolve) => setTimeout(resolve, 10_000)); - } - } + await smokeAll({ account, relay, voice }, sha, { preview: true, attempts: 6 }); + console.log( + `Preview smoke checks passed: ${account}/login, ${relay}/connect/, ${voice} (${sha})`, + ); } diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs index d57cf45f9..eee181e6d 100644 --- a/hosted/scripts/preview.mjs +++ b/hosted/scripts/preview.mjs @@ -1,8 +1,8 @@ import { createHmac } from "node:crypto"; -import { readFile, writeFile, mkdir, appendFile, rm } from "node:fs/promises"; +import { writeFile, mkdir, appendFile, rm } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; +import { WORKERS, deployWorkers, fromStage, readConfigs } from "./workers.mjs"; export function required(env, name) { if (!env[name]) @@ -10,23 +10,23 @@ export function required(env, name) { return env[name]; } -/** Hosted's three Workers, as their production config files and script names spell them. */ -export const WORKERS = { - account: { config: "wrangler.jsonc", script: "hosted" }, - relay: { config: "wrangler.relay.jsonc", script: "relay" }, - voice: { config: "wrangler.voice.jsonc", script: "voice" }, -}; - -/** A PR's preview Worker, `dormouse-`; async function start(entry: string) { const mf = new Miniflare( - convertV4MiniflareOptions({ - modules: true, - script: (await bundleWorker(entry)).outputFiles[0].text, - compatibilityDate: wrangler.relay.compatibility_date, - compatibilityFlags: wrangler.relay.compatibility_flags, + miniflareOptions("relay", (await bundleWorker(entry)).outputFiles[0].text, { bindings: { APP_ORIGIN: origin }, - durableObjects: Object.fromEntries( - wrangler.relay.durable_objects!.bindings.map(({ name, class_name }) => [ - name, - { - className: class_name, - useSQLite: wrangler.relay.migrations!.some((migration) => - migration.new_sqlite_classes?.includes(class_name), - ), - }, - ]), - ), - ratelimits: Object.fromEntries( - wrangler.relay.ratelimits!.map(({ name, ...limit }) => [name, limit]), - ), 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 @@ -93,7 +71,7 @@ let production: Awaited>; let short: Awaited>; beforeAll(async () => { [production, short] = await Promise.all([ - start("server/relay-worker.ts"), + start(ENTRIES.relay), start("server/tests/one-time-entry.ts"), ]); }); diff --git a/hosted/server/tests/voice-entry.ts b/hosted/server/tests/voice-entry.ts index f2283dc20..c09d0ec72 100644 --- a/hosted/server/tests/voice-entry.ts +++ b/hosted/server/tests/voice-entry.ts @@ -1,3 +1,4 @@ +import type { ExecutionContext } from "hono"; import { voiceBindings } from "../bindings"; import { voiceApp } from "../voice-app"; // The after-speech sweep runs within the test instead of 10 s later. @@ -8,7 +9,7 @@ export default { fetch( request: Request, env: Parameters[1], - ctx: Parameters[2], + ctx: ExecutionContext, ) { if (new URL(request.url).pathname === "/__test/wait-until") return new Response(String(waitUntilCalls)); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index 771f1dab1..8b9c580fa 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -3,7 +3,6 @@ import { digest } from "@pgstencil/auth/security"; import { fileURLToPath } from "node:url"; import { Miniflare, - convertV4MiniflareOptions, Request as WorkerRequest, Response as WorkerResponse, } from "miniflare"; @@ -22,18 +21,18 @@ import { import type { Session } from "../../src/api"; import { ADMIN_EMAIL } from "../admin"; import { CRON_SWEEP_CAP, SPEECH_SWEEP_CAP, VOICE_DAILY_CAP } from "../voice"; -import { bundleWorker, wrangler } from "./bundle"; +import { ENTRIES, ORIGINS, bundleWorker, miniflareOptions, type Name } from "./bundle"; -const origin = "https://hosted.dormouse.sh"; -const voiceOrigin = "https://voice.dormouse.sh"; +const origin = ORIGINS.account; +const voiceOrigin = ORIGINS.voice; /** The sibling Workers' origins: same-site, so a browser sends them the login cookie. */ -const SAME_SITE = ["https://dormouse.sh", "https://relay.dormouse.sh", voiceOrigin]; +const SAME_SITE = ["https://dormouse.sh", ORIGINS.relay, voiceOrigin]; const bundle = (production: boolean | "preview") => bundleWorker( production === "preview" ? "server/preview-worker.ts" : production - ? "server/worker.ts" + ? ENTRIES.account : "server/tests/worker-entry.ts", production ? [] @@ -48,7 +47,7 @@ const productionBundle = bundle(true); const previewBundle = bundle("preview"); const voiceBundles = { test: bundleWorker("server/tests/voice-entry.ts"), - production: bundleWorker("server/voice-worker.ts"), + production: bundleWorker(ENTRIES.voice), preview: bundleWorker("server/voice-preview-worker.ts"), }; type Result = Awaited>; @@ -56,24 +55,23 @@ type Handler = ( request: WorkerRequest, ) => WorkerResponse | Promise; -/** One Worker under Miniflare; `database` backs the HYPERDRIVE binding. */ -const workerOptions = ({ - script, - database, - assets, - ...rest -}: { - script: string; - database: string; - assets?: Handler; - bindings: Record; - outboundService: Handler; -}) => - convertV4MiniflareOptions({ - modules: true, +/** Worker `name` under Miniflare; `database` backs the HYPERDRIVE binding. */ +const workerOptions = ( + name: Name, + { script, - compatibilityDate: wrangler.account.compatibility_date, - compatibilityFlags: wrangler.account.compatibility_flags, + database, + assets, + ...rest + }: { + script: string; + database: string; + assets?: Handler; + bindings: Record; + outboundService: Handler; + }, +) => + miniflareOptions(name, script, { hyperdrives: { HYPERDRIVE: database }, ...(assets ? { serviceBindings: { ASSETS: assets } } : {}), ...rest, @@ -171,7 +169,7 @@ async function fixture( }); }; const worker = new Miniflare( - workerOptions({ + workerOptions("account", { script: ( await (production === "preview" ? previewBundle @@ -203,7 +201,7 @@ async function fixture( const voice = () => (voiceWorker ??= (async () => { const started = new Miniflare( - workerOptions({ + workerOptions("voice", { script: ( await voiceBundles[ production === "preview" @@ -797,7 +795,7 @@ test("the voice Worker's cron sweeps ElevenLabs history with only its key and no maxInFlight = 0; const sweeper = async (bindings: Record) => { const worker = new Miniflare( - workerOptions({ + workerOptions("voice", { script: (await voiceBundles.production).outputFiles![0].text, // No APP_ORIGIN. bindings, diff --git a/hosted/server/voice-app.ts b/hosted/server/voice-app.ts index 3b55a54a7..6e150570b 100644 --- a/hosted/server/voice-app.ts +++ b/hosted/server/voice-app.ts @@ -1,4 +1,3 @@ -import type { ExecutionContext } from "hono"; import type { VoiceEnv } from "./bindings"; import { RUNS_NOTHING_POLICY } from "./headers"; import { elevenLabs, speakRoute, sweepOnCron } from "./voice"; @@ -13,7 +12,7 @@ export function voiceApp( bindings: (env: VoiceEnv) => VoiceEnv, { sweepDelayMs }: { sweepDelayMs?: number } = {}, ) { - const app = workerApp({ + return workerApp({ bindings, policy: () => RUNS_NOTHING_POLICY, unavailable: "Managed voice is temporarily unavailable. Please try again.", @@ -32,14 +31,9 @@ export function voiceApp( }; }); }, - }); - return { - fetch: (request: Request, env: VoiceEnv, ctx: ExecutionContext) => - app.fetch(request, env, ctx), - // The Cron Trigger: the same mapper decides whether a key reaches the sweep. - async scheduled(_controller: unknown, env: VoiceEnv) { - const key = bindings(env).ELEVENLABS_API_KEY; - if (key) await sweepOnCron(key); + // The Cron Trigger: the mapper decides whether a key reaches the sweep. + async scheduled(_controller, env) { + if (env.ELEVENLABS_API_KEY) await sweepOnCron(env.ELEVENLABS_API_KEY); }, - }; + }); } diff --git a/hosted/server/worker-app.ts b/hosted/server/worker-app.ts index 1bfe31c6c..a43627d99 100644 --- a/hosted/server/worker-app.ts +++ b/hosted/server/worker-app.ts @@ -1,30 +1,41 @@ -import { Hono } from "hono"; +import { Hono, type ExecutionContext } from "hono"; +import type { WorkerEnv } from "./bindings"; import { secureHeaders, type PolicyFor } from "./headers"; /** * What all three Workers share (`docs/specs/hosted.md` -> "Application * boundary"): the secure headers, the bindings mapper, the 421 gate, `/api/health`, * the 404 tail, and `onError`. Each entry mounts only its own routes, between - * the gate and the tail, and its `fallback` after the tail. + * the gate and the tail, and its `fallback` after the tail. A Cron Trigger's + * `scheduled` sees the same mapped bindings a route does. */ -export function workerApp({ +export function workerApp({ bindings, policy, + hashedAssets = [], routes, fallback, + scheduled, unavailable, }: { /** The only bindings that reach the routes. */ bindings: (env: E) => E; policy: PolicyFor; + /** Path prefixes of this Worker's content-hashed files, cached as immutable. */ + hashedAssets?: readonly string[]; routes: (app: Hono<{ Bindings: E }>) => void; /** Answers what nothing else did; without one, Hono's 404. */ fallback?: (app: Hono<{ Bindings: E }>) => void; + scheduled?: ( + controller: unknown, + env: E, + ctx: ExecutionContext, + ) => Promise; /** `onError`'s message. */ unavailable: string; }) { const app = new Hono<{ Bindings: E }>(); - secureHeaders(app, policy); + secureHeaders(app, policy, hashedAssets); // The mapper alone decides which bindings reach the routes; resolving it in // the request keeps a misconfigured deployment on onError, headers and all. app.use("*", async (c, next) => { @@ -46,5 +57,11 @@ export function workerApp( app.all("/__test/*", (c) => c.notFound()); fallback?.(app); app.onError((_error, c) => c.json({ message: unavailable }, 503)); - return app; + return { + fetch: app.fetch, + ...(scheduled && { + scheduled: (controller: unknown, env: E, ctx: ExecutionContext) => + scheduled(controller, bindings(env), ctx), + }), + }; } diff --git a/hosted/server/worker.ts b/hosted/server/worker.ts index 8a2de7bd8..b680decb1 100644 --- a/hosted/server/worker.ts +++ b/hosted/server/worker.ts @@ -10,7 +10,4 @@ const auth = createBetterAuthWorker({ email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM), }); -export default accountApp( - (request, env, ctx) => auth.fetch(request, env, ctx), - accountBindings, -); +export default accountApp(auth.fetch, accountBindings); diff --git a/hosted/vite.config.ts b/hosted/vite.config.ts index 7964f1237..e12fa9ef6 100644 --- a/hosted/vite.config.ts +++ b/hosted/vite.config.ts @@ -2,5 +2,6 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], - build: { sourcemap: false }, + // Each Worker's static files build under `dist//`. + build: { outDir: "dist/account", sourcemap: false }, }); diff --git a/hosted/wrangler.jsonc b/hosted/wrangler.jsonc index c5afa26f9..f56467049 100644 --- a/hosted/wrangler.jsonc +++ b/hosted/wrangler.jsonc @@ -15,7 +15,7 @@ } ], "assets": { - "directory": "./dist", + "directory": "./dist/account", "binding": "ASSETS", "not_found_handling": "single-page-application", "run_worker_first": true @@ -46,6 +46,8 @@ } ], "triggers": { + // Empty, never absent: an absent `triggers` leaves a deployed schedule in + // place, and the history sweep's moved to the voice Worker. "crons": [] }, "observability": { diff --git a/hosted/wrangler.relay.jsonc b/hosted/wrangler.relay.jsonc index c336dcb1e..d56c9c8fe 100644 --- a/hosted/wrangler.relay.jsonc +++ b/hosted/wrangler.relay.jsonc @@ -15,7 +15,7 @@ } ], "assets": { - "directory": "./dist-relay", + "directory": "./dist/relay", "binding": "ASSETS", "not_found_handling": "none", "run_worker_first": true diff --git a/lib/src/host/relay-origin.test.ts b/lib/src/host/relay-origin.test.ts index e33182c2a..db12427bd 100644 --- a/lib/src/host/relay-origin.test.ts +++ b/lib/src/host/relay-origin.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -14,6 +14,7 @@ import { } from '../../../scripts/relay-origin.mjs'; import { DEFAULT_RELAY_ORIGIN, + HOSTED_VOICE_ORIGIN, bakedRelay, bakedRelayMode, hostedOrigin, @@ -21,6 +22,12 @@ import { isRelayOrigin, } from './relay-origin'; +/** The production `APP_ORIGIN` of a Hosted Worker's Wrangler config (JSON plus whole-line `//` comments). */ +function hostedWorkerOrigin(config: string): string { + const text = readFileSync(new URL(`../../../hosted/${config}`, import.meta.url), 'utf8'); + return JSON.parse(text.replace(/^\s*\/\/.*$/gm, '')).vars.APP_ORIGIN; +} + /** * An `https://` origin of exactly `length` characters under `example`, built * from labels a real host could have, prepended until it fits. @@ -68,6 +75,11 @@ describe('the baked relay origin', () => { expect(BUILD_DEFAULT).toBe(DEFAULT_RELAY_ORIGIN); }); + it('names the origins Hosted deploys its relay and voice Workers at', () => { + expect(DEFAULT_RELAY_ORIGIN).toBe(hostedWorkerOrigin('wrangler.relay.jsonc')); + expect(HOSTED_VOICE_ORIGIN).toBe(hostedWorkerOrigin('wrangler.voice.jsonc')); + }); + it('reads as the Hosted default where nothing was baked (the test runner)', () => { expect(bakedRelay()).toEqual({ origin: DEFAULT_RELAY_ORIGIN, mode: 'hosted' }); expect(bakedRelayMode()).toBe('hosted'); From 507e2dd86a70d7ac9d70a15f6da828448f14e47a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 22:51:02 -0700 Subject: [PATCH 4/7] Deploy relay and voice before the account, and smoke each Worker on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review fixes: the account preview carries no Durable Object migrations, production deploys relay → voice → account so the rendezvous never lapses, retries only the relay and voice smokes, runs the one-time smoke on relay health alone, and the local account dev server serves no speak route. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 14 +++--- docs/specs/hosted.rationale.md | 5 +++ hosted/README.md | 19 ++++---- hosted/scripts/preview-smoke.mjs | 39 ++++++++++------- hosted/scripts/preview.mjs | 10 +++-- hosted/scripts/preview.test.mjs | 65 ++++++++++++++++++++-------- hosted/scripts/production.mjs | 21 ++++++--- hosted/scripts/production.test.mjs | 47 +++++++++++++++++--- hosted/scripts/workers.mjs | 22 +++++----- hosted/server/dev.ts | 27 ++---------- hosted/server/tests/boundary.test.ts | 19 ++++++++ lib/src/host/relay-origin.test.ts | 16 +++---- 12 files changed, 197 insertions(+), 107 deletions(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index bb17a5143..e83494a70 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -79,7 +79,7 @@ Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 5. 429 once the owner's UTC-day counter reaches 500. The increment is atomic and precedes the upstream call, so failed upstream attempts count. 6. 502 when ElevenLabs throws or answers non-2xx. -**Never log the text or forward an upstream body or status.** The upstream URL, model `eleven_flash_v2_5`, and format `mp3_44100_128` are fixed in code; no binding or request field redirects them. `ELEVENLABS_API_KEY` is the voice Worker's secret, which production preflight requires there; the voice preview mapper never passes it. Only the local development entry substitutes silent MP3 when the key is unset. Tests fake ElevenLabs in Miniflare's outbound service, so no entry carries an upstream override. +**Never log the text or forward an upstream body or status.** The upstream URL, model `eleven_flash_v2_5`, and format `mp3_44100_128` are fixed in code; no binding or request field redirects them. `ELEVENLABS_API_KEY` is the voice Worker's secret, which production preflight requires there; the voice preview mapper never passes it. Tests fake ElevenLabs in Miniflare's outbound service, so no entry carries an upstream override. **Must delete ElevenLabs speech history, which keeps each generation's text, from the production voice Worker only.** A successful speak schedules one sweep about 10 s later in `waitUntil`; a Cron Trigger every 5 minutes sweeps what that missed. No retention bound is guaranteed (rationale). @@ -92,13 +92,13 @@ Source of truth: `isAdmin` in `hosted/server/admin.ts`; `voiceTokenRoutes` / `sp ## Development and release -**Must run local development with `dor tool hosted` inside Dormouse.** A single `http://localhost:` origin, bound to loopback on an OS-assigned port unless `PORT` pins one, serves Vite and Node auth, with a disposable development database, and mounts speak beside the token routes. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback, without a database (`docs/specs/one-time.md` -> "Dev loop"). +**Must run local development with `dor tool hosted` inside Dormouse.** A single `http://localhost:` origin, bound to loopback on an OS-assigned port unless `PORT` pins one, serves Vite and Node auth, with a disposable development database, and the voice token routes but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. `dor tool one-time` runs the relay Worker on loopback, without a database (`docs/specs/one-time.md` -> "Dev loop"). **Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free Miniflare suites (the rendezvous, and the three Workers' boundary); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts` needing Docker. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. **Must keep production, test, and preview databases and credentials separate.** The development and preview entries are email-only. Production configuration and operator steps live in `hosted/README.md`. -Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `hosted/server/dev.ts`; `hosted/server/tests/workers.test.ts`; `build` in `hosted/package.json`. +Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `hosted/server/dev.ts`; `hosted/server/tests/workers.test.ts`; `build` in `hosted/package.json`. Pinned by `the local development entry serves no speak` in `hosted/server/tests/boundary.test.ts`. ## PR previews @@ -114,15 +114,15 @@ Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workf ## Production releases -**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`; the relay has none). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy account, relay, then voice, stopping at a failure. Production has no public candidate URL. +**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`; the relay has none). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure (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. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay starts its own `v1`. A deploy restarts every room, dropping links still waiting or mid-handshake; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay after the account smoke and the relay's revision check. +**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. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay starts its own `v1`. A deploy restarts every room, dropping links still waiting or mid-handshake; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay once the relay's revision check passes, whatever the account's outcome; a failed smoke reports every failed part. -**Must bound retries.** Health GETs require the selected revision, sharing six five-second retries for transport failures or healthy stale revisions. Retry rate-limited OAuth once; never replay POSTs after transport failures. +**Must bound retries.** Health GETs require the selected revision, sharing six five-second retries for transport failures or healthy stale revisions. Retry rate-limited OAuth once; never replay POSTs after transport failures. Production repeats only the relay and voice smokes, up to six times 10 s apart (rationale). **Must record an immutable annotated hosted/YYYY-MM-DD tag only after live verification.** Tags identify the deployed commit and verification run/attempt; retries are idempotent and redeployments get new tags. Dating and repeat-deployment suffixes: `recordDeployment`. Code rollback never reverses migrations. -Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` in `hosted/scripts/production.mjs`; `deployWorkers` in `hosted/scripts/workers.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` / `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. +Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` / `productionSmoke` in `hosted/scripts/production.mjs`; `WORKERS` / `deployWorkers` in `hosted/scripts/workers.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` / `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. ## Future diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 1b1d261c8..2dedb2364 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -19,3 +19,8 @@ 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. - 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. - No released desktop build bakes `hosted.dormouse.sh` (v1.1.0, the last release, predates one-time and managed voice), so the routes moved off it with no compatibility shim. + +## 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. +- 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/hosted/README.md b/hosted/README.md index 4f8aa08a4..bbce1407e 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -31,10 +31,9 @@ URL it prints. Request a code for a test address and read it at the worktree path; `docs/specs/hosted.md` -> "Development and release" owns what the local entry serves and what production omits. The port is OS-assigned unless you set `PORT`. Do not share this local inbox publicly. The local -origin serves speak beside the token routes, which production splits across -the account and voice Workers; set `ELEVENLABS_API_KEY` in the environment of -`pnpm dev:hosted` to hear real speech (`docs/specs/hosted.md` -> "Managed -voice"). +origin serves the voice token routes but not speak: a Hosted build speaks only +at `https://voice.dormouse.sh` (`docs/specs/hosted.md` -> "Development and +release"). ```sh pnpm test:hosted @@ -350,13 +349,15 @@ credential pair do and do not enable. Facebook is outside this milestone. Default `promote=false` verifies and builds only. `promote=true` enters the protected production environment and runs the preflight, backup and restore-test, migration, deployment, and live-verification sequence in - `docs/specs/hosted.md` -> "Production releases", deploying the account, - relay, and voice Workers in that order. No real mail is sent by its smoke + `docs/specs/hosted.md` -> "Production releases", deploying the relay, + voice, and account Workers in that order. No real mail is sent by its smoke checks, and passing them is not acceptance. - The first release after the split deletes the account Worker's - `OneTimeRoom` (its append-only migration `v2`) and creates the relay's - (`v1`): links open at that moment drop, and both become rollback floors. + The first release after the split creates the relay's `OneTimeRoom` (`v1`), + then deletes the account Worker's (its append-only migration `v2`): links + open at that moment drop, and both become rollback floors. Its smoke repeats + the relay and voice checks up to six times, 10 s apart, while their new + custom domains' certificates issue. 3. Tagging runs only after live verification, on the terms in `docs/specs/hosted.md` -> "Production releases". If only tagging fails, rerun failed jobs: it records the original deployment without deploying again. diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs index d86b715eb..c8c00942d 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -262,9 +262,12 @@ async function emailLoginSmoke(request, origin) { /** * Every Worker's smoke: the account's auth boundary, each other Worker's - * revision, and the relay's rendezvous once both the account and the relay - * passed. Independent parts run concurrently, and each retries on its own up - * to `attempts` times, `retryMs` apart, so a passed part never runs again. + * revision, and the relay's rendezvous once the relay's revision passed — never + * waiting on the account, so an account failure hides no relay one. Independent + * parts run concurrently, and each retries on its own up to its `attempts` + * (one count for all, or `{ account, relay, voice }`; the rendezvous takes the + * relay's), `retryMs` apart, so a passed part never runs again. Every part + * settles before the smoke fails, and the failure names each part that failed. */ export async function smokeAll( { account, relay, voice }, @@ -279,28 +282,34 @@ export async function smokeAll( wait = delay, } = {}, ) { - const retried = async (what, check) => { + const retried = async (part, what, check) => { + const limit = typeof attempts === "number" ? attempts : (attempts[part] ?? 1); for (let attempt = 1; ; attempt++) { try { return await check(); } catch (error) { - if (attempt >= attempts) throw error; + if (attempt >= limit) throw error; console.log( - `${what} not ready (attempt ${attempt}/${attempts}); retrying in ${retryMs / 1000} seconds`, + `${what} not ready (attempt ${attempt}/${limit}); retrying in ${retryMs / 1000} seconds`, ); await wait(retryMs); } } }; - const accountSmoke = retried(account, () => - smoke(account, sha, fetcher, preview, providers), - ); - const relayHealth = retried(relay, () => healthSmoke(relay, sha, fetcher)); - const voiceHealth = retried(voice, () => healthSmoke(voice, sha, fetcher)); - const rendezvous = Promise.all([accountSmoke, relayHealth]).then(() => - retried(`${relay} one-time`, () => oneTime(relay)), - ); - await Promise.all([accountSmoke, relayHealth, voiceHealth, rendezvous]); + const relayHealth = retried("relay", relay, () => healthSmoke(relay, sha, fetcher)); + const results = await Promise.allSettled([ + retried("account", account, () => smoke(account, sha, fetcher, preview, providers)), + relayHealth, + retried("voice", voice, () => healthSmoke(voice, sha, fetcher)), + relayHealth.then(() => retried("relay", `${relay} one-time`, () => oneTime(relay))), + ]); + // A relay that failed its revision fails the rendezvous with the same error. + const failures = [ + ...new Set(results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))), + ]; + if (failures.length === 1) throw failures[0]; + if (failures.length) + throw new AggregateError(failures, failures.map((error) => error.message).join("\n")); } if ( diff --git a/hosted/scripts/preview.mjs b/hosted/scripts/preview.mjs index eee181e6d..a6ead4f49 100644 --- a/hosted/scripts/preview.mjs +++ b/hosted/scripts/preview.mjs @@ -20,7 +20,7 @@ export function previewName(pr, name) { /** * A Worker's preview config, allowlisted from its production `base`: its own * workers.dev origin, its registry preview entry, assets rebased, its Durable - * Objects and migrations (a namespace belongs to the Worker implementing it), + * Objects with their migrations (a namespace belongs to the Worker implementing it), * rate limits in preview-only namespaces (counters are account-wide), and the * PR's Hyperdrive wherever the base binds one. Never routes, triggers, other * bindings, or production vars. @@ -56,8 +56,12 @@ export function previewConfig(base, env, worker, hyperdriveId) { id: hyperdriveId, })); } - if (base.durable_objects) config.durable_objects = base.durable_objects; - if (base.migrations) config.migrations = base.migrations; + // Migrations travel only with the Durable Objects they create: a preview + // Worker that implements none starts with none to delete. + if (base.durable_objects) { + config.durable_objects = base.durable_objects; + if (base.migrations) config.migrations = base.migrations; + } if (base.ratelimits) config.ratelimits = base.ratelimits.map((limit) => ({ ...limit, diff --git a/hosted/scripts/preview.test.mjs b/hosted/scripts/preview.test.mjs index 5f91d60c4..4a0c9eb4c 100644 --- a/hosted/scripts/preview.test.mjs +++ b/hosted/scripts/preview.test.mjs @@ -78,8 +78,10 @@ test("each preview configuration isolates its origin and excludes production bin assert.equal(configs.voice.assets, undefined); for (const worker of ["account", "relay"]) assert.equal(configs[worker].assets.run_worker_first, true, worker); - // The account's migrations, append-only, delete the class its Worker no longer implements. - assert.deepEqual(configs.account.migrations, bases.account.migrations); + // Production's account keeps the migrations that deleted its old room; its + // preview, implementing no Durable Object, carries none. + assert.ok(bases.account.migrations?.length); + assert.equal(configs.account.migrations, undefined); assert.equal(configs.account.durable_objects, undefined); assert.equal(configs.account.ratelimits, undefined); assert.deepEqual(configs.relay.durable_objects, bases.relay.durable_objects); @@ -267,7 +269,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-hosted-pr-42", "dormouse-relay-pr-42", "dormouse-voice-pr-42"]; + const workers = ["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], @@ -353,21 +355,22 @@ test("deployment smoke rejects malformed health before making any auth requests" assert.equal(requests, 1); }); -test("the smoke runs its parts concurrently, retries each alone, and checks the rendezvous after the account and relay", async () => { +test("the smoke runs its parts concurrently, retries each alone, and checks the rendezvous after the relay's revision alone", async () => { const origins = { account: "https://account.example.test", relay: "https://relay.example.test", voice: "https://voice.example.test", }; const events = []; - let relayHealthy = false; + // How many more health checks each origin fails before it is healthy. + const unhealthy = {}; // Every request the production smoke makes, answered as a healthy account would. const fetcher = async (url, init = {}) => { const { origin, pathname } = new URL(url); if (pathname === "/api/health") { events.push(`${origin} health`); - if (origin === origins.relay && !relayHealthy) { - relayHealthy = true; + if (unhealthy[origin] > 0) { + unhealthy[origin]--; return Response.json({ ok: false }, { status: 503 }); } return Response.json({ ok: true, revision: env.BUILD_SHA }); @@ -391,36 +394,62 @@ test("the smoke runs its parts concurrently, retries each alone, and checks the return new Response("", { headers: { "content-type": "text/html" } }); return new Response(null, { status: 404 }); }; + const count = (event) => events.filter((e) => e === event).length; + const oneTime = async (origin) => { + assert.equal(origin, origins.relay); + events.push("one-time"); + }; const waits = []; + unhealthy[origins.relay] = 1; await smokeAll(origins, env.BUILD_SHA, { fetcher, attempts: 2, wait: async (ms) => waits.push(ms), - oneTime: async (origin) => { - assert.equal(origin, origins.relay); - events.push("one-time"); - }, + oneTime, }); - // The relay alone retried; the rendezvous ran once, after both passed. + // The relay alone retried; the rendezvous ran once, after it passed. assert.deepEqual(waits, [10_000]); - const count = (event) => events.filter((e) => e === event).length; assert.equal(count(`${origins.account} health`), 1); assert.equal(count(`${origins.voice} health`), 1); assert.equal(count(`${origins.relay} health`), 2); assert.equal(count("one-time"), 1); assert.equal(events.at(-1), "one-time"); - // A part out of attempts fails the smoke, and the rendezvous never runs on a relay that did not pass. - relayHealthy = false; + // Per-part attempts: the account runs once while the relay and voice retry. events.length = 0; + waits.length = 0; + Object.assign(unhealthy, { [origins.account]: 1, [origins.relay]: 2, [origins.voice]: 1 }); await assert.rejects( smokeAll(origins, env.BUILD_SHA, { fetcher, - oneTime: async () => events.push("one-time"), + attempts: { account: 1, relay: 3, voice: 2 }, + wait: async (ms) => waits.push(ms), + oneTime, }), - /must be healthy/, + { message: new RegExp(`^${origins.account} must be healthy`) }, + ); + assert.equal(count(`${origins.account} health`), 1); + assert.equal(count(`${origins.relay} health`), 3); + assert.equal(count(`${origins.voice} health`), 2); + // An account failure does not hold back the rendezvous. + assert.equal(count("one-time"), 1); + + // A part out of attempts fails the smoke, the rendezvous never runs on a + // relay that did not pass, and every failed part is reported. + events.length = 0; + Object.assign(unhealthy, { [origins.account]: 1, [origins.relay]: 1 }); + await assert.rejects( + smokeAll(origins, env.BUILD_SHA, { fetcher, oneTime }), + (error) => { + assert.ok(error instanceof AggregateError); + assert.deepEqual( + error.errors.map(({ message }) => message.split("\n")[0]), + [`${origins.account} must be healthy`, `${origins.relay} must be healthy`], + ); + return true; + }, ); - assert.ok(!events.includes("one-time")); + assert.equal(count("one-time"), 0); }); test("a relay or voice health check requires the deployed revision, and nothing else", async () => { diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs index ce572b1e6..fb048c58e 100644 --- a/hosted/scripts/production.mjs +++ b/hosted/scripts/production.mjs @@ -79,6 +79,19 @@ export function requiredSecrets(configs) { ]), ); } +/** + * The release's live verification. 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. `options` stands in for the network in tests. + */ +export function productionSmoke(configs, sha, options = {}) { + return smokeAll(originsOf(configs), sha, { + providers: oauthProviders(configs.account), + attempts: { account: 1, relay: 6, voice: 6 }, + ...options, + }); +} export async function verifyPackages() { const commits = []; for (const name of ["pgstencil", "@pgstencil/auth/better-auth"]) { @@ -143,18 +156,14 @@ if ( const configs = productionConfigs(await readConfigs(), process.env); const action = process.argv[2]; if (action === "smoke") { - await smokeAll( - originsOf(configs), - process.env.BUILD_SHA, - { providers: oauthProviders(configs.account) }, - ); + await productionSmoke(configs, process.env.BUILD_SHA); console.log( "Hosted production revisions, auth boundary, and one-time rendezvous verified.", ); } else if (action === "preflight" || action === "deploy") { await verifyPackages(); await preflight(process.env, configs); - // Account, relay, voice; a failure stops the rest. + // Relay, voice, then account; a failure stops the rest. if (action === "deploy") await deployWorkers("production", configs); } else throw new Error("Use preflight, deploy or smoke"); } catch (error) { diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs index c7f7eda80..1085d8c30 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -5,6 +5,7 @@ import { readFile, rm } from "node:fs/promises"; import { productionConfig, productionConfigs, + productionSmoke, preflight, } from "./production.mjs"; import { deployWorkers, oauthProviders, readConfigs } from "./workers.mjs"; @@ -67,7 +68,7 @@ test("each production config keeps its canonical domain and production entry, an ); assert.throws(() => productionConfig(bases.account, env)); }); -test("Workers deploy in registry order from one config path each, and a failure stops the rest", async (t) => { +test("Workers deploy 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 }), ); @@ -82,22 +83,56 @@ test("Workers deploy in registry order from one config path each, and a failure 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. [ - ["dormouse-hosted", ".wrangler/deploy-test/wrangler.account.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"], ], ); deployed.length = 0; await assert.rejects( - deployWorkers("deploy-test", configs, { spawn: spawn("dormouse-relay") }), - { message: "dormouse-relay deploy failed" }, + deployWorkers("deploy-test", configs, { spawn: spawn("dormouse-voice") }), + { message: "dormouse-voice deploy failed" }, ); - assert.deepEqual(deployed.map(([name]) => name), ["dormouse-hosted", "dormouse-relay"]); + assert.deepEqual(deployed.map(([name]) => name), ["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" }); }); +test("live verification retries the relay and voice while their domains come up, and runs the account's POSTs once", async () => { + const health = {}; + // Each origin's health fails this many times before it passes. + const failing = { + "https://hosted.dormouse.sh": 1, + "https://relay.dormouse.sh": 5, + "https://voice.dormouse.sh": 5, + }; + const fetcher = async (url) => { + const { origin, pathname } = new URL(url); + assert.equal(pathname, "/api/health"); + health[origin] = (health[origin] ?? 0) + 1; + return health[origin] > failing[origin] + ? Response.json({ ok: true, revision: env.BUILD_SHA }) + : Response.json({ ok: false }, { status: 503 }); + }; + let rendezvous = 0; + await assert.rejects( + productionSmoke(configs, env.BUILD_SHA, { + fetcher, + wait: async () => {}, + oneTime: async () => rendezvous++, + }), + // The account's failure alone: the relay and voice passed on their sixth try. + { message: /^https:\/\/hosted\.dormouse\.sh must be healthy/ }, + ); + assert.deepEqual(health, { + "https://hosted.dormouse.sh": 1, + "https://relay.dormouse.sh": 6, + "https://voice.dormouse.sh": 6, + }); + assert.equal(rendezvous, 1); +}); test("the history sweep's cron is the voice Worker's alone, and the account's removes its old one", () => { assert.deepEqual(configs.voice.triggers, { crons: ["*/5 * * * *"] }); // An absent `triggers` would leave a deployed schedule in place. @@ -164,7 +199,7 @@ test("preflight rejects wrong databases, caching, reused roles, and incomplete s const read = []; await preflight(env, configs, provider({ read })); // The relay holds no secret, so nothing is asked of it. - assert.deepEqual(read, ["dormouse-hosted", "dormouse-voice"]); + assert.deepEqual(read.sort(), ["dormouse-hosted", "dormouse-voice"]); for (const missing of accountSecrets) await assert.rejects( preflight( diff --git a/hosted/scripts/workers.mjs b/hosted/scripts/workers.mjs index 30b4da937..f4203bbd2 100644 --- a/hosted/scripts/workers.mjs +++ b/hosted/scripts/workers.mjs @@ -11,8 +11,20 @@ const hosted = new URL("../", import.meta.url); * Hosted's three Workers, 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 live. */ export const WORKERS = { + relay: { + config: "wrangler.relay.jsonc", + // The production entry: its mapper passes nothing a preview lacks. + previewMain: "server/relay-worker.ts", + }, + voice: { + config: "wrangler.voice.jsonc", + previewMain: "server/voice-preview-worker.ts", + secrets: () => ["ELEVENLABS_API_KEY"], + }, account: { config: "wrangler.jsonc", previewMain: "server/preview-worker.ts", @@ -31,16 +43,6 @@ export const WORKERS = { /** Its preview is deployed with the derived `AUTH_SECRET`. */ previewSecrets: true, }, - relay: { - config: "wrangler.relay.jsonc", - // The production entry: its mapper passes nothing a preview lacks. - previewMain: "server/relay-worker.ts", - }, - voice: { - config: "wrangler.voice.jsonc", - previewMain: "server/voice-preview-worker.ts", - secrets: () => ["ELEVENLABS_API_KEY"], - }, }; /** A Wrangler config: JSON, plus `//` comments on lines of their own. */ diff --git a/hosted/server/dev.ts b/hosted/server/dev.ts index 267dcab8f..67c304844 100644 --- a/hosted/server/dev.ts +++ b/hosted/server/dev.ts @@ -10,12 +10,7 @@ import { EmailDev, SystemTime } from "pgstencil"; import { authPolicy } from "./policy"; import { migrations } from "./migrations"; import { allowedDevRequest } from "./dev-host-guard"; -import { - elevenLabs, - speakRoute, - voiceTokenRoutes, - type Synthesize, -} from "./voice"; +import { voiceTokenRoutes } from "./voice"; // Bind first, then derive the origin from the port actually bound, so an unset // PORT runs beside another checkout's server. `localhost`, not `127.0.0.1`: it @@ -63,27 +58,13 @@ const auth = createAuthApp({ auth.app.get("/api/dev/emails", (c) => c.json(email.all().map(({ to, text }) => ({ to, text }))), ); -// A quarter second of silent MP3 (MPEG-1 Layer III, 128 kbps, 44.1 kHz; zeroed -// side info decodes as silence). -const silence: Synthesize = async () => { - const frame = 417; - const audio = new Uint8Array(frame * 10); - for (let i = 0; i < audio.length; i += frame) - audio.set([0xff, 0xfb, 0x90, 0x64], i); - return new Response(audio, { headers: { "content-type": "audio/mpeg" } }); -}; -const elevenLabsKey = process.env.ELEVENLABS_API_KEY; const app = new Hono(); voiceTokenRoutes(app, () => ({ databaseUrl, auth: (request: Request) => auth.app.fetch(request), })); -// Deployed, speak is the voice Worker's alone; locally it sits beside the -// token routes on this one origin, so a minted token can be tried with curl. -speakRoute(app, () => ({ - databaseUrl, - synthesize: elevenLabsKey ? elevenLabs(elevenLabsKey) : silence, -})); +// No speak: a Hosted build speaks only at the fixed voice origin, so no +// Dormouse build could reach one here. app.all("*", (c) => auth.app.fetch(c.req.raw)); const vite = await createViteServer({ server: { @@ -100,7 +81,7 @@ ready = { vite, }; console.log( - `Dormouse Hosted: ${origin}\nLocal email inbox: ${origin}/api/dev/emails\nEmail stays local; OAuth is disabled in this development entry.\nManaged voice: ${elevenLabsKey ? "real ElevenLabs key" : "silent fake audio (ELEVENLABS_API_KEY unset)"}.`, + `Dormouse Hosted: ${origin}\nLocal email inbox: ${origin}/api/dev/emails\nEmail stays local; OAuth is disabled in this development entry.`, ); for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, async () => { diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 12a7607c1..66eb76874 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -1,5 +1,6 @@ import { test, expect, beforeAll, afterAll, vi } from "vitest"; import type { ExecutionContext } from "hono"; +import { build } from "esbuild"; import { Miniflare, Response as WorkerResponse } from "miniflare"; import { ONE_TIME_PAGE_PATH, ONE_TIME_WS_ROUTES } from "remote-lib-common"; import { @@ -217,6 +218,24 @@ test("speak is the voice Worker's, bearer-only", async () => { ); }); +test("the local development entry serves no speak", async () => { + // A Hosted build speaks only at the fixed voice origin, so a local speak + // would be unreachable; the bundle keeps only what the entry mounts. + const { + outputFiles: [dev], + } = await build({ + entryPoints: ["server/dev.ts"], + bundle: true, + write: false, + format: "esm", + platform: "node", + packages: "external", + }); + expect(dev.text).toContain("/api/voice/tokens"); + expect(dev.text).not.toContain("/api/voice/speak"); + expect(dev.text).not.toContain("api.elevenlabs.io"); +}); + test("the ElevenLabs history sweep runs on the voice Worker alone", async () => { outbound.length = 0; for (const name of ["account", "relay"] as const) { diff --git a/lib/src/host/relay-origin.test.ts b/lib/src/host/relay-origin.test.ts index db12427bd..04330fabb 100644 --- a/lib/src/host/relay-origin.test.ts +++ b/lib/src/host/relay-origin.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -12,6 +12,7 @@ import { relayOriginDefine, resolveRelayOrigin, } from '../../../scripts/relay-origin.mjs'; +import { readConfigs } from '../../../hosted/scripts/workers.mjs'; import { DEFAULT_RELAY_ORIGIN, HOSTED_VOICE_ORIGIN, @@ -22,12 +23,6 @@ import { isRelayOrigin, } from './relay-origin'; -/** The production `APP_ORIGIN` of a Hosted Worker's Wrangler config (JSON plus whole-line `//` comments). */ -function hostedWorkerOrigin(config: string): string { - const text = readFileSync(new URL(`../../../hosted/${config}`, import.meta.url), 'utf8'); - return JSON.parse(text.replace(/^\s*\/\/.*$/gm, '')).vars.APP_ORIGIN; -} - /** * An `https://` origin of exactly `length` characters under `example`, built * from labels a real host could have, prepended until it fits. @@ -75,9 +70,10 @@ describe('the baked relay origin', () => { expect(BUILD_DEFAULT).toBe(DEFAULT_RELAY_ORIGIN); }); - it('names the origins Hosted deploys its relay and voice Workers at', () => { - expect(DEFAULT_RELAY_ORIGIN).toBe(hostedWorkerOrigin('wrangler.relay.jsonc')); - expect(HOSTED_VOICE_ORIGIN).toBe(hostedWorkerOrigin('wrangler.voice.jsonc')); + it('names the origins Hosted deploys its relay and voice Workers at', async () => { + const { relay, voice } = await readConfigs(); + expect(DEFAULT_RELAY_ORIGIN).toBe(relay.vars.APP_ORIGIN); + expect(HOSTED_VOICE_ORIGIN).toBe(voice.vars.APP_ORIGIN); }); it('reads as the Hosted default where nothing was baked (the test runner)', () => { From d9b53c188050589afc2479908e212fec944e440c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 23:55:44 -0700 Subject: [PATCH 5/7] =?UTF-8?q?Name=20voice.dormouse.sh=20in=20Settings=20?= =?UTF-8?q?=E2=86=92=20Network's=20managed-voice=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- lib/src/components/NetworkSettings.test.tsx | 6 +++--- lib/src/components/NetworkSettings.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/src/components/NetworkSettings.test.tsx b/lib/src/components/NetworkSettings.test.tsx index c54cd7e89..37448bf54 100644 --- a/lib/src/components/NetworkSettings.test.tsx +++ b/lib/src/components/NetworkSettings.test.tsx @@ -73,7 +73,7 @@ describe('connectionsFor', () => { it('lists Hosted only while a link is open, and the phone on an allowed network, under Local networks', () => { expect(connectionsFor(facts())).toEqual([ { - to: 'hosted.dormouse.sh', + to: 'relay.dormouse.sh', when: 'Only while a one-time link is open', carries: 'Encrypted handshakes. Never terminal traffic.', }, @@ -86,7 +86,7 @@ describe('connectionsFor', () => { it('lists Hosted while a link is open, Cloudflare’s STUN as a phone connects, and the phone on any network, under Anywhere', () => { const rows = [ { - to: 'hosted.dormouse.sh', + to: 'relay.dormouse.sh', when: 'Only while a one-time link is open', carries: 'Encrypted handshakes. Never terminal traffic.', }, @@ -130,7 +130,7 @@ describe('connectionsFor', () => { connectionsFor(facts({ policy: { ...LOCAL, allowed: [] }, ...over })); expect(voice({ managedVoice: true })).toEqual([ { - to: 'hosted.dormouse.sh', + to: 'voice.dormouse.sh', when: 'When an alert is spoken in the managed voice', carries: 'The pane’s name and the voice id, which Hosted passes to ElevenLabs.', }, diff --git a/lib/src/components/NetworkSettings.tsx b/lib/src/components/NetworkSettings.tsx index 5a4bcda79..dd4d56819 100644 --- a/lib/src/components/NetworkSettings.tsx +++ b/lib/src/components/NetworkSettings.tsx @@ -15,6 +15,7 @@ import { useManagedVoiceConfigured } from './ManagedVoiceSection'; import { hostOf, useBusyAction } from './remote-control-shared'; import { HeldEnrollment, RemoteControlSection } from './RemoteControlSection'; import type { BurrowConsoleStatus } from '../host/remote/service-protocol'; +import { HOSTED_VOICE_ORIGIN } from '../host/relay-origin'; import { getPlatform } from '../lib/platform'; import { CLOUDFLARE_STUN_HOST } from '../remote/direct/ice-servers'; import type { UpdatesPort, UpdatesSnapshot } from '../lib/platform/types'; @@ -227,7 +228,7 @@ export function connectionsFor(facts: NetworkFacts): ConnectionRow[] { } if (facts.managedVoice && status.relayMode === 'hosted') { rows.push({ - to: relay, + to: hostOf(HOSTED_VOICE_ORIGIN), when: 'When an alert is spoken in the managed voice', carries: 'The pane’s name and the voice id, which Hosted passes to ElevenLabs.', }); From 460087fdbb6b7de4a58dbd3296e96677bd83c346 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 00:14:29 -0700 Subject: [PATCH 6/7] Let specs name hosted/dist, which a clean checkout lacks Co-Authored-By: Claude Opus 5.5 --- scripts/spec-lint.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/spec-lint.mjs b/scripts/spec-lint.mjs index 1fb411a32..ded91f89e 100644 --- a/scripts/spec-lint.mjs +++ b/scripts/spec-lint.mjs @@ -92,7 +92,7 @@ const TOP_LEVEL_DIRS = [ // output which does not exist in a clean checkout. const SKIP_PATH_PREFIXES = [ 'lib/dist', 'dor/dist', 'vscode-ext/dist', 'vscode-ext/media', - 'standalone/dist', + 'standalone/dist', 'hosted/dist', 'website/src/data/changelog.json', // gitignored, generated by website prebuild (deploy.md) 'canopy/node_modules', // created by pnpm install; lint:specs must pass on a fresh checkout (webgl-text.md) ]; From 9f9d9a798cf6532ae34c19fd304ff23777a8ce24 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 12:40:33 -0700 Subject: [PATCH 7/7] Smoke the relay in production before the account deploys The account's v2 deletes the old OneTimeRoom, so it must never run until the relay serving the replacement passes its revision check and oneTimeSmoke. deployWorkers gains an afterDeploy hook; deployProduction runs relaySmoke (with the relay's six bounded attempts) after the relay deploy, and a failure stops before voice or account deploy. The full smoke still runs at the end. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/hosted-production.yml | 2 +- docs/specs/hosted.md | 4 +- docs/specs/hosted.rationale.md | 1 + hosted/README.md | 6 +- hosted/scripts/preview-smoke.mjs | 82 ++++++++++++++++--------- hosted/scripts/production.mjs | 41 ++++++++++--- hosted/scripts/production.test.mjs | 56 +++++++++++++++++ hosted/scripts/workers.mjs | 9 ++- 8 files changed, 155 insertions(+), 46 deletions(-) diff --git a/.github/workflows/hosted-production.yml b/.github/workflows/hosted-production.yml index 1792bf2e3..4008b3545 100644 --- a/.github/workflows/hosted-production.yml +++ b/.github/workflows/hosted-production.yml @@ -88,7 +88,7 @@ jobs: run: pnpm --filter dormouse-hosted db:migrate && pnpm --filter dormouse-hosted db:validate env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - - name: Deploy verified build to the account, relay, and voice Workers + - name: Deploy the relay and verify its rendezvous, then the voice and account Workers run: node hosted/scripts/production.mjs deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }} diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index e83494a70..ef6b512d8 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -114,7 +114,7 @@ Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workf ## Production releases -**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`; the relay has none). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure (rationale). Production has no public candidate URL. +**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's pinned name and origin and lone custom domain; preflight checks uncached Hyperdrive, matching migration/runtime database identity with distinct roles, and each Worker's own secret names (the voice's is `ELEVENLABS_API_KEY`; the relay has none). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check 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. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay starts its own `v1`. A deploy restarts every room, dropping links still waiting or mid-handshake; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay once the relay's revision check passes, whatever the account's outcome; a failed smoke reports every failed part. @@ -122,7 +122,7 @@ Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workf **Must record an immutable annotated hosted/YYYY-MM-DD tag only after live verification.** Tags identify the deployed commit and verification run/attempt; retries are idempotent and redeployments get new tags. Dating and repeat-deployment suffixes: `recordDeployment`. Code rollback never reverses migrations. -Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` / `productionSmoke` in `hosted/scripts/production.mjs`; `WORKERS` / `deployWorkers` in `hosted/scripts/workers.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` / `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. +Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / `verifyPackages` / `preflight` / `deployProduction` / `productionSmoke` in `hosted/scripts/production.mjs`; `WORKERS` / `deployWorkers` in `hosted/scripts/workers.mjs`; `hosted/scripts/production-backup.mjs`; `smokeRequest` / `healthSmoke` / `relaySmoke` / `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs`; `recordDeployment` in `hosted/scripts/production-tag.mjs`. Pinned by `hosted/scripts/production.test.mjs`, `hosted/scripts/smoke-request.test.mjs`, and `hosted/scripts/production-tag.test.mjs`. ## Future diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 2dedb2364..40872bc0e 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -23,4 +23,5 @@ Three origins (decided 2026-09-30): ## 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. +- 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. - 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/hosted/README.md b/hosted/README.md index bbce1407e..41072c38e 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -350,12 +350,14 @@ credential pair do and do not enable. Facebook is outside this milestone. protected production environment and runs the preflight, backup and restore-test, migration, deployment, and live-verification sequence in `docs/specs/hosted.md` -> "Production releases", deploying the relay, - voice, and account Workers in that order. No real mail is sent by its smoke + voice, and account Workers in that order. The relay's revision and one-time + checks pass before the voice or account deploys, and all three are checked + again after the account deploys. No real mail is sent by its smoke checks, and passing them is not acceptance. The first release after the split creates the relay's `OneTimeRoom` (`v1`), then deletes the account Worker's (its append-only migration `v2`): links - open at that moment drop, and both become rollback floors. Its smoke repeats + open at that moment drop, and both become rollback floors. Its smokes repeat the relay and voice checks up to six times, 10 s apart, while their new custom domains' certificates issue. 3. Tagging runs only after live verification, on the terms in diff --git a/hosted/scripts/preview-smoke.mjs b/hosted/scripts/preview-smoke.mjs index c8c00942d..edd8e80b8 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -260,14 +260,49 @@ async function emailLoginSmoke(request, origin) { ); } +/** Runs `check` up to `limit` times, `retryMs` apart, logging each retry. */ +async function retrying(limit, what, check, { retryMs, wait }) { + for (let attempt = 1; ; attempt++) { + try { + return await check(); + } catch (error) { + if (attempt >= limit) throw error; + console.log( + `${what} not ready (attempt ${attempt}/${limit}); retrying in ${retryMs / 1000} seconds`, + ); + await wait(retryMs); + } + } +} + /** - * Every Worker's smoke: the account's auth boundary, each other Worker's - * revision, and the relay's rendezvous once the relay's revision passed — never - * waiting on the account, so an account failure hides no relay one. Independent - * parts run concurrently, and each retries on its own up to its `attempts` - * (one count for all, or `{ account, relay, voice }`; the rendezvous takes the - * relay's), `retryMs` apart, so a passed part never runs again. Every part - * settles before the smoke fails, and the failure names each part that failed. + * The relay's smoke: its revision, then its one-time rendezvous, each retried + * on its own up to `attempts`, `retryMs` apart, so a passed revision never + * runs again and the rendezvous never runs on a relay that did not pass. + */ +export async function relaySmoke( + origin, + sha, + { + fetcher = fetch, + oneTime = (origin) => oneTimeSmoke(origin), + attempts = 1, + retryMs = 10_000, + wait = delay, + } = {}, +) { + const retry = { retryMs, wait }; + await retrying(attempts, origin, () => healthSmoke(origin, sha, fetcher), retry); + await retrying(attempts, `${origin} one-time`, () => oneTime(origin), retry); +} + +/** + * Every Worker's smoke: the account's auth boundary, the voice's revision, 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. */ export async function smokeAll( { account, relay, voice }, @@ -282,31 +317,18 @@ export async function smokeAll( wait = delay, } = {}, ) { - const retried = async (part, what, check) => { - const limit = typeof attempts === "number" ? attempts : (attempts[part] ?? 1); - for (let attempt = 1; ; attempt++) { - try { - return await check(); - } catch (error) { - if (attempt >= limit) throw error; - console.log( - `${what} not ready (attempt ${attempt}/${limit}); retrying in ${retryMs / 1000} seconds`, - ); - await wait(retryMs); - } - } - }; - const relayHealth = retried("relay", relay, () => healthSmoke(relay, sha, fetcher)); + const limit = (part) => + typeof attempts === "number" ? attempts : (attempts[part] ?? 1); + const retry = { retryMs, wait }; const results = await Promise.allSettled([ - retried("account", account, () => smoke(account, sha, fetcher, preview, providers)), - relayHealth, - retried("voice", voice, () => healthSmoke(voice, sha, fetcher)), - relayHealth.then(() => retried("relay", `${relay} one-time`, () => oneTime(relay))), + retrying(limit("account"), account, () => + smoke(account, sha, fetcher, preview, providers), retry), + relaySmoke(relay, sha, { fetcher, oneTime, attempts: limit("relay"), retryMs, wait }), + retrying(limit("voice"), voice, () => healthSmoke(voice, sha, fetcher), retry), ]); - // A relay that failed its revision fails the rendezvous with the same error. - const failures = [ - ...new Set(results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))), - ]; + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); if (failures.length === 1) throw failures[0]; if (failures.length) throw new AggregateError(failures, failures.map((error) => error.message).join("\n")); diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs index fb048c58e..158fa8866 100644 --- a/hosted/scripts/production.mjs +++ b/hosted/scripts/production.mjs @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { required, cloudflare, hyperdriveOrigin, originsOf } from "./preview.mjs"; -import { smokeAll } from "./preview-smoke.mjs"; +import { relaySmoke, smokeAll } from "./preview-smoke.mjs"; import { WORKERS, deployWorkers, @@ -80,15 +80,40 @@ export function requiredSecrets(configs) { ); } /** - * The release's live verification. 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. `options` stands in for the network in tests. + * 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. */ +const ATTEMPTS = { account: 1, relay: 6, voice: 6 }; + +/** + * Deploys 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. + * `options` stands in for the process and network in tests. + */ +export function deployProduction( + configs, + sha, + { stage = "production", spawn, ...options } = {}, +) { + return deployWorkers(stage, configs, { + spawn, + afterDeploy: async (worker) => { + if (worker === "relay") + await relaySmoke(configs.relay.vars.APP_ORIGIN, sha, { + attempts: ATTEMPTS.relay, + ...options, + }); + }, + }); +} + +/** The release's live verification, of all three once the account deployed. */ export function productionSmoke(configs, sha, options = {}) { return smokeAll(originsOf(configs), sha, { providers: oauthProviders(configs.account), - attempts: { account: 1, relay: 6, voice: 6 }, + attempts: ATTEMPTS, ...options, }); } @@ -163,8 +188,8 @@ if ( } else if (action === "preflight" || action === "deploy") { await verifyPackages(); await preflight(process.env, configs); - // Relay, voice, then account; a failure stops the rest. - if (action === "deploy") await deployWorkers("production", configs); + if (action === "deploy") + await deployProduction(configs, process.env.BUILD_SHA); } else throw new Error("Use preflight, deploy or smoke"); } catch (error) { console.error(error.message); diff --git a/hosted/scripts/production.test.mjs b/hosted/scripts/production.test.mjs index 1085d8c30..f4fd3ddb5 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { readFile, rm } from "node:fs/promises"; import { + deployProduction, productionConfig, productionConfigs, productionSmoke, @@ -100,6 +101,61 @@ test("Workers deploy relay, voice, then account from one config path each, and a for (const [, path] of deployed) await assert.rejects(readFile(path), { code: "ENOENT" }); }); +test("production smokes the relay before anything after it deploys, and a relay failure never deploys the account", async (t) => { + t.after(() => + rm(new URL("../.wrangler/deploy-production-test/", import.meta.url), { + recursive: true, + force: true, + }), + ); + const events = []; + const spawn = (_command, args) => { + const path = args[args.indexOf("--config") + 1]; + events.push(`deploy ${JSON.parse(readFileSync(path, "utf8")).name}`); + return { status: 0 }; + }; + // The relay's health fails this many times before it passes. + let unhealthy = 5; + const fetcher = async (url) => { + assert.equal(url, "https://relay.dormouse.sh/api/health"); + events.push("relay health"); + return unhealthy-- > 0 + ? Response.json({ ok: false }, { status: 503 }) + : Response.json({ ok: true, revision: env.BUILD_SHA }); + }; + const deploy = (oneTime) => + deployProduction(configs, env.BUILD_SHA, { + stage: "deploy-production-test", + spawn, + fetcher, + oneTime, + wait: async () => {}, + }); + await deploy(async (origin) => { + assert.equal(origin, "https://relay.dormouse.sh"); + events.push("relay one-time"); + }); + // The relay retried up to its sixth attempt, then the rendezvous ran once. + assert.deepEqual(events, [ + "deploy dormouse-relay", + ...Array(6).fill("relay health"), + "relay one-time", + "deploy dormouse-voice", + "deploy dormouse-hosted", + ]); + events.length = 0; + let rendezvous = 0; + await assert.rejects( + deploy(async () => { + rendezvous++; + throw new Error("rendezvous failed"); + }), + { message: "rendezvous failed" }, + ); + // Six attempts, then nothing else deploys: the account's `v2` never runs. + assert.equal(rendezvous, 6); + assert.deepEqual(events, ["deploy dormouse-relay", "relay health"]); +}); test("live verification retries the relay and voice while their domains come up, and runs the account's POSTs once", async () => { const health = {}; // Each origin's health fails this many times before it passes. diff --git a/hosted/scripts/workers.mjs b/hosted/scripts/workers.mjs index f4203bbd2..fb552961b 100644 --- a/hosted/scripts/workers.mjs +++ b/hosted/scripts/workers.mjs @@ -12,7 +12,7 @@ const hosted = new URL("../", import.meta.url); * 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 live. + * the relay serving the replacement is deployed and, in production, smoked. */ export const WORKERS = { relay: { @@ -73,12 +73,14 @@ export const fromStage = (path) => posix.join("../..", path); /** * Writes each config to `.wrangler//wrangler..json` and deploys * them in `WORKERS` order, stopping at the first failure. `args` adds a - * Worker's own flags; `spawn` stands in for the process in tests. + * Worker's own flags; `afterDeploy(worker)` checks a deployed Worker before the + * next deploys, and its failure stops the rest; `spawn` stands in for the + * process in tests. */ export async function deployWorkers( stage, configs, - { args = () => [], spawn = spawnSync } = {}, + { args = () => [], afterDeploy = async () => {}, spawn = spawnSync } = {}, ) { const directory = new URL(`.wrangler/${stage}/`, hosted); await mkdir(directory, { recursive: true, mode: 0o700 }); @@ -96,5 +98,6 @@ export async function deployWorkers( } finally { await rm(path, { force: true }); } + await afterDeploy(worker); } }