diff --git a/.claude/resolve-budgets.py b/.claude/resolve-budgets.py new file mode 100644 index 000000000..db18f2ae0 --- /dev/null +++ b/.claude/resolve-budgets.py @@ -0,0 +1,12 @@ +import re +p='scripts/spec-word-budgets.json' +s=open(p).read() +def merge(m): + vals, order = {}, [] + for block in (m.group(1), m.group(2)): + for k, v in re.findall(r'"([^"]+)":\s*(\d+)', block): + if k not in vals: order.append(k) + vals[k] = max(vals.get(k, 0), int(v)) + return ''.join(f' "{k}": {vals[k]},\n' for k in order) +s = re.sub(r"<<<<<<< [^\n]*\n(.*?)(?:\|\|\|\|\|\|\| [^\n]*\n.*?)?=======\n(.*?)>>>>>>> [^\n]*\n", merge, s, flags=re.S) +open(p,'w').write(s) diff --git a/.claude/resolve-hosted-md.py b/.claude/resolve-hosted-md.py new file mode 100644 index 000000000..25854a7f8 --- /dev/null +++ b/.claude/resolve-hosted-md.py @@ -0,0 +1,10 @@ +# Take the replayed commit's side of each hosted.md conflict, then restore +# #867's relay-smoke clause on the production deploy-order sentence. +import re, sys +p='docs/specs/hosted.md' +s=open(p).read() +s=re.sub(r"<<<<<<< [^\n]*\n(.*?)(?:\|\|\|\|\|\|\| [^\n]*\n.*?)?=======\n(.*?)>>>>>>> [^\n]*\n", lambda m: m.group(2), s, flags=re.S) +clause="Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check, push config, and `oneTimeSmoke` before the next deploy (rationale)." +s=s.replace("Deploy relay, voice, then account, stopping at a failure (rationale).", clause) +if clause not in s: sys.exit("clause anchor not found") +open(p,'w').write(s) 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..078bcc5a4 100644 --- a/.github/workflows/hosted-preview.yml +++ b/.github/workflows/hosted-preview.yml @@ -62,6 +62,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm test:hosted - run: pnpm build:hosted + # Every Worker's static files, each under its own `dist//`. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: preview-assets @@ -132,17 +133,17 @@ 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_ORIGINS" "$BUILD_SHA" env: - PREVIEW_ORIGIN: ${{ steps.deploy.outputs.url }} + PREVIEW_ORIGINS: ${{ steps.deploy.outputs.origins }} cleanup: if: >- @@ -162,7 +163,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..4008b3545 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,6 +30,7 @@ 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();' + # Every Worker's static files, each under its own `dist//`. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: hosted-production-assets @@ -65,7 +66,7 @@ jobs: with: name: hosted-production-assets path: hosted/dist - - name: Validate production identity, uncached Hyperdrive and Worker secrets + - 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 +88,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 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 }} 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/AGENTS.md b/AGENTS.md index 4e0c8ed13..218c82b7d 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: account and Better Auth (`hosted.dormouse.sh`), one-time rendezvous (`relay.`), 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/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..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 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 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 acc0d6fc3..ef6b512d8 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -1,13 +1,21 @@ # 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. 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. **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 +23,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`; `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 @@ -47,7 +55,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 +79,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. 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` 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 -**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 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 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`. Pinned by `the local development entry serves no speak` in `hosted/server/tests/boundary.test.ts`. ## 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 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`; `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`; `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; 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 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 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`. 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`; `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` / `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 69debf871..40872bc0e 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -9,5 +9,19 @@ 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. + +## 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/docs/specs/one-time.md b/docs/specs/one-time.md index d6a64901d..b066548c1 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, +`hosted/dist/relay/`, copies it to `hosted/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/relay.md b/docs/specs/relay.md index 3491a33d9..ec1a24a6d 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,14 @@ 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. +- 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 @@ -181,7 +190,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 +1119,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 +1138,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-hosted.md b/docs/specs/security-hosted.md index ef8d636f1..abf31ffc4 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 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/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/docs/specs/security-local.md b/docs/specs/security-local.md index cc4adb00a..fc9d3f2ec 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -192,7 +192,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 `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 — @@ -219,7 +219,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/hosted/README.md b/hosted/README.md index 649e7a019..41072c38e 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 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 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 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 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,17 @@ 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 relay, + 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 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 `docs/specs/hosted.md` -> "Production releases". If only tagging fails, rerun failed jobs: it records the original deployment without deploying again. @@ -341,9 +368,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 +389,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 +400,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 +425,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..9cb146b18 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -5,15 +5,14 @@ "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", "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..90519cfe7 100644 --- a/hosted/scripts/dev-one-time.mjs +++ b/hosted/scripts/dev-one-time.mjs @@ -4,12 +4,12 @@ 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` - * -> "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 +54,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 +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.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/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..edd8e80b8 100644 --- a/hosted/scripts/preview-smoke.mjs +++ b/hosted/scripts/preview-smoke.mjs @@ -61,6 +61,18 @@ export async function smokeRequest(fetcher, url, options, wait = delay, expected } } +/** 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, + 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, @@ -69,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, @@ -258,25 +260,90 @@ async function emailLoginSmoke(request, origin) { ); } -if ( - process.argv[1] && - resolve(process.argv[1]) === fileURLToPath(import.meta.url) -) { - const [origin, 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. +/** 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 { - await smoke(origin, sha, fetch, true); - await oneTimeSmoke(origin); - console.log(`Preview smoke checks passed: ${origin}/login (${sha})`); - break; + return await check(); } catch (error) { - if (attempt === 6) throw error; + if (attempt >= limit) throw error; console.log( - `Preview not ready (attempt ${attempt}/6); retrying in 10 seconds`, + `${what} not ready (attempt ${attempt}/${limit}); retrying in ${retryMs / 1000} seconds`, ); - await new Promise((resolve) => setTimeout(resolve, 10_000)); + await wait(retryMs); } } } + +/** + * 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 }, + sha, + { + fetcher = fetch, + preview = false, + providers = [], + oneTime = (origin) => oneTimeSmoke(origin), + attempts = 1, + retryMs = 10_000, + wait = delay, + } = {}, +) { + const limit = (part) => + typeof attempts === "number" ? attempts : (attempts[part] ?? 1); + const retry = { retryMs, wait }; + const results = await Promise.allSettled([ + retrying(limit("account"), account, () => + smoke(account, sha, fetcher, preview, providers), retry), + relaySmoke(relay, sha, { fetcher, oneTime, attempts: limit("relay"), retryMs, wait }), + retrying(limit("voice"), voice, () => healthSmoke(voice, sha, fetcher), retry), + ]); + 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")); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + 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. + 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 b8d3d93fb..a6ead4f49 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,47 +10,74 @@ export function required(env, name) { return env[name]; } -export function previewName(pr) { +/** A PR's preview of the production Worker `name`: `-pr-N`. The account's also names its Hyperdrive and Neon branch. */ +export function previewName(pr, name) { if (!/^[1-9]\d{0,8}$/.test(pr ?? "")) throw new Error("PR_NUMBER must be a positive integer"); - return `dormouse-hosted-pr-${pr}`; + return `${name}-pr-${pr}`; } -export function previewConfig(base, env, hyperdriveId) { - const name = previewName(env.PR_NUMBER); +/** + * A Worker's preview config, allowlisted from its production `base`: its own + * workers.dev origin, its registry preview entry, assets rebased, its Durable + * 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. + */ +export function previewConfig(base, env, worker, hyperdriveId) { + const name = previewName(env.PR_NUMBER, base.name); const subdomain = required(env, "CLOUDFLARE_WORKERS_SUBDOMAIN"); if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(subdomain)) throw new Error( "Use the workers.dev subdomain only, without dots or a URL", ); - if (!/^[a-f0-9]{32}$/.test(hyperdriveId)) - throw new Error("Invalid Hyperdrive ID"); if (!/^[a-f0-9]{40}$/.test(env.BUILD_SHA ?? "")) throw new Error("BUILD_SHA must be a commit SHA"); - // Deliberately allowlist fields: no production routes, bindings, or OAuth secrets. - return { + const config = { name, - main: "../../server/preview-worker.ts", + main: fromStage(WORKERS[worker].previewMain), compatibility_date: base.compatibility_date, compatibility_flags: base.compatibility_flags, workers_dev: true, preview_urls: false, - assets: { ...base.assets, directory: "../../dist" }, vars: { APP_ORIGIN: `https://${name}.${subdomain}.workers.dev`, - BUILD_SHA: required(env, "BUILD_SHA"), + BUILD_SHA: env.BUILD_SHA, }, - hyperdrive: [{ binding: "HYPERDRIVE", id: hyperdriveId }], - // A Durable Object namespace belongs to the Worker that implements it, so - // the preview's is its own; rate-limit counters are account-wide, so the - // preview's move to their own namespace ids. - durable_objects: base.durable_objects, - migrations: base.migrations, - ratelimits: base.ratelimits?.map((limit) => ({ + }; + if (base.assets) + config.assets = { ...base.assets, directory: fromStage(base.assets.directory) }; + if (base.hyperdrive) { + if (!/^[a-f0-9]{32}$/.test(hyperdriveId)) + throw new Error("Invalid Hyperdrive ID"); + config.hyperdrive = base.hyperdrive.map(({ binding }) => ({ + binding, + id: hyperdriveId, + })); + } + // 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, namespace_id: previewRatelimitNamespace(limit.namespace_id), - })), - }; + })); + return config; +} + +/** Every Worker's preview config, keyed as `WORKERS` is; the account and voice share one Hyperdrive, as production does. */ +export function previewConfigs(bases, env, hyperdriveId) { + return Object.fromEntries( + Object.keys(WORKERS).map((worker) => [ + worker, + previewConfig(bases[worker], env, worker, hyperdriveId), + ]), + ); } /** Production rate-limit namespace ids stay below this; previews use id + offset. */ @@ -137,12 +164,25 @@ export async function findHyperdrives(api, name) { } } -export async function prepare(env = process.env) { - const name = previewName(env.PR_NUMBER); - const base = JSON.parse( - await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"), +/** Where `prepare` leaves the account preview's secrets for `deploy`. */ +const SECRETS_FILE = new URL("../.wrangler/preview/secrets.json", import.meta.url); + +/** Each Worker's origin in `configs`, keyed as `WORKERS` is. */ +export const originsOf = (configs) => + Object.fromEntries( + Object.entries(configs).map(([worker, config]) => [worker, config.vars.APP_ORIGIN]), ); - const config = previewConfig(base, env, "0".repeat(32)); + +/** + * Validates every preview config, upserts the PR's Hyperdrive, writes the + * account's secrets file, and emits the origins: `url` (the account's, for the + * environment link) and `origins` (all three as JSON, for the smoke). + */ +export async function prepare(env = process.env) { + const bases = await readConfigs(); + const name = previewName(env.PR_NUMBER, bases.account.name); + // Every config validates before any resource is created. + previewConfigs(bases, env, "0".repeat(32)); const secret = required(env, "PREVIEW_AUTH_SECRET"); if (secret.length < 32) throw new Error("PREVIEW_AUTH_SECRET needs at least 32 random characters"); @@ -162,35 +202,38 @@ export async function prepare(env = process.env) { }; const path = `hyperdrive/configs${matches[0] ? `/${matches[0].id}` : ""}`; const { result } = await api(path, matches[0] ? "PUT" : "POST", body); - config.hyperdrive[0].id = result.id; - const directory = new URL("../.wrangler/preview/", import.meta.url); - await mkdir(directory, { recursive: true, mode: 0o700 }); - await writeFile( - new URL("wrangler.json", directory), - JSON.stringify(config, null, 2) + "\n", - ); - await writeFile(new URL("secrets.json", directory), JSON.stringify(secrets), { - mode: 0o600, - }); + const configs = previewConfigs(bases, env, result.id); + await mkdir(new URL("./", SECRETS_FILE), { recursive: true, mode: 0o700 }); + await writeFile(SECRETS_FILE, JSON.stringify(secrets), { mode: 0o600 }); + const origins = originsOf(configs); if (env.GITHUB_OUTPUT) - await appendFile(env.GITHUB_OUTPUT, `url=${config.vars.APP_ORIGIN}\n`); + await appendFile( + env.GITHUB_OUTPUT, + `url=${origins.account}\norigins=${JSON.stringify(origins)}\n`, + ); if (env.GITHUB_STEP_SUMMARY) await appendFile( env.GITHUB_STEP_SUMMARY, - `Preview target: ${config.vars.APP_ORIGIN}/login\n\nRevision: ${env.BUILD_SHA}\n\nCaptured email inbox: ${config.vars.APP_ORIGIN}/dev/emails. No real email is sent. Deployment and smoke checks must succeed below.\n`, + `Preview target: ${origins.account}/login\n\nOne-time page: ${origins.relay}/connect/\n\nVoice: ${origins.voice}\n\nRevision: ${env.BUILD_SHA}\n\nCaptured email inbox: ${origins.account}/dev/emails. No real email is sent. Deployment and smoke checks must succeed below.\n`, ); - console.log(`Prepared ${config.vars.APP_ORIGIN}`); + console.log(`Prepared ${Object.values(origins).join(", ")}`); + return configs; } export async function cleanup(env = process.env) { - const name = previewName(env.PR_NUMBER); + const bases = await readConfigs(); + const name = previewName(env.PR_NUMBER, bases.account.name); // Validate both providers before deleting anything, so a missing Neon token is caught first. required(env, "NEON_PROJECT_ID"); required(env, "NEON_API_KEY"); const api = cloudflare(env); // `force`: a Worker that implements a Durable Object namespace is deleted // with it rather than refused. - await api(`workers/scripts/${name}?force=true`, "DELETE"); + for (const base of Object.values(bases)) + await api( + `workers/scripts/${previewName(env.PR_NUMBER, base.name)}?force=true`, + "DELETE", + ); for (const item of await findHyperdrives(api, name)) await api(`hyperdrive/configs/${item.id}`, "DELETE"); // The official create action reuses branches; cleanup must also tolerate retries. @@ -230,30 +273,16 @@ if ( if (action === "prepare") await prepare(); else if (action === "cleanup") await cleanup(); else if (action === "deploy") { - await prepare(); + const configs = await prepare(); try { - const result = spawnSync( - "pnpm", - [ - "exec", - "wrangler", - "deploy", - "--config", - ".wrangler/preview/wrangler.json", - "--secrets-file", - ".wrangler/preview/secrets.json", - ], - { - cwd: fileURLToPath(new URL("../", import.meta.url)), - stdio: "inherit", - }, - ); - process.exitCode = result.status ?? 1; + await deployWorkers("preview", configs, { + args: (worker) => + WORKERS[worker].previewSecrets + ? ["--secrets-file", fileURLToPath(SECRETS_FILE)] + : [], + }); } finally { - await rm( - new URL("../.wrangler/preview/secrets.json", import.meta.url), - { force: true }, - ); + await rm(SECRETS_FILE, { force: true }); } } else throw new Error("Use prepare, deploy or cleanup"); } catch (error) { diff --git a/hosted/scripts/preview.test.mjs b/hosted/scripts/preview.test.mjs index 096d27ddb..4a0c9eb4c 100644 --- a/hosted/scripts/preview.test.mjs +++ b/hosted/scripts/preview.test.mjs @@ -4,13 +4,15 @@ import { readFile } from "node:fs/promises"; import { previewName, previewConfig, + previewConfigs, hyperdriveOrigin, cloudflare, findHyperdrives, cleanup, previewRatelimitNamespace, } from "./preview.mjs"; -import { smoke } from "./preview-smoke.mjs"; +import { healthSmoke, smoke, smokeAll } from "./preview-smoke.mjs"; +import { readConfigs } from "./workers.mjs"; const env = { PR_NUMBER: "42", @@ -24,53 +26,84 @@ const env = { const result = (value, extra = {}) => Response.json({ success: true, result: value, ...extra }); -test("preview configuration isolates the origin and excludes production bindings", async () => { - const base = JSON.parse( - await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"), - ); - const config = previewConfig( +test("each preview configuration isolates its origin and excludes production bindings", async () => { + const bases = await readConfigs(); + // Every production field a preview must never copy, added to each base. + const stale = (base) => ({ + ...base, + routes: ["production.example/*"], + vars: { ...base.vars, GOOGLE_CLIENT_SECRET: "do-not-copy", ELEVENLABS_API_KEY: "do-not-copy" }, + d1_databases: [{ production: true }], + triggers: { crons: ["* * * * *"] }, + }); + const configs = previewConfigs( { - ...base, - routes: ["production.example/*"], - vars: { GOOGLE_CLIENT_SECRET: "do-not-copy" }, - d1_databases: [{ production: true }], + account: stale(bases.account), + relay: stale(bases.relay), + voice: stale(bases.voice), }, env, "c".repeat(32), ); - assert.equal(config.name, "dormouse-hosted-pr-42"); - assert.equal( - config.vars.APP_ORIGIN, - "https://dormouse-hosted-pr-42.hosted-tests.workers.dev", - ); - assert.equal(config.workers_dev, true); - assert.equal(config.main, "../../server/preview-worker.ts"); - assert.equal(config.vars.EMAIL_FROM, undefined); - assert.equal(config.routes, undefined); - // Production alone sweeps ElevenLabs history; a preview has no cron. - assert.ok(base.triggers?.crons?.length); - assert.equal(config.triggers, undefined); - assert.equal(config.d1_databases, undefined); - assert.equal(config.vars.GOOGLE_CLIENT_SECRET, undefined); - assert.equal(config.assets.run_worker_first, true); - assert.deepEqual(config.durable_objects, base.durable_objects); - assert.deepEqual(config.migrations, base.migrations); + const expected = { + account: ["dormouse-hosted-pr-42", "../../server/preview-worker.ts"], + relay: ["dormouse-relay-pr-42", "../../server/relay-worker.ts"], + voice: ["dormouse-voice-pr-42", "../../server/voice-preview-worker.ts"], + }; + for (const [worker, [name, main]] of Object.entries(expected)) { + const config = configs[worker]; + assert.equal(config.name, name, worker); + assert.equal(config.main, main, worker); + assert.deepEqual( + config.vars, + { + APP_ORIGIN: `https://${name}.hosted-tests.workers.dev`, + BUILD_SHA: env.BUILD_SHA, + }, + worker, + ); + assert.equal(config.workers_dev, true, worker); + assert.equal(config.preview_urls, false, worker); + for (const key of ["routes", "triggers", "d1_databases", "observability"]) + assert.equal(config[key], undefined, `${worker} ${key}`); + } + // Production alone sweeps ElevenLabs history, on the voice Worker; a preview has no cron. + assert.ok(bases.voice.triggers?.crons?.length); + // The account and voice previews share one database; the relay reaches none. + assert.deepEqual(configs.account.hyperdrive, [{ binding: "HYPERDRIVE", id: "c".repeat(32) }]); + assert.deepEqual(configs.voice.hyperdrive, configs.account.hyperdrive); + assert.equal(configs.relay.hyperdrive, undefined); + assert.equal(configs.account.assets.directory, "../../dist/account"); + assert.equal(configs.relay.assets.directory, "../../dist/relay"); + assert.equal(configs.voice.assets, undefined); + for (const worker of ["account", "relay"]) + assert.equal(configs[worker].assets.run_worker_first, true, worker); + // 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); + assert.deepEqual(configs.relay.migrations, bases.relay.migrations); assert.deepEqual( - config.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), - base.ratelimits.map(({ name, namespace_id }) => [ + configs.relay.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), + bases.relay.ratelimits.map(({ name, namespace_id }) => [ name, String(Number(namespace_id) + 1000), ]), ); for (const bad of ["0", "-1", "42/../../production", "main", "42\n"]) - assert.throws(() => previewName(bad)); + assert.throws(() => previewName(bad, "dormouse-hosted")); assert.throws(() => previewConfig( - base, + bases.account, { ...env, CLOUDFLARE_WORKERS_SUBDOMAIN: "example.com" }, + "account", "c".repeat(32), ), ); + assert.throws(() => previewConfigs(bases, env, "not-an-id")); }); test("preview configuration keeps its own Durable Objects and rate-limit namespaces", () => { @@ -80,8 +113,9 @@ test("preview configuration keeps its own Durable Objects and rate-limit namespa const migrations = [{ tag: "v1", new_sqlite_classes: ["Room"] }]; const config = previewConfig( { + name: "dormouse-relay", compatibility_date: "2026-01-01", - assets: {}, + assets: { directory: "./dist/relay" }, durable_objects, migrations, ratelimits: [ @@ -89,7 +123,7 @@ test("preview configuration keeps its own Durable Objects and rate-limit namespa ], }, env, - "c".repeat(32), + "relay", ); assert.deepEqual(config.durable_objects, durable_objects); assert.deepEqual(config.migrations, migrations); @@ -235,13 +269,14 @@ test("cleanup only deletes this PR's resources and can run twice", async (t) => await cleanup(env); existing = false; await cleanup(env); + const workers = ["dormouse-relay-pr-42", "dormouse-voice-pr-42", "dormouse-hosted-pr-42"]; assert.deepEqual( removed.map((path) => path.split("/").pop()), - ["dormouse-hosted-pr-42", "ours", "br-ours", "dormouse-hosted-pr-42"], + [...workers, "ours", "br-ours", ...workers], ); assert.deepEqual( forced, - [true, true], + Array(6).fill(true), "a Worker implementing a Durable Object is deleted with force", ); }); @@ -319,3 +354,117 @@ 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 relay's revision alone", async () => { + const origins = { + account: "https://account.example.test", + relay: "https://relay.example.test", + voice: "https://voice.example.test", + }; + const events = []; + // 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 (unhealthy[origin] > 0) { + unhealthy[origin]--; + return Response.json({ ok: false }, { status: 503 }); + } + return Response.json({ ok: true, revision: env.BUILD_SHA }); + } + assert.equal(origin, origins.account); + if (pathname === "/api/ready") return new Response(null, { status: 200 }); + if (pathname === "/api/auth/csrf") + return Response.json( + { csrf: "csrf-token" }, + { + headers: { + "cache-control": "no-store", + "set-cookie": "__Host-session=1; Path=/; Secure; HttpOnly; SameSite=Lax", + }, + }, + ); + if (pathname === "/api/auth/get-session") return Response.json(null); + if (pathname === "/api/providers") return Response.json([]); + if (init.method === "POST") return new Response(null, { status: 403 }); + if (pathname === "/login") + 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, + }); + // The relay alone retried; the rendezvous ran once, after it passed. + assert.deepEqual(waits, [10_000]); + 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"); + + // 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, + attempts: { account: 1, relay: 3, voice: 2 }, + wait: async (ms) => waits.push(ms), + oneTime, + }), + { 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.equal(count("one-time"), 0); +}); + +test("a relay or voice health check requires the deployed revision, and nothing else", async () => { + const origin = "https://dormouse-voice-pr-42.test.workers.dev"; + const answering = (status, body) => async (url) => { + assert.equal(url, origin + "/api/health"); + return Response.json(body, { status }); + }; + await healthSmoke(origin, env.BUILD_SHA, answering(200, { ok: true, revision: env.BUILD_SHA })); + await assert.rejects( + healthSmoke(origin, env.BUILD_SHA, answering(200, { ok: true, revision: env.BUILD_SHA, extra: 1 })), + ); + await assert.rejects( + healthSmoke(origin, env.BUILD_SHA, answering(503, { ok: false })), + /must be healthy/, + ); + await assert.rejects(healthSmoke(origin + "/", env.BUILD_SHA, answering(200, {})), /exact origin/); +}); diff --git a/hosted/scripts/production.mjs b/hosted/scripts/production.mjs index df027dfe9..158fa8866 100644 --- a/hosted/scripts/production.mjs +++ b/hosted/scripts/production.mjs @@ -1,34 +1,121 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { required, cloudflare, hyperdriveOrigin } from "./preview.mjs"; -import { smoke } from "./preview-smoke.mjs"; -import { oneTimeSmoke } from "./one-time-smoke.mjs"; -import { providerIds } from "../server/providers.js"; +import { required, cloudflare, hyperdriveOrigin, originsOf } from "./preview.mjs"; +import { relaySmoke, smokeAll } from "./preview-smoke.mjs"; +import { + WORKERS, + deployWorkers, + fromStage, + oauthProviders, + readConfigs, +} from "./workers.mjs"; -const root = new URL("../", import.meta.url); -export function productionConfig(base, env) { +/** + * Each Worker's production name and origin, pinned here so an edited config + * cannot redirect production or stand in for a sibling. + */ +export const PRODUCTION = { + account: { name: "dormouse-hosted", origin: "https://hosted.dormouse.sh" }, + relay: { name: "dormouse-relay", origin: "https://relay.dormouse.sh" }, + voice: { name: "dormouse-voice", origin: "https://voice.dormouse.sh" }, +}; + +export function productionConfig(base, env, worker) { + const identity = PRODUCTION[worker]; + assert.ok(identity, `Unknown Worker ${worker}`); assert.match(required(env, "BUILD_SHA"), /^[a-f0-9]{40}$/); - assert.match(required(env, "HYPERDRIVE_ID"), /^[a-f0-9]{32}$/); - assert.notEqual( - env.HYPERDRIVE_ID, - "0".repeat(32), - "Provision production Hyperdrive first", - ); assert.match(required(env, "CLOUDFLARE_ACCOUNT_ID"), /^[a-f0-9]{32}$/); - assert.equal(base.name, "dormouse-hosted"); - assert.equal(base.vars.APP_ORIGIN, "https://hosted.dormouse.sh"); + assert.equal(base.name, identity.name); + assert.equal(base.vars.APP_ORIGIN, identity.origin); + // The canonical domain alone: no public alias, candidate, or preview URL. + assert.deepEqual(base.routes, [ + { pattern: new URL(identity.origin).host, custom_domain: true }, + ]); assert.equal(base.workers_dev, false); assert.equal(base.preview_urls, false); - return { + assert.deepEqual(base.observability, { enabled: false }); + const config = { ...base, - main: "../../server/worker.ts", - assets: { ...base.assets, directory: "../../dist" }, + main: fromStage(base.main), vars: { ...base.vars, BUILD_SHA: env.BUILD_SHA }, - hyperdrive: [{ binding: "HYPERDRIVE", id: env.HYPERDRIVE_ID }], }; + if (base.assets) + config.assets = { ...base.assets, directory: fromStage(base.assets.directory) }; + if (base.hyperdrive) { + assert.match(required(env, "HYPERDRIVE_ID"), /^[a-f0-9]{32}$/); + assert.notEqual( + env.HYPERDRIVE_ID, + "0".repeat(32), + "Provision production Hyperdrive first", + ); + config.hyperdrive = base.hyperdrive.map(({ binding }) => ({ + binding, + id: env.HYPERDRIVE_ID, + })); + } + return config; +} + +/** All three, keyed as `WORKERS` is. */ +export function productionConfigs(bases, env) { + return Object.fromEntries( + Object.keys(WORKERS).map((worker) => [ + worker, + productionConfig(bases[worker], env, worker), + ]), + ); +} + +/** The secret names each Worker that holds any must hold, by script. */ +export function requiredSecrets(configs) { + return Object.fromEntries( + Object.entries(WORKERS) + .filter(([, { secrets }]) => secrets) + .map(([worker, { secrets }]) => [ + configs[worker].name, + secrets(configs[worker]), + ]), + ); +} +/** + * 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: ATTEMPTS, + ...options, + }); } export async function verifyPackages() { const commits = []; @@ -56,7 +143,7 @@ export async function verifyPackages() { "Production requires accepted, clean pgstencil provenance", ); } -export async function preflight(env, config, api = cloudflare(env)) { +export async function preflight(env, configs, api = cloudflare(env)) { const origin = hyperdriveOrigin(required(env, "DATABASE_URL")); const { result } = await api(`hyperdrive/configs/${env.HYPERDRIVE_ID}`); assert.equal( @@ -79,70 +166,30 @@ export async function preflight(env, config, api = cloudflare(env)) { origin.user, "Use separate runtime and migration roles", ); - const { result: bindings } = await api( - "workers/scripts/dormouse-hosted/secrets", - ); - const names = new Set(bindings.map((item) => item.name)); - const requiredSecrets = [ - "AUTH_SECRET", - "POSTMARK_SERVER_TOKEN", - "ELEVENLABS_API_KEY", - ]; - for (const provider of config.vars.OAUTH_PROVIDERS.split(",") - .map((s) => s.trim()) - .filter(Boolean)) { - assert.ok(providerIds.includes(provider), "Unknown OAuth provider"); - requiredSecrets.push( - `${provider.toUpperCase()}_CLIENT_ID`, - `${provider.toUpperCase()}_CLIENT_SECRET`, - ); + for (const [script, secrets] of Object.entries(requiredSecrets(configs))) { + const { result: bindings } = await api(`workers/scripts/${script}/secrets`); + const names = new Set(bindings.map((item) => item.name)); + for (const name of secrets) + assert.ok(names.has(name), `Missing ${script} secret: ${name}`); } - for (const name of requiredSecrets) - assert.ok(names.has(name), `Missing Worker secret: ${name}`); } if ( process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { try { - const base = JSON.parse( - await readFile(new URL("wrangler.jsonc", root), "utf8"), - ); - const config = productionConfig(base, process.env); + const configs = productionConfigs(await readConfigs(), process.env); const action = process.argv[2]; if (action === "smoke") { - await smoke( - config.vars.APP_ORIGIN, - process.env.BUILD_SHA, - fetch, - false, - config.vars.OAUTH_PROVIDERS.split(",") - .map((s) => s.trim()) - .filter(Boolean), - ); - await oneTimeSmoke(config.vars.APP_ORIGIN); + await productionSmoke(configs, process.env.BUILD_SHA); console.log( - "Hosted production revision, auth boundary, and one-time rendezvous verified.", + "Hosted production revisions, auth boundary, and one-time rendezvous verified.", ); } else if (action === "preflight" || action === "deploy") { await verifyPackages(); - await preflight(process.env, config); - if (action === "deploy") { - const directory = new URL(".wrangler/production/", root); - await mkdir(directory, { recursive: true }); - const path = new URL("wrangler.json", directory); - await writeFile(path, JSON.stringify(config, null, 2) + "\n"); - const run = spawnSync( - "pnpm", - ["exec", "wrangler", "deploy", "--config", fileURLToPath(path)], - { - cwd: fileURLToPath(root), - stdio: "inherit", - }, - ); - await rm(path, { force: true }); - assert.equal(run.status, 0, "Hosted deploy failed"); - } + await preflight(process.env, 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 b6a6595b8..f4fd3ddb5 100644 --- a/hosted/scripts/production.test.mjs +++ b/hosted/scripts/production.test.mjs @@ -1,113 +1,318 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { productionConfig, preflight } from "./production.mjs"; -const base = JSON.parse( - await readFile(new URL("../wrangler.jsonc", import.meta.url), "utf8"), -); +import { readFileSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { + deployProduction, + productionConfig, + productionConfigs, + productionSmoke, + preflight, +} from "./production.mjs"; +import { deployWorkers, oauthProviders, readConfigs } from "./workers.mjs"; +const bases = await readConfigs(); const env = { BUILD_SHA: "a".repeat(40), HYPERDRIVE_ID: "b".repeat(32), CLOUDFLARE_ACCOUNT_ID: "c".repeat(32), DATABASE_URL: "postgres://migration:synthetic@ep-production.neon.tech/neondb", }; -const config = productionConfig(base, env); -test("production config keeps canonical domain and production entry, excludes public aliases", () => { - assert.equal(config.main, "../../server/worker.ts"); - assert.equal(config.vars.APP_ORIGIN, "https://hosted.dormouse.sh"); - assert.equal(config.vars.BUILD_SHA, env.BUILD_SHA); - assert.equal(config.workers_dev, false); - assert.equal(config.preview_urls, false); - assert.equal(config.hyperdrive[0].id, env.HYPERDRIVE_ID); - assert.deepEqual(config.triggers, { crons: ["*/5 * * * *"] }); +const configs = productionConfigs(bases, env); +test("each production config keeps its canonical domain and production entry, and excludes public aliases", () => { + const expected = { + account: ["dormouse-hosted", "https://hosted.dormouse.sh", "../../server/worker.ts"], + relay: ["dormouse-relay", "https://relay.dormouse.sh", "../../server/relay-worker.ts"], + voice: ["dormouse-voice", "https://voice.dormouse.sh", "../../server/voice-worker.ts"], + }; + for (const [worker, [name, origin, main]] of Object.entries(expected)) { + const config = configs[worker]; + assert.equal(config.name, name, worker); + assert.equal(config.main, main, worker); + assert.equal(config.vars.APP_ORIGIN, origin, worker); + assert.equal(config.vars.BUILD_SHA, env.BUILD_SHA, worker); + assert.deepEqual( + config.routes, + [{ pattern: new URL(origin).host, custom_domain: true }], + worker, + ); + assert.equal(config.workers_dev, false, worker); + assert.equal(config.preview_urls, false, worker); + assert.deepEqual(config.observability, { enabled: false }, worker); + const alias = { + ...bases[worker], + routes: [...bases[worker].routes, { pattern: "dormouse.sh/*" }], + }; + assert.throws(() => productionConfig(alias, env, worker), worker); + assert.throws( + () => productionConfig({ ...bases[worker], workers_dev: true }, env, worker), + worker, + ); + } + // Each config is its own Worker's: none stands in for a sibling. + assert.throws(() => productionConfig(bases.relay, env, "account")); + assert.throws(() => productionConfig(bases.account, env, "voice")); + // The account and voice share the production database; the relay reaches none. + assert.deepEqual(configs.account.hyperdrive, [{ binding: "HYPERDRIVE", id: env.HYPERDRIVE_ID }]); + assert.deepEqual(configs.voice.hyperdrive, configs.account.hyperdrive); + assert.equal(configs.relay.hyperdrive, undefined); + assert.equal(configs.account.assets.directory, "../../dist/account"); + assert.equal(configs.relay.assets.directory, "../../dist/relay"); + assert.equal(configs.voice.assets, undefined); + assert.throws(() => + productionConfig(bases.account, { ...env, HYPERDRIVE_ID: "0".repeat(32) }, "account"), + ); + assert.throws(() => + productionConfig(bases.voice, { ...env, HYPERDRIVE_ID: "0".repeat(32) }, "voice"), + ); assert.throws(() => - productionConfig(base, { ...env, HYPERDRIVE_ID: "0".repeat(32) }), + productionConfig(bases.account, { ...env, BUILD_SHA: "main" }, "account"), ); - assert.throws(() => productionConfig(base, { ...env, BUILD_SHA: "main" })); - assert.throws(() => productionConfig({ ...base, workers_dev: true }, env)); + assert.throws(() => productionConfig(bases.account, env)); }); -test("production config keeps the rendezvous Durable Object, its append-only migrations, and its rate limits", () => { - assert.deepEqual(config.durable_objects, { - bindings: [{ name: "ONE_TIME_ROOM", class_name: "OneTimeRoom" }], +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 }), + ); + const deployed = []; + // Reads the config while it is on disk, as Wrangler does. + const spawn = (failing) => (_command, args) => { + const path = args[args.indexOf("--config") + 1]; + const { name } = JSON.parse(readFileSync(path, "utf8")); + deployed.push([name, path]); + return { status: name === failing ? 1 : 0 }; + }; + 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-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-voice") }), + { message: "dormouse-voice deploy failed" }, + ); + 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("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. + 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, }); - // Durable Object migrations are append-only: a deployed tag is never edited. - assert.deepEqual(config.migrations[0], { - tag: "v1", - new_sqlite_classes: ["OneTimeRoom"], + 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. + assert.deepEqual(configs.account.triggers, { crons: [] }); + assert.equal(configs.relay.triggers, undefined); +}); +test("the rendezvous Durable Object and its rate limits are the relay's, and Durable Object migrations are append-only", () => { + assert.deepEqual(configs.relay.durable_objects, { + bindings: [{ name: "ONE_TIME_ROOM", class_name: "OneTimeRoom" }], }); + assert.deepEqual(configs.relay.migrations, [ + { tag: "v1", new_sqlite_classes: ["OneTimeRoom"] }, + ]); assert.deepEqual( - config.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), + configs.relay.ratelimits.map(({ name, namespace_id }) => [name, namespace_id]), [ ["ONE_TIME_MINT_LIMIT", "1"], ["ONE_TIME_JOIN_LIMIT", "2"], ], ); + // The account deployed `v1` with the room, so it keeps that tag unedited and + // appends the deletion. + assert.deepEqual(configs.account.migrations, [ + { tag: "v1", new_sqlite_classes: ["OneTimeRoom"] }, + { tag: "v2", deleted_classes: ["OneTimeRoom"] }, + ]); + for (const worker of ["account", "voice"]) { + assert.equal(configs[worker].durable_objects, undefined, worker); + assert.equal(configs[worker].ratelimits, undefined, worker); + } + assert.equal(configs.voice.migrations, undefined); }); +const accountSecrets = [ + "AUTH_SECRET", + "POSTMARK_SERVER_TOKEN", + ...oauthProviders(configs.account).flatMap((name) => [ + `${name.toUpperCase()}_CLIENT_ID`, + `${name.toUpperCase()}_CLIENT_SECRET`, + ]), +]; function provider({ host = "ep-production.neon.tech", database = "neondb", user = "runtime", disabled = true, - secrets = ["AUTH_SECRET", "POSTMARK_SERVER_TOKEN", "ELEVENLABS_API_KEY"], + secrets = { + "dormouse-hosted": accountSecrets, + "dormouse-voice": ["ELEVENLABS_API_KEY"], + }, + read = [], } = {}) { return async (path) => { if (path.startsWith("hyperdrive/configs/")) return { result: { origin: { host, database, user }, caching: { disabled } }, }; - assert.equal(path, "workers/scripts/dormouse-hosted/secrets"); - return { result: secrets.map((name) => ({ name })) }; + const script = /^workers\/scripts\/([a-z-]+)\/secrets$/.exec(path)?.[1]; + assert.ok(script && script in secrets, `Unexpected ${path}`); + read.push(script); + return { result: secrets[script].map((name) => ({ name })) }; }; } -test("preflight rejects wrong databases, caching, reused roles, and incomplete provider secrets", async () => { - const configuredSecrets = [ - "AUTH_SECRET", "POSTMARK_SERVER_TOKEN", "ELEVENLABS_API_KEY", - ...config.vars.OAUTH_PROVIDERS.split(",").filter(Boolean).flatMap((name) => [ - `${name.toUpperCase()}_CLIENT_ID`, `${name.toUpperCase()}_CLIENT_SECRET`, - ]), - ]; - await preflight(env, config, provider({ secrets: configuredSecrets })); - for (const missing of configuredSecrets) { +test("preflight rejects wrong databases, caching, reused roles, and incomplete secrets on each Worker", async () => { + const read = []; + await preflight(env, configs, provider({ read })); + // The relay holds no secret, so nothing is asked of it. + assert.deepEqual(read.sort(), ["dormouse-hosted", "dormouse-voice"]); + for (const missing of accountSecrets) await assert.rejects( - preflight(env, config, provider({ secrets: configuredSecrets.filter((name) => name !== missing) })), - { message: `Missing Worker secret: ${missing}` }, + preflight( + env, + configs, + provider({ + secrets: { + "dormouse-hosted": accountSecrets.filter((name) => name !== missing), + "dormouse-voice": ["ELEVENLABS_API_KEY"], + }, + }), + ), + { message: `Missing dormouse-hosted secret: ${missing}` }, ); - } + // The key on the account Worker, where it used to live, does not count. + await assert.rejects( + preflight( + env, + configs, + provider({ + secrets: { + "dormouse-hosted": [...accountSecrets, "ELEVENLABS_API_KEY"], + "dormouse-voice": [], + }, + }), + ), + { message: "Missing dormouse-voice secret: ELEVENLABS_API_KEY" }, + ); // Each case supplies every configured secret, so it fails on its own check. for (const [override, message] of [ [{ host: "ep-preview.neon.tech" }, "Migration and runtime databases must use the same host"], [{ database: "preview" }, "Migration and runtime databases must match"], [{ user: "migration" }, "Use separate runtime and migration roles"], [{ disabled: false }, "Production Hyperdrive must disable caching"], - [{ secrets: ["AUTH_SECRET"] }, "Missing Worker secret: POSTMARK_SERVER_TOKEN"], ]) - await assert.rejects( - preflight(env, config, provider({ secrets: configuredSecrets, ...override })), - { message: new RegExp(`^${message}`) }, - ); - const oauth = { - ...config, - vars: { ...config.vars, OAUTH_PROVIDERS: "github" }, - }; - await assert.rejects(preflight(env, oauth, provider())); + await assert.rejects(preflight(env, configs, provider(override)), { + message: new RegExp(`^${message}`), + }); + const withProviders = (providers) => ({ + ...configs, + account: { + ...configs.account, + vars: { ...configs.account.vars, OAUTH_PROVIDERS: providers }, + }, + }); + const minimal = (extra) => + provider({ + secrets: { + "dormouse-hosted": ["AUTH_SECRET", "POSTMARK_SERVER_TOKEN", ...extra], + "dormouse-voice": ["ELEVENLABS_API_KEY"], + }, + }); + await assert.rejects(preflight(env, withProviders("github"), minimal([]))); await preflight( env, - oauth, - provider({ - secrets: [ - "AUTH_SECRET", - "POSTMARK_SERVER_TOKEN", - "ELEVENLABS_API_KEY", - "GITHUB_CLIENT_ID", - "GITHUB_CLIENT_SECRET", - ], - }), - ); - await assert.rejects( - preflight( - env, - { ...config, vars: { ...config.vars, OAUTH_PROVIDERS: "unknown" } }, - provider(), - ), + withProviders("github"), + minimal(["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET"]), ); + await assert.rejects(preflight(env, withProviders("unknown"), provider())); }); diff --git a/hosted/scripts/stage-one-time.mjs b/hosted/scripts/stage-one-time.mjs index 9fef34d32..f017964a5 100644 --- a/hosted/scripts/stage-one-time.mjs +++ b/hosted/scripts/stage-one-time.mjs @@ -1,4 +1,4 @@ -import { cpSync, existsSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -7,21 +7,20 @@ import { } from "../../lib/scripts/assert-pocket-worker.mjs"; /** - * Copy the one-time phone page `lib`'s `build:one-time` emitted into Hosted's - * assets at the page path (`docs/specs/one-time.md` -> "Phone page"), after - * Hosted's own Vite build has emptied `dist/`, and check the copy's shell - * against the page's policy — what Hosted serves, not only what `lib` built. - * Returns how many scripts the shell loads. + * Copy the one-time phone page `lib`'s `build:one-time` emitted into the relay + * Worker's assets at the page path (`docs/specs/one-time.md` -> "Phone page"), + * emptying that directory first so the page is all it holds, and check the + * copy's shell against the page's policy — what the relay serves, not only + * what `lib` built. Returns how many scripts the shell loads. */ -export function stageOneTime(built, dist) { +export function stageOneTime(built, assets) { if (!existsSync(join(built, "index.html"))) throw new Error( `${built} holds no one-time page; run \`pnpm --filter dormouse-lib build:one-time\` first.`, ); - if (!existsSync(dist)) - throw new Error(`${dist} does not exist; run Hosted's Vite build first.`); - const target = join(dist, ONE_TIME_SHELL.base); - rmSync(target, { recursive: true, force: true }); + rmSync(assets, { recursive: true, force: true }); + mkdirSync(assets, { recursive: true }); + const target = join(assets, ONE_TIME_SHELL.base); cpSync(built, target, { recursive: true }); const scripts = assertPocketShell(target, ONE_TIME_SHELL); if (scripts === 0) throw new Error("the staged one-time shell has no script"); @@ -35,10 +34,10 @@ if ( try { const scripts = stageOneTime( fileURLToPath(new URL("../../lib/dist-one-time", import.meta.url)), - fileURLToPath(new URL("../dist", import.meta.url)), + fileURLToPath(new URL("../dist/relay", import.meta.url)), ); console.log( - `one-time page staged at dist${ONE_TIME_SHELL.base}: ${scripts} script(s) under ${ONE_TIME_SHELL.scriptBase}`, + `one-time page staged at dist/relay${ONE_TIME_SHELL.base}: ${scripts} script(s) under ${ONE_TIME_SHELL.scriptBase}`, ); } catch (error) { console.error(`one-time staging failed: ${error.message}`); diff --git a/hosted/scripts/stage-one-time.test.mjs b/hosted/scripts/stage-one-time.test.mjs index d090d3702..ccc42965c 100644 --- a/hosted/scripts/stage-one-time.test.mjs +++ b/hosted/scripts/stage-one-time.test.mjs @@ -1,32 +1,31 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stageOneTime } from "./stage-one-time.mjs"; -/** A `lib/dist-one-time` whose shell loads `script`, and an emptied Hosted `dist/`. */ +/** A `lib/dist-one-time` whose shell loads `script`, and the relay's assets directory from an earlier build. */ function fixture(script = '') { 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/scripts/workers.mjs b/hosted/scripts/workers.mjs new file mode 100644 index 000000000..fb552961b --- /dev/null +++ b/hosted/scripts/workers.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { posix } from "node:path"; +import { fileURLToPath } from "node:url"; +import { providerIds } from "../server/providers.js"; + +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 deployed and, in production, smoked. + */ +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", + /** The secret names its production Worker must hold. */ + secrets: (config) => [ + "AUTH_SECRET", + "POSTMARK_SERVER_TOKEN", + ...oauthProviders(config).flatMap((provider) => { + assert.ok(providerIds.includes(provider), "Unknown OAuth provider"); + return [ + `${provider.toUpperCase()}_CLIENT_ID`, + `${provider.toUpperCase()}_CLIENT_SECRET`, + ]; + }), + ], + /** Its preview is deployed with the derived `AUTH_SECRET`. */ + previewSecrets: true, + }, +}; + +/** A Wrangler config: JSON, plus `//` comments on lines of their own. */ +export const parseConfig = (text) => JSON.parse(text.replace(/^\s*\/\/.*$/gm, "")); + +/** Each Worker's checked-in config, keyed as `WORKERS` is. */ +export async function readConfigs() { + const configs = {}; + for (const [worker, { config }] of Object.entries(WORKERS)) + configs[worker] = parseConfig(await readFile(new URL(config, hosted), "utf8")); + return configs; +} + +/** The OAuth providers a config enables, as `OAUTH_PROVIDERS` lists them. */ +export function oauthProviders(config) { + return (config.vars?.OAUTH_PROVIDERS ?? "") + .split(",") + .map((provider) => provider.trim()) + .filter(Boolean); +} + +/** + * A path in a checked-in config, relative to `hosted/`, as a config written + * to `.wrangler//` must spell it. + */ +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; `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 = () => [], afterDeploy = async () => {}, spawn = spawnSync } = {}, +) { + const directory = new URL(`.wrangler/${stage}/`, hosted); + await mkdir(directory, { recursive: true, mode: 0o700 }); + for (const worker of Object.keys(WORKERS)) { + const path = new URL(`wrangler.${worker}.json`, directory); + await writeFile(path, JSON.stringify(configs[worker], null, 2) + "\n"); + try { + const run = spawn( + "pnpm", + ["exec", "wrangler", "deploy", "--config", fileURLToPath(path), ...args(worker)], + { cwd: fileURLToPath(hosted), stdio: "inherit" }, + ); + if (run.status !== 0) + throw new Error(`${configs[worker].name} deploy failed`); + } finally { + await rm(path, { force: true }); + } + await afterDeploy(worker); + } +} diff --git a/hosted/server/account-app.ts b/hosted/server/account-app.ts new file mode 100644 index 000000000..19d9dec7b --- /dev/null +++ b/hosted/server/account-app.ts @@ -0,0 +1,52 @@ +import type { ExecutionContext, Hono } from "hono"; +import { queryDatabase } from "pgstencil/postgres"; +import type { AccountEnv } from "./bindings"; +import { ACCOUNT_HASHED_ASSETS, 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, + hashedAssets: ACCOUNT_HASHED_ASSETS, + 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..3120004f3 --- /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/security-hosted.md` -> "Origin +// 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. */ +export 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..67c304844 100644 --- a/hosted/server/dev.ts +++ b/hosted/server/dev.ts @@ -10,7 +10,7 @@ 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 { 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 @@ -58,23 +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 voice = { +const app = new Hono(); +voiceTokenRoutes(app, () => ({ databaseUrl, auth: (request: Request) => auth.app.fetch(request), - synthesize: elevenLabsKey ? elevenLabs(elevenLabsKey) : silence, -}; -const app = new Hono(); -voiceRoutes(app, () => voice); +})); +// 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: { @@ -91,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/headers.ts b/hosted/server/headers.ts index b703377da..cad8e2452 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,37 +48,47 @@ 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. */ -const HASHED_ASSETS = ["/assets/", `${ONE_TIME_PAGE_PATH}assets/`]; +/** What a Worker's `secureHeaders` asks for each response's policy. */ +export type PolicyFor = (pathname: string, appOrigin: unknown) => string; + +/** The account frontend's content-hashed Vite output. */ +export const ACCOUNT_HASHED_ASSETS = ["/assets/"]; + +/** The one-time page's content-hashed Vite output, on the relay. */ +export const RELAY_HASHED_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, + hashedAssets: readonly string[], +) { app.use("*", async (c, next) => { await next(); // A WebSocket upgrade carries no document, and its headers are the runtime's. if (c.res.status === 101) return; const { pathname } = new URL(c.req.url); - // Vite emits content-hashed files under each `assets/`, so they are safe to cache - // forever, but the SPA fallback answers an unknown /assets/ path with the HTML shell: - // cache only a 200 whose type is not HTML, and leave everything else uncached. + // Vite emits content-hashed files under this Worker's `hashedAssets`, so they are safe + // to cache forever, but the SPA fallback answers an unknown /assets/ path with the HTML + // shell: cache only a 200 whose type is not HTML, and leave everything else uncached. const asset = c.res.status === 200 && - HASHED_ASSETS.some((prefix) => pathname.startsWith(prefix)) && + hashedAssets.some((prefix) => pathname.startsWith(prefix)) && !(c.res.headers.get("content-type") ?? "").includes("text/html"); c.header( "Cache-Control", @@ -85,9 +101,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..ee9716657 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( - (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()), +export default accountApp( + auth.fetch, + 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..b981278ed --- /dev/null +++ b/hosted/server/relay-worker.ts @@ -0,0 +1,23 @@ +import { relayBindings, type RelayEnv } from "./bindings"; +import { RELAY_HASHED_ASSETS, 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, + hashedAssets: RELAY_HASHED_ASSETS, + 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..66eb76874 --- /dev/null +++ b/hosted/server/tests/boundary.test.ts @@ -0,0 +1,348 @@ +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 { + accountBindings, + accountPreviewBindings, + relayBindings, + voiceBindings, + voicePreviewBindings, +} from "../bindings"; +import { + ACCOUNT_POLICY, + RELAY_HASHED_ASSETS, + RUNS_NOTHING_POLICY, +} from "../headers"; +import { voiceApp } from "../voice-app"; +import { workerApp } from "../worker-app"; +import { + ENTRIES, + NAMES, + ORIGINS, + bundleWorker, + miniflareOptions, + type Name, +} 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 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", +}; + +const IMMUTABLE = "public, max-age=31536000, immutable"; +const HASHED_FILES = ["/assets/app-abc123.js", `${ONE_TIME_PAGE_PATH}assets/page-abc123.js`]; + +/** Every request any Worker sent upstream. */ +const outbound: string[] = []; +const workers = {} as Record; +const bundles = {} as Record>>; + +beforeAll(async () => { + await Promise.all( + NAMES.map(async (name) => { + bundles[name] = await bundleWorker(ENTRIES[name]); + workers[name] = new Miniflare( + miniflareOptions(name, bundles[name].outputFiles[0].text, { + 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" }, + // Two content-hashed scripts, one under each Worker's prefix, and an + // SPA fallback answering every other path, so a route that reached + // the assets shows as a 200 rather than a 404. + serviceBindings: { + ASSETS: (request) => + HASHED_FILES.includes(new URL(request.url).pathname) + ? new WorkerResponse("export {};", { + headers: { "content-type": "text/javascript" }, + }) + : 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("each Worker caches only its own hashed assets as immutable", async () => { + const [account, phone] = HASHED_FILES; + const cache = (response: { headers: { get(name: string): string | null } }) => + response.headers.get("cache-control"); + expect(cache(await send("account", ORIGINS.account + account))).toBe(IMMUTABLE); + expect(cache(await send("account", ORIGINS.account + phone))).toBe("no-store"); + // The relay hands its assets only `/connect/` paths, so its prefixes are + // checked on an app that serves a script at both. + const relay = workerApp({ + bindings: (env) => env, + policy: () => RUNS_NOTHING_POLICY, + hashedAssets: RELAY_HASHED_ASSETS, + unavailable: "", + routes: (app) => + app.get("*", () => + new Response("export {};", { headers: { "content-type": "text/javascript" } }), + ), + }); + const env = { APP_ORIGIN: ORIGINS.relay }; + expect(cache(await relay.fetch(new Request(ORIGINS.relay + phone), env))).toBe(IMMUTABLE); + expect(cache(await relay.fetch(new Request(ORIGINS.relay + account), env))).toBe("no-store"); +}); + +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 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) { + // 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("the cron handler gets the mapped bindings: a key the mapper drops never sweeps", async () => { + const fetch = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => Response.json({ history: [] })); + try { + const env = { + APP_ORIGIN: ORIGINS.voice, + HYPERDRIVE: { connectionString: "postgres://example.test/none" }, + ELEVENLABS_API_KEY: "test-elevenlabs-key", + }; + const ctx = {} as ExecutionContext; + await voiceApp(voicePreviewBindings).scheduled!({}, env, ctx); + expect(fetch).not.toHaveBeenCalled(); + // The production mapper passes the same key on, and it sweeps. + await voiceApp(voiceBindings).scheduled!({}, env, ctx); + expect(fetch).toHaveBeenCalledOnce(); + } finally { + fetch.mockRestore(); + } +}); + +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, bundle] of [ + [ENTRIES.relay, bundles.relay], + [ENTRIES.voice, bundles.voice], + ["server/voice-preview-worker.ts", await bundleWorker("server/voice-preview-worker.ts")], + ] as const) { + const inputs = Object.keys(bundle.metafile.inputs); + expect( + inputs.filter((input) => /better-auth|postmark/.test(input)), + entry, + ).toEqual([]); + } + // The pattern finds it where it is. + const account = Object.keys(bundles.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..f8de3a3ce 100644 --- a/hosted/server/tests/bundle.ts +++ b/hosted/server/tests/bundle.ts @@ -1,19 +1,75 @@ import { build } from "esbuild"; +import { convertV4MiniflareOptions } from "miniflare"; import { readFileSync } from "node:fs"; import { builtinModules } from "node:module"; +import { WORKERS, parseConfig } from "../../scripts/workers.mjs"; -/** `hosted/wrangler.jsonc`, which is strict JSON so scripts can parse it. */ -export const wrangler = JSON.parse(readFileSync("wrangler.jsonc", "utf8")) as { +interface WranglerConfig { + main: string; + vars: { APP_ORIGIN: string }; 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, as the registry names them. */ +export const wrangler = Object.fromEntries( + Object.entries(WORKERS).map(([name, { config }]) => [ + name, + parseConfig(readFileSync(config, "utf8")) as WranglerConfig, + ]), +) as Record; + +export type Name = keyof typeof wrangler; +export const NAMES = Object.keys(wrangler) as Name[]; +const each = (pick: (config: WranglerConfig) => T) => + Object.fromEntries(NAMES.map((name) => [name, pick(wrangler[name])])) as Record; +/** Each Worker's production origin, from its config. */ +export const ORIGINS = each((config) => config.vars.APP_ORIGIN); +/** Each Worker's production entry, from its config. */ +export const ENTRIES = each((config) => config.main); + +type V4Options = Parameters[0]; + +/** + * Miniflare options running `script` as Worker `name`: compatibility from + * that Worker's own config, and its Durable Objects and rate limits bound as + * the config declares them. `extra` adds the rest, and wins. + */ +export function miniflareOptions(name: Name, script: string, extra: Partial = {}) { + const config = wrangler[name]; + return convertV4MiniflareOptions({ + modules: true, + script, + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + ...(config.durable_objects && { + durableObjects: Object.fromEntries( + config.durable_objects.bindings.map(({ name, class_name }) => [ + name, + { + className: class_name, + useSQLite: !!config.migrations?.some((migration) => + migration.new_sqlite_classes?.includes(class_name), + ), + }, + ]), + ), + }), + ...(config.ratelimits && { + ratelimits: Object.fromEntries( + config.ratelimits.map(({ name, ...limit }) => [name, limit]), + ), + }), + ...extra, + } as V4Options); +} /** A Worker entry bundled for workerd the way Wrangler bundles it. */ export function bundleWorker(entry: string, inject: string[] = []) { @@ -22,6 +78,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..fbd6e9cd0 100644 --- a/hosted/server/tests/one-time.test.ts +++ b/hosted/server/tests/one-time.test.ts @@ -1,9 +1,5 @@ import { test, expect, beforeAll, afterAll } from "vitest"; -import { - Miniflare, - convertV4MiniflareOptions, - Response as WorkerResponse, -} from "miniflare"; +import { Miniflare, Response as WorkerResponse } from "miniflare"; import { E2E_ID_LENGTH, isOneTimeRoomFrame, @@ -30,46 +26,30 @@ 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 { ENTRIES, ORIGINS, bundleWorker, miniflareOptions, 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 = ORIGINS.relay; +/** The sibling Workers' origins, which share the account's login cookie as same-site. */ +const SIBLINGS = [ORIGINS.account, ORIGINS.voice]; const PAGE_SCRIPT = `${ONE_TIME_PAGE_PATH}assets/page-abc123.js`; const PAGE_SHELL = ``; async function start(entry: string) { const mf = new Miniflare( - convertV4MiniflareOptions({ - modules: true, - script: (await bundleWorker(entry)).outputFiles[0].text, - compatibilityDate: wrangler.compatibility_date, - compatibilityFlags: wrangler.compatibility_flags, - bindings: { APP_ORIGIN: origin, OAUTH_PROVIDERS: "" }, - durableObjects: Object.fromEntries( - wrangler.durable_objects.bindings.map(({ name, class_name }) => [ - name, - { - className: class_name, - useSQLite: wrangler.migrations.some((migration) => - migration.new_sqlite_classes?.includes(class_name), - ), - }, - ]), - ), - ratelimits: Object.fromEntries( - wrangler.ratelimits.map(({ name, ...limit }) => [name, limit]), - ), + miniflareOptions("relay", (await bundleWorker(entry)).outputFiles[0].text, { + bindings: { APP_ORIGIN: origin }, 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 +71,7 @@ let production: Awaited>; let short: Awaited>; beforeAll(async () => { [production, short] = await Promise.all([ - start("server/worker.ts"), + start(ENTRIES.relay), start("server/tests/one-time-entry.ts"), ]); }); @@ -162,16 +142,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 +185,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 +389,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 +444,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 +476,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 +537,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 +548,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 +568,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..c09d0ec72 --- /dev/null +++ b/hosted/server/tests/voice-entry.ts @@ -0,0 +1,27 @@ +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. +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: ExecutionContext, + ) { + 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..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,15 +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 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", 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 ? [] @@ -43,31 +45,35 @@ 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(ENTRIES.voice), + preview: bundleWorker("server/voice-preview-worker.ts"), +}; type Result = Awaited>; 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.compatibility_date, - compatibilityFlags: wrangler.compatibility_flags, + database, + assets, + ...rest + }: { + script: string; + database: string; + assets?: Handler; + bindings: Record; + outboundService: Handler; + }, +) => + miniflareOptions(name, script, { hyperdrives: { HYPERDRIVE: database }, - serviceBindings: { ASSETS: assets }, + ...(assets ? { serviceBindings: { ASSETS: assets } } : {}), ...rest, }); @@ -107,8 +113,63 @@ 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({ + workerOptions("account", { script: ( await (production === "preview" ? previewBundle @@ -131,63 +192,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("voice", { + 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 +321,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 +335,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 +349,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 +441,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 +450,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 +494,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 +606,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 +659,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 +728,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 +753,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 +779,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. @@ -746,13 +795,12 @@ test("production cron sweeps ElevenLabs history with only its key and no databas maxInFlight = 0; 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. + workerOptions("voice", { + 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..6e150570b --- /dev/null +++ b/hosted/server/voice-app.ts @@ -0,0 +1,39 @@ +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 } = {}, +) { + return 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, + }; + }); + }, + // 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/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..a43627d99 100644 --- a/hosted/server/worker-app.ts +++ b/hosted/server/worker-app.ts @@ -1,90 +1,67 @@ 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 type { WorkerEnv } from "./bindings"; +import { secureHeaders, type PolicyFor } from "./headers"; -export function workerApp( - fetchAuth: ( - request: Request, - env: Env, +/** + * 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. A Cron Trigger's + * `scheduled` sees the same mapped bindings a route does. + */ +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, - ) => 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. + ) => Promise; + /** `onError`'s message. */ + unavailable: string; +}) { + const app = new Hono<{ Bindings: E }>(); + 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) => { 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, - ), - ); - + fallback?.(app); + app.onError((_error, c) => c.json({ message: unavailable }, 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); - }, + 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 baf9ba954..b680decb1 100644 --- a/hosted/server/worker.ts +++ b/hosted/server/worker.ts @@ -1,51 +1,13 @@ -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(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 e47da114a..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 @@ -31,44 +31,24 @@ "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 * * * *" - ] + // Empty, never absent: an absent `triggers` leaves a deployed schedule in + // place, and the history sweep's moved to the voice Worker. + "crons": [] }, "observability": { "enabled": false diff --git a/hosted/wrangler.relay.jsonc b/hosted/wrangler.relay.jsonc new file mode 100644 index 000000000..d56c9c8fe --- /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/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/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.', }); 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..04330fabb 100644 --- a/lib/src/host/relay-origin.test.ts +++ b/lib/src/host/relay-origin.test.ts @@ -12,7 +12,16 @@ import { relayOriginDefine, resolveRelayOrigin, } from '../../../scripts/relay-origin.mjs'; -import { DEFAULT_RELAY_ORIGIN, bakedRelay, bakedRelayMode, hostedOrigin, isRelayOrigin } from './relay-origin'; +import { readConfigs } from '../../../hosted/scripts/workers.mjs'; +import { + DEFAULT_RELAY_ORIGIN, + HOSTED_VOICE_ORIGIN, + bakedRelay, + bakedRelayMode, + hostedOrigin, + hostedVoiceOrigin, + isRelayOrigin, +} from './relay-origin'; /** * An `https://` origin of exactly `length` characters under `example`, built @@ -30,22 +39,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,10 +66,16 @@ 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); }); + 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)', () => { expect(bakedRelay()).toEqual({ origin: DEFAULT_RELAY_ORIGIN, mode: 'hosted' }); expect(bakedRelayMode()).toBe('hosted'); @@ -71,6 +86,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 +210,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/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", 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-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) ]; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0bbc67832..1d3af8573 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": 5250, "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": 3950, "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 () => {