diff --git a/.github/audit/hosted.md b/.github/audit/hosted.md index 34203430d..1a4f2e31e 100644 --- a/.github/audit/hosted.md +++ b/.github/audit/hosted.md @@ -7,18 +7,23 @@ **Output file:** `audit-hosted.md` 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 +application (`hosted.dormouse.sh`), the relay that serves the Hosted Relay's +account-scoped routes, Pocket, and 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 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 rendezvous", and "Phone page"), `docs/specs/relay.md` (its "HTTP API", +"Setup tokens and the pairing QR", and "WebAuthn without a WebAuthn library", +whose semantics the Hosted Relay keeps), `hosted/server/`, `hosted/src/`, `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 — +`remote-lib-common/src/remote/one-time-wire.ts`, +`remote-lib-common/src/remote/relay-common.ts`, the Pocket build the relay +serves (`lib/vite.pocket.config.ts`, `lib/pocket/`), the phone page it serves — `lib/vite.one-time.config.ts`, `lib/one-time/`, `lib/src/remote/one-time-app/`, and `lib/scripts/assert-pocket-worker.mjs` — and `.github/workflows/hosted-preview.yml` and @@ -91,14 +96,40 @@ Be adversarial, and go past the `FAIL IF` list. Ask specifically: 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. + Workers must hold and pass no auth secret and never import Better Auth, and + the account and relay no ElevenLabs key, and the account and voice no + `RELAY_ENROLL_SECRET`. The relay's Hyperdrive reaches only + its own tables and the entitlement's user row. +- **Can one account reach another's Relay rows?** Trace every query in + `hosted/server/relay-api.ts`: a session or Burrow token of account B must not + list, prove with, spend, or mint against account A's Burrows, passkeys, + nonces, setup tokens, or setup challenges; a single-use row must be spent in + the statement that reads it; a bearer secret must be at rest only as its + hash; a de-entitled account or removed Burrow must act on nothing, the + account's sessions and sign-in included; every unauthenticated route that + reaches Postgres must spend its per-address limit first; and every table a + caller grows must stay bounded against that caller, a capped write never + touching another key's rows. Classify a percent-encoded path (`/%63onnect/`, + `/%61ssets/`) the way `secureHeaders` and the routes do. +- **Can an enrollment become someone else's Burrow, or a second one?** Trace + a device code from `begin` through the account's approval + (`hosted/server/relay-account.ts`) to the poll that redeems it: begin must + write nothing; a web page must not begin or poll; the device code must carry + its own expiry and its user code must be the relay secret's HMAC of it, so + no one can forge a device code that redeems another's approval; an approval + must need a recent admin login from the account's own origin and never + replace a live one; the redemption must be one statement whose owner is that + approver; and a removed Burrow's row must be gone, its token opening nothing + on any relay route. Look for a user code predictable without the secret, an + unlimited approval loop, and a table an unauthenticated caller can grow. - **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 provider credential must enable nothing. No Hosted endpoint may mint a Burrow - ACL grant or stand in for the encrypted pairing and presence proof, and the - rendezvous authorizes nothing. + ACL grant or stand in for the encrypted pairing and presence proof, the + Relay must read no cookie, and the rendezvous authorizes nothing. Pocket and + `/connect/` share the relay origin; check what each page's policy lets it + reach of the other. - **Can the rendezvous become more than a handshake pipe?** Trace a frame through `OneTimeRoom`: nothing may read, keep, or log it, and the length, type, and count bounds must close both ends before a byte past them is diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index ef6b512d8..4148470ae 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -1,7 +1,7 @@ # Dormouse Hosted accounts > See `docs/specs/glossary.md` for Burrow, Client, Relay, and Session vocabulary. -> 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. +> Owns the Hosted account application, the Hosted Relay's account-scoped routes, 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"; the Relay's shared route semantics to `docs/specs/relay.md` -> "HTTP API"; remote authorization to `docs/specs/remote-security-model.md`. ## Application boundary @@ -9,11 +9,11 @@ | 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-hosted` | `https://hosted.dormouse.sh` | the account frontend, `/api/auth/*`, `/api/providers`, `/api/ready`, voice tokens, the Relay's account routes ("Burrow enrollment") | the login cookie, auth secrets, Hyperdrive, the approval rate limit | +| `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay and Pocket ("Relay"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET` | | `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). +Every Worker answers `/api/health`, 404s anything else under its non-page prefixes (`/api`, and the relay's `/ws` too), `/dev/*`, or `/__test/*`, and answers a thrown request 503 under `secureHeaders`. The account falls back to its SPA assets, the relay to Pocket's. 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. @@ -33,7 +33,7 @@ Source of truth: `workerApp` in `hosted/server/worker-app.ts`; `accountApp` in ` **May create provider-only accounts without verified email.** Public email is null; pgstencil's internal placeholder is never a delivery address. Email-code login remains an access path to an account's canonical verified mailbox. No merge, email adoption, unlink, or account-recovery interface exists. -**Must identify accounts by immutable user ID, never email.** Provider-only accounts keep their identity when a provider subsequently supplies email. Exception: "Managed voice". +**Must identify accounts by immutable user ID, never email.** Provider-only accounts keep their identity when a provider subsequently supplies email. Exception: `ADMIN_EMAIL` ("Managed voice"). **Must enable providers explicitly in `OAUTH_PROVIDERS`.** The allowed set is GitHub, Google, Microsoft, and Apple. Missing paired credentials or unknown names fail closed; unused credentials enable nothing. Email uses Postmark in production and local capture in development. @@ -47,7 +47,7 @@ Source of truth: `hosted/server/providers.js`; `authPolicy` / `providerBindings` **Must show configured sign-in methods only.** Email has send, existing-code, verify, resend, and change-address paths. The account screen lists connected methods and explains recent-login requirements and provider-only recovery limits. Failed callbacks display a recoverable error and remove query parameters from browser history. -**Must check the account on return to the page and serialize submitted actions.** Authenticated data remains in memory; login tokens never enter local storage. Only public identity fields are rendered, without provider images or external assets. The Voice tokens section renders only when `GET /api/voice/tokens` succeeds, and its failure never fails the account page; a minted token stays in memory and is shown once. +**Must check the account on return to the page and serialize submitted actions.** Authenticated data remains in memory; login tokens never enter local storage. Only public identity fields are rendered, without provider images or external assets. The Voice tokens and Computers sections render only when `GET /api/voice/tokens` and `GET /api/relay/burrows` succeed, and neither failure fails the account page; a minted token stays in memory and is shown once. **Must inherit Dormouse product theme tokens before mounting React.** The OS light/dark preference selects bundled Light Visual Studio or Kimbie Dark. Its type scale and touch sizing are in `hosted/src/style.css`. It loads no marketing styles, fonts, or analytics. @@ -66,7 +66,7 @@ An admin-only test slice: Dormouse desktop exchanges a pasted voice token for El Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 for any account but the admin. -**Must admit only `ADMIN_EMAIL` while it is the account's verified email, rechecked on every request.** This is the only exception to "never email" ("Identity and login"); nothing else may key on an address, and it ends with the entitlement in Future item 3. Cookie routes ask the Better Auth handler's `get-session` for the login; speak reads the token owner's user row. +**Must admit only `ADMIN_EMAIL` while it is the account's verified email, rechecked on every request.** This gate, `isAdmin`, is also the Relay's entitlement ("Relay") and the only exception to "never email" ("Identity and login"); nothing else may key on an address, and it ends with the entitlement in Future item 3. Cookie routes ask the Better Auth handler's `get-session` for the login; speak reads the token owner's user row. **Must store only a token's SHA-256.** A token is `dmv_` plus base64url of 32 random bytes, returned only by the mint response. Revocation is permanent; speak stamps `lastUsedAt`. @@ -88,13 +88,74 @@ Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 - **Must bound each pass**; a backlog waits for the next pass. At most six deletes are in flight; a 404 counts as deleted; a failed delete never aborts the pass. Only counts and statuses are logged or thrown. - **Must fail the cron invocation when its pass cannot list or any delete fails; the after-speech pass only logs** (rationale). -Source of truth: `isAdmin` in `hosted/server/admin.ts`; `voiceTokenRoutes` / `speakRoute` / `elevenLabs` / `sweepOnCron` / `sweepAfterSpeech` in `hosted/server/voice.ts`; `scheduled` in `hosted/server/voice-app.ts` 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`. +Source of truth: `isAdmin` in `hosted/server/admin.ts`; `cookieAdmin` in `hosted/server/account-gate.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`. + +## Relay + +The relay Worker serves the self-host Relay's HTTP API to many accounts: the paths, shapes, statuses, and error strings of `docs/specs/relay.md` -> "HTTP API", "Setup tokens and the pairing QR", and "WebAuthn without a WebAuthn library", so a Burrow and Pocket cannot tell the two apart; both run the checks and bounds in `remote-lib-common/src/remote/relay-common.ts`. Assertions demand presence, not verification. Security checks: `docs/specs/security-hosted.md` -> "Relay boundary". Only the differences: + +| Route | On Hosted | +|---|---| +| `POST /api/setup/begin`, `/finish` | The passkey joins the account owning the token's Burrow: `accountId` is its user ID, `existingCredentialIds` its passkeys; 409 at `MAX_PASSKEYS_PER_ACCOUNT` | +| `POST /api/setup/retire` | Spends only a token one of the session's account's Burrows minted | +| `POST /api/signin/finish` | The asserted credential's account; `accountId` is its user ID. 401 `NOT_ENTITLED_ERROR`, and no session, for an account not entitled | +| `POST /api/reauth/begin`, `/finish` | Only the session's account's credentials and nonces | +| `GET /api/burrows` | The session's account's Burrows, each `online: false`: no relay socket reaches Hosted yet | +| `POST /api/burrow/setup-token` | 401 for a removed Burrow, 403 `NOT_ENTITLED_ERROR` for an owner not entitled | +| `POST /api/burrow/enroll` | Always 401 `UNAUTHORIZED_ERROR`: Hosted has no setup password; a Burrow enrolls by device code ("Burrow enrollment") | +| `GET /api/push/config` | `{ applicationServerKey: null }`: push is off; every other push route, `/ws/*`, and `GET /api/hello` (the self-host installers' probe) are 404 | +| `GET /*` | Pocket (below) | + +A session-gated route answers a session of an account no longer entitled with the expired session's 401 `UNAUTHORIZED_ERROR`, so Pocket returns to sign-in. + +- **Must keep the Relay's state in Postgres** (`hosted/server/dormouse-migrations/002_relay.sql`). A sign-in challenge is the challenge row with no Burrow. +- **Must resolve each bearer and its owner's entitlement in one query joining `"user"`** (`sessionByToken`, `burrowByToken`). The entitlement is `isAdmin` ("Managed voice"). Reserved: the relay socket upgrades (Future item 4) call the same lookups with the query-parameter token. +- **Must cap every table a caller grows, keyed by whoever grows it**, as `docs/specs/relay.md` -> "Guardrails" does: setup tokens and setup challenges per Burrow (`MAX_TOKENS_PER_BURROW`), presence nonces per session (`MAX_PENDING_REAUTH_NONCES_PER_SESSION`), and sessions per account (`MAX_SESSIONS_PER_ACCOUNT`), each evicting its key's own oldest; passkeys per account are refused at the cap (rationale). A capped insert runs under its key's advisory lock and, in one statement, prunes that key's own expired rows, trims its live rows, and inserts; it never touches another key's rows. +- **Must sweep every Relay table's expired rows from the relay's hourly Cron Trigger** (rationale); sign-in challenges, minted unauthenticated and flat, are bounded by it and the per-address limit. +- **Must answer 429 with `Retry-After` past the per-address limit on `signin/*` (`RELAY_SIGNIN_LIMIT`) and `setup/begin`/`finish` (`RELAY_SETUP_LIMIT`)**, before the body limit and any database read: 30 a minute, a ceremony's two routes sharing one budget (rationale). +- **Must restore a token a refused `finish` spent on its original expiry, within the Burrow's cap, and never once that expiry has passed.** + +**Pocket at the root.** `build` stages `lib/dist-pocket` at the root of the relay's assets and the one-time page beside it, checking both shells. Pocket is served per `docs/specs/pocket-app.md` -> "Serving the built bundle", except that a path naming no file (no extension, outside `/diagnostics`) gets the shell in one asset fetch. + +Source of truth: `relayApiRoutes` / `sweepExpired` in `hosted/server/relay-api.ts`; `sessionByToken` / `burrowByToken` / `requireSession` / `requireBurrow` in `hosted/server/relay-auth.ts`; `hosted/server/dormouse-migrations/002_relay.sql`; `triggers` and `ratelimits` in `hosted/wrangler.relay.jsonc`; `pocketRoutes` in `hosted/server/pocket.ts`; `relayRules` / `relayPathKind` in `hosted/server/headers.ts`; `stageRelay` in `hosted/scripts/stage-relay.mjs`; `checkRegistration` / `verifySigninAssertion` in `remote-lib-common/src/remote/relay-common.ts`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, `hosted/scripts/stage-relay.test.mjs`, and `remote-lib-common/test/relay-common.test.mjs`. + +## Burrow enrollment + +A Burrow joins an account by device code, in place of the self-host setup password. The account owns the Burrow; the Burrow's own ACL still authorizes every Client. The relay serves the Burrow's two routes, the account the rest; wire types are `BurrowEnrollBeginResponse` / `BurrowEnrollPollResponse` in `remote-lib-common/src/remote/wire.ts`. + +1. The Burrow sends `{ origin }` to `POST /api/burrow/enroll/begin`. Another origin, or none, is the self-host 409 `ORIGIN_MISMATCH_ERROR`. The answer: `deviceCode`, `userCode` (`XXXX-XXXX`), `verificationUrl` (`ACCOUNT_ORIGIN/enroll#`, absent without `ACCOUNT_ORIGIN`), `expiresAt` (`ENROLLMENT_TTL_MS`, 10 minutes), and `interval` (5 seconds). +2. The user opens the link, signs in, compares the code, and approves, writing the approval `{ userCode, userId, expiresAt }`. +3. The Burrow polls `POST /api/burrow/enroll/poll` with `{ deviceCode }` every `interval`: `expired` for a malformed or expired code; `pending` while no live approval holds its user code; or `enrolled` with the self-host `BurrowEnrollResponse`, `origin` the relay's `APP_ORIGIN` and `rpId` its hostname, without `requireUserVerification`. 403 `NOT_ENTITLED_ERROR` when the approver is no longer entitled; 409 naming `ACCOUNT_ORIGIN/account` at `MAX_ENROLLED_BURROWS` Burrows. Both refusals keep the approval, so a later poll can enroll. + +- **Never write from begin.** The device code is 32 bytes, the bearer shape: a 4-byte big-endian expiry in epoch seconds, then 28 random bytes. The user code is `enrollUserCode`: `HMAC-SHA-256(RELAY_ENROLL_SECRET, deviceCode)` read five bits at a time into `ENROLL_USER_CODE_ALPHABET`, values past it skipped (rationale). +- **Must store the approval alone** (`dormouse_relay_enrollment_approvals`): it cannot tell an issued code from any well-formed one, so it approves any; one no Burrow redeems expires (rationale). +- **Never admit a begin or poll request carrying `Origin`** (403): only a Node Burrow calls them. Then 429 with `Retry-After` past `RELAY_ENROLL_BEGIN_LIMIT` (10 a minute per address) or `RELAY_ENROLL_POLL_LIMIT` (60), before the body limit and any database read. +- **Must answer an expired device code from the code alone**, reading no database. +- **Must redeem in one statement**: the poll recomputes the user code, deletes its live approval, and inserts the Burrow owned by the approval's `userId`, under the account's lock with the cap check, so two polls mint one Burrow. The entitlement is rechecked first. + +| Route (account) | Credential | Success | +|---|---|---| +| `POST /api/relay/enrollments/approve` | login cookie, exact `Origin`, JSON `{ userCode }` | 204 | +| `GET /api/relay/burrows` | login cookie | 200 `{ burrows: [{ burrowId, enrolledAt }] }` | +| `DELETE /api/relay/burrows/:burrowId` | login cookie, exact `Origin` | 204; 404 for another account's or an unknown ID | + +Errors are the managed-voice cookie routes' ("Managed voice"), except that 403 for any account but the admin carries `NOT_ENTITLED_ERROR`. + +- **Must refuse approval from a login older than `LOGIN_FRESH_AGE_MS`**, 403 `RECENT_LOGIN_REQUIRED`, reading `get-session`'s `createdAt` in that route alone and failing closed when it is missing or unparsable; the gate the voice routes share never reads it. +- **Must count every approval attempt** against `RELAY_APPROVE_LIMIT` (10 a minute per account) before reading the body: 429 with `Retry-After` past it. +- **Must answer a malformed code 400**, forgiving case, spaces, and dashes, and a second approval of a live code 409 `ALREADY_APPROVED`, whichever account sends it: a live approval never moves. An expired one is replaced. +- **Must remove by deleting the Burrow's row**, its setup tokens and setup challenges cascading, so `burrowByToken` finds nothing on any relay route; its live socket ends with the sockets (Future item 4). +- **Must take the `/enroll` fragment before render and erase it from history**, as `/connect/` does, and hold the code in memory only: through email sign-in and back, never into storage. A fragment change on `/enroll` takes the new code the same way without reloading; elsewhere it does nothing. Provider sign-in leaves the page, so the user opens the link again. + +The page shows the code in the user-code role with "Approve only if Dormouse on your computer is showing this code right now." and offers Approve only within the recent-login window, Sign in again otherwise. The account page's Computers section lists each Burrow by its ID's first eight characters and enrollment date (the Relay keeps no name), with Remove. + +Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `cookieAdmin` in `hosted/server/account-gate.ts`; `ENROLLMENT_TTL_MS` / `RECENT_LOGIN_WINDOW` in `hosted/server/policy-constants.ts`; `takeEnrollment` in `hosted/src/enrollment.ts`; `App` in `hosted/src/App.tsx`; `enrollUserCode` in `remote-lib-common/src/remote/enroll-code.ts`; `MAX_ENROLLED_BURROWS` in `remote-lib-common/src/remote/relay-common.ts`; `relayBindings` in `hosted/server/bindings.ts`; `ratelimits` and `ACCOUNT_ORIGIN` in `hosted/wrangler.relay.jsonc` and `hosted/wrangler.jsonc`; `previewConfigs` in `hosted/scripts/preview.mjs`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `remote-lib-common/test/wire.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, 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 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 and Relay account 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, so its Relay routes answer 503 (`docs/specs/one-time.md` -> "Dev loop"). -**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Root `pnpm test` runs the `hosted/scripts/*.test.mjs` deploy suites and `test:miniflare`, the Docker-free Miniflare suites (the rendezvous, and the three Workers' boundary); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts` needing Docker. The test entry alone injects the packed Better Auth deterministic module. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. +**Must 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, Pocket's serving, and the three Workers' boundary); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts` and `relay.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`. @@ -104,17 +165,17 @@ Source of truth: `allowedDevRequest` in `hosted/server/dev-host-guard.ts`; `host **Must deploy only verified same-repository PR merge revisions touching Hosted or its shared build inputs.** Drafts qualify; forks receive no deployment credentials. Changed paths include rename sources and all API pages. Deployment runs serialize per PR without cancellation; close/merge cleanup ignores path filtering and tolerates absent resources. -**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive the account and voice share, and a Neon branch from an empty dedicated preview project**, all reused until close. Preview configs exclude production routes, triggers, and credentials; runtime bindings cannot enable OAuth, Postmark, or ElevenLabs. **Must give each relay preview its own Durable Object namespace and preview-only rate-limit namespaces**, and delete every preview Worker with `force`. The smoke checks what production's does ("Production releases"), with the captured-mail login in place of providers, retrying each part on its own. +**Must isolate each PR in three persistent workers.dev Workers (`dormouse-{hosted,relay,voice}-pr-N`), one uncached Hyperdrive all three share, and a Neon branch from an empty dedicated preview project**, all reused until 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, each preview preview-only rate-limit namespaces, and the relay preview an `ACCOUNT_ORIGIN` naming its account preview**, and delete every preview Worker with `force`. A preview's one secret (the account's `AUTH_SECRET`, the relay's `RELAY_ENROLL_SECRET`) is the HMAC of `PREVIEW_AUTH_SECRET` with its Worker's name (`previewSecrets`). 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` / `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`. +Source of truth: `touchesHosted` in `hosted/scripts/changed.mjs`; `.github/workflows/hosted-preview.yml`; `previewConfig` / `previewSecrets` / `prepare` / `cleanup` in `hosted/scripts/preview.mjs`; `smokeAll` in `hosted/scripts/preview-smoke.mjs`; `postgresInbox` in `hosted/server/preview-inbox.ts`; `hosted/server/preview-worker.ts`; `hosted/server/voice-preview-worker.ts`. Pinned by `hosted/scripts/preview.test.mjs`, `hosted/scripts/changed.test.mjs`, and `hosted/server/tests/workers.test.ts`. ## Production releases -**Must deploy only manually selected main revisions after Hosted tests/build and accepted clean package provenance.** `verifyPackages` checks both installed packages' clean, matching provenance; `productionConfig` holds each config to its Worker's 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 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, and the relay's `ACCOUNT_ORIGIN` to the account's origin; 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's `RELAY_ENROLL_SECRET`). Back up, encrypt, decrypt, and restore-test before applying migrations; upload only the encrypted archive. Deploy relay, voice, then account, stopping at a failure; the relay must pass its revision check and `oneTimeSmoke` before the next deploy (rationale). Production has no public candidate URL. **Must only append Durable Object migrations**: a deployed tag is never edited or removed, and Cloudflare refuses a rollback across one, so each is a rollback floor. The account keeps the `v1` that created `OneTimeRoom` and appends `v2` deleting it; the relay starts its own `v1`. A deploy restarts every room, dropping links still waiting or mid-handshake; a session already on its direct path never touches Hosted. Live verification checks each Worker's revision and runs `oneTimeSmoke` on the relay once the relay's revision check passes, whatever the account's outcome; a failed smoke reports every failed part. @@ -129,4 +190,4 @@ Source of truth: `.github/workflows/hosted-production.yml`; `productionConfig` / 1. Deploy the configured providers and pass real production acceptance. pgstencil includes the Microsoft fix; personal and work/school callbacks need acceptance. 2. Add per-browser login listing/revocation, sign-out-everywhere, and account recovery before broad paid use. Revisit the fixed 24-hour login lifetime for daily voice use. 3. Managed voice beyond the admin slice: a real entitlement or licence replacing `ADMIN_EMAIL`, credentials scoped for non-admin accounts, per-account quotas, usage accounting, and spending bounds beyond the fixed daily cap, and explicit text/redaction disclosure. -4. Hosted Relay: **saas-multitenant** in `docs/specs/relay.md` and **remote-network** in `docs/specs/remote-network.md`. Account login never replaces Burrow pairing and authorization. Paid security claims require independent review. +4. Hosted Relay beyond "Relay" and "Burrow enrollment": relay sockets and `online`, live sockets ending on removal, desktop enrollment, and push — **saas-multitenant** in `docs/specs/relay.md` and **remote-network** in `docs/specs/remote-network.md`. Account login never replaces Burrow pairing and authorization. Paid security claims require independent review. diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 40872bc0e..47faac9c6 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -25,3 +25,24 @@ Three origins (decided 2026-09-30): - 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. + +## Relay + +Caps (2026-09-30): + +- Passkeys per account (`MAX_PASSKEYS_PER_ACCOUNT`, 32): registration needs a setup token the account's own Burrow minted, so only the account grows its rows. A person registers one per phone or unsynced browser profile, and every `setup/begin` returns the whole list as `excludeCredentials`. A full account is refused rather than evicted, since evicting a passkey would sign a device out without saying so. +- Sessions per account (`MAX_SESSIONS_PER_ACCOUNT`, 32): each costs an assertion by one of the account's own passkeys, so only the account can spend it, and 32 is far above the browsers a person signs in from within the 12-hour lifetime. Evicting the oldest costs at worst one re-sign-in. +- Setup challenges are keyed by the Burrow whose token began them, which also makes a challenge unredeemable with another Burrow's token. The self-host Relay's single flat issuer was the accepted exception for one tenant; across accounts, a flat map would let one account evict another's live registration. +- Sign-in challenges stay flat: `signin/begin` has no caller to key on. The per-address limit, the two-minute expiry, and the hourly sweep bound them, rather than a global cap whose flood would evict every account's live sign-in. +- A capped write prunes only its own key's expired rows because a table-wide prune would make every unauthenticated request a full-table delete; the Cron Trigger sweeps the rest. + +Sweep interval (2026-09-30): hourly, not the voice sweep's five minutes. Each pass opens a Postgres connection, which wakes a suspended Neon compute; expired rows are refused on read whatever their age, so the sweep only bounds storage, and an hour of sign-in challenges is the per-address limit times an hour per address. + +Rate limits (2026-09-30): `signin/*` and `setup/begin`/`finish` are the unauthenticated routes that reach Postgres. A ceremony's two routes share one budget, so 30 a minute per address is 15 ceremonies, far above one person's retries and enough that a burst costs Postgres little. Like the one-time limits, they are keyed per address (an IPv6 /64), so they bound one caller, not a botnet. + +## Burrow enrollment + +- Begin stores nothing (2026-10-01): a stored request needed a cap, and `begin` is unauthenticated, so the cap could only be global, which a few /64s at the per-address limit could fill, locking every Burrow out of enrolling. With the expiry inside the device code and the user code derived from it, begin costs one HMAC and grows nothing. +- User code derivation: the HMAC key keeps the user code unpredictable from the device code, so no one can mint a device code for a code a victim is about to approve without about 30⁸ ≈ 6.6 × 10¹¹ begins, against 10 a minute per address. Two live device codes sharing a user code is a 40-bit collision; the approval then redeems for whichever polls first, as owned by the approver. Skipping the two 5-bit values past the alphabet keeps characters uniform; a 256-bit MAC holds 51 groups for 8 characters. +- Approvals table bound: the approval route is its only writer, authenticated, admin-only, and limited to 10 attempts a minute per account, each approval living 10 minutes, so one account holds at most about 100 live approvals. +- Guessing: approving a well-formed code no Burrow holds enrolls nothing; to steal a pending enrollment the account would have to be the one approving its code, which is the flow itself. diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index b066548c1..cfc6e7751 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -316,8 +316,8 @@ The relay Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): - **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 the relay origin: no storage, IndexedDB, - worker, push, or cookie. `applyPocketTheme` applies Pocket's +- **Never persist anything** from the page: no storage, IndexedDB, worker, + push, or cookie, though Pocket keeps its own on the same origin. `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 @@ -334,15 +334,17 @@ The relay Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): no module-preload polyfill) into `lib/dist-one-time`, then runs `assertPocketShell --one-time`: scripts under `/connect/assets/`, links under `/connect/`, nothing inline. Hosted's `build` runs it first, and -`hosted/scripts/stage-one-time.mjs` empties the relay's assets directory, -`hosted/dist/relay/`, copies it to `hosted/dist/relay/connect/`, and checks the copy. +`hosted/scripts/stage-relay.mjs` empties the relay's assets directory, +`hosted/dist/relay/`, copies it to `hosted/dist/relay/connect/` beside Pocket at +the root, and checks the copy. **Serving.** The relay Worker answers `/connect`, `/connect/`, and -`/connect/assets/*` from its assets, which have no SPA fallback; an HTML answer -under `/connect/assets/` and any other path, under `/connect/` or not, is a 404. +`/connect/assets/*` from its assets, with no SPA fallback; an HTML answer +under `/connect/assets/` and any other path under `/connect/` 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`: +replaced by `ws`); every other relay response carries Pocket's policy or +`RUNS_NOTHING_POLICY` (`docs/specs/security-hosted.md` -> "Relay boundary"): ``` default-src 'none'; script-src /connect/assets/ 'wasm-unsafe-eval'; @@ -363,13 +365,13 @@ Source of truth: `OneTimeApp` and `oneTimeDeviceLabel` in `takeOneTimeLinkUrl` / `reloadOnNewLink` in `lib/src/remote/one-time-app/take-link.ts`; `applyPocketTheme` 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`; `relayPolicy` in +`lib/scripts/assert-pocket-worker.mjs`; `stageRelay` in +`hosted/scripts/stage-relay.mjs`; `oneTimePageRoutes` in +`hosted/server/one-time.ts`; `relayRules` 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`, -`hosted/scripts/stage-one-time.test.mjs`, and +`hosted/scripts/stage-relay.test.mjs`, and `hosted/server/tests/one-time.test.ts`; `oneTimeSmoke` in `hosted/scripts/one-time-smoke.mjs` checks a deployment's page and script. @@ -380,7 +382,8 @@ 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 relay config's Durable Object, -migration, and rate limits, and carries no route or secret; `vite build --watch` +migration, and rate limits, and carries no route or production secret (its +`RELAY_ENROLL_SECRET` is the fixed, public `DEV_ENROLL_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/pocket-app.md b/docs/specs/pocket-app.md index 0268b2d67..3ebeaa129 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -82,6 +82,9 @@ lands** ([remote-security-model.md](./remote-security-model.md) → Pairing). * **A passkey the authenticator already holds outranks an empty store**: `excludeCredentials` refusing (`PasskeyAlreadyRegisteredError`) proves this device can sign in, so sign-in leads. (rationale) +* **A registered passkey is named `Dormouse Pocket ()`**, self-host and + Hosted alike, for the platform's passkey manager; the account id the Relay + answered is only its `user.id`. * **A refused token is reported, never folded away**: `SETUP_TOKEN_INVALID_ERROR` — expired, spent, or minted by a since-revoked Burrow — becomes `SetupTokenInvalidError`, whose message is the recovery: show a @@ -157,7 +160,8 @@ gate in `lib/src/remote/pocket-app/App.tsx`; `PairingCodeView` in `lib/src/remote/pocket-app/PocketWall.tsx`; `lib/src/remote/pocket-app/pair-link.ts`; `lib/src/remote/pocket-app/ScanInvitation.tsx`; `PocketClient.pair` in -`lib/src/remote/client/pocket-client.ts`; `RemotePtyAdapter` in +`lib/src/remote/client/pocket-client.ts`; `passkeyUserName` in +`lib/src/remote/client/webauthn.ts`; `RemotePtyAdapter` in `lib/src/remote/client/remote-adapter.ts`; `attachableDirectoryEntries` in `lib/src/remote/pocket-app/wall-model.ts`. @@ -476,9 +480,12 @@ previous build's hashed assets (rationale). Two rules make it hold: response's cache policy describes the response, and the shell is never a useful answer to a subresource miss. (rationale) -Source of truth: `registerPocketServing` in `relay/src/app.ts`. Both built HTML -shells are checked by `assertPocketShell` in -`lib/scripts/assert-pocket-worker.mjs`. +The Hosted Relay serves the same bundle by the same rules +(`docs/specs/hosted.md` -> "Relay"). + +Source of truth: `registerPocketServing` in `relay/src/app.ts`; `pocketRoutes` in +`hosted/server/pocket.ts`. Both built HTML shells are checked by +`assertPocketShell` in `lib/scripts/assert-pocket-worker.mjs`. ### The capability harness @@ -608,7 +615,7 @@ passkeys to the serving origin, and Chrome's Private Network Access rules block public-site → private-network fetches. Pocket holds itself to it by construction — an empty API base, a `wsBase` from `location.origin` — and the Relay enforces it: a registration or assertion whose `clientDataJSON.origin` is -not the configured `DORMOUSE_ORIGIN` is rejected ([relay.md](./relay.md); +not the configured origin is rejected ([relay.md](./relay.md); rationale); the Relay emits no cross-origin grant ([security-remote.md](./security-remote.md#cross-origin-access)). **The bundle mounts at the origin root, never under a path prefix**: the manifest's @@ -626,8 +633,8 @@ Every source is the app's own origin * **`style-src 'unsafe-inline'`**, because the shell carries a pre-paint `