diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 39c46374a..40df20063 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -13,9 +13,9 @@ | `dormouse-relay` | `https://relay.dormouse.sh` | the Hosted Relay, its sockets, and Pocket ("Relay", "Relay sockets"), the one-time rendezvous and `/connect/` (`docs/specs/one-time.md` -> "Hosted rendezvous") | Hyperdrive, `OneTimeRoom`, `RelayRoom`, the one-time, sign-in, setup, and enrollment rate limits, `ACCOUNT_ORIGIN`, `RELAY_ENROLL_SECRET`, the VAPID pair | | `dormouse-voice` | `https://voice.dormouse.sh` | speak and the history sweep ("Managed voice") | `ELEVENLABS_API_KEY`, Hyperdrive | -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). +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. Origin and binding isolation: `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. +Marketing deploys separately; esbuild and Wrangler compile `remote-lib-common` from source through `hosted/tsconfig.json` `paths`. **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. @@ -49,7 +49,7 @@ Source of truth: `hosted/server/providers.js`; `authPolicy` / `providerBindings` **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. +**Must inherit Dormouse product theme tokens before mounting React.** The OS light/dark preference selects bundled Light Visual Studio or Kimbie Dark. Type and touch sizing: `hosted/src/style.css`. It loads no marketing styles, fonts, or analytics. Source of truth: `App` in `hosted/src/App.tsx`; `restoreTheme` in `hosted/src/main.tsx`; `hosted/src/style.css`. @@ -148,7 +148,7 @@ The relay Worker serves `GET /ws/burrow` and `GET /ws/client` at the self-host p - **Must measure a text frame in UTF-8 bytes against `MAX_RELAY_FRAME_BYTES` before parsing it**, as the self-host `maxPayload` counts, closing 1009 past it; a binary frame is dropped. The Client cap is per account object. - **Must keep one alarm at the earlier of the earliest held Client's `expiresAt` and, while a Burrow socket is held, the next sweep**, at most `RELAY_ROOM_SWEEP_MS` (an hour) away. Each alarm closes every expired Client 1008 `unauthorized`, its Burrow told `client-gone`; then reads every held Burrow's row in one query and closes each removed or another account's 4001 (`WS_CLOSE_BURROW_REVOKED`) and each de-entitled 4002 (`WS_CLOSE_BURROW_NOT_ENTITLED`), its Clients told `burrow-gone`; then re-arms, or clears while nothing is held. An upgrade only brings the alarm earlier and a close leaves it. It replaces the self-host expiry and revocation sweeps. - **Must answer `RELAY_PING` with `RELAY_PONG` as the object's auto-response**, which never wakes it, reaches no handler, and is never forwarded. **A socket whose last auto-response is older than `RELAY_IDLE_TIMEOUT_MS` is gone**, retired with 1001; a socket that never pinged is never judged. It replaces the self-host heartbeat. Two gaps are accepted: a Burrow that never pings is never judged, since every build that can enroll with Hosted pings; and at the Client cap a backgrounded Pocket, its pings paused, counts as gone once silent that long — the cap is far above real use, and the Burrow reaps its session at 120 s anyway. -- **Must close a removed Burrow's socket from the account Worker's `DELETE /api/relay/burrows/:burrowId`**, through its `RELAY_ROOM` binding (`script_name` the relay) calling `closeBurrow`: 4001, its Clients `burrow-gone`. **Must answer 204 once the row is gone, even if the close fails**, logging it; the sweep closes the socket. No route reaches either RPC; `GET /api/burrows` reads `online` through `onlineBurrows`, beside its query. +- **Must close a removed Burrow's socket from the account Worker's `DELETE /api/relay/burrows/:burrowId`**, through its `RELAY_ROOM` binding (`script_name` the relay) calling `closeBurrow`: 4001, its Clients `burrow-gone`. **Must answer 204 once the row is gone, even if the close fails**, logging it; the sweep closes the socket. `GET /api/burrows` reads `online` through account-scoped `onlineBurrows`. **Never expose either RPC or `RelayRows` as a public endpoint.** Source of truth: `relaySocketRoutes` in `hosted/server/relay-sockets.ts`; `relayRoom` / `RELAY_ROOM_PARAMS` / `RELAY_ROOM_SWEEP_MS` / `RELAY_ROW_READ_TIMEOUT_MS` in `hosted/server/relay-room-contract.ts`; `RelayRoom` in `hosted/server/relay-room.ts`; `RelayRows` in `hosted/server/relay-rows.ts`; `forwardUpgrade` / `refuseSocket` in `hosted/server/socket-room.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `exceedsRelayFrameBytes` in `remote-lib-common/src/remote/relay-routing.ts`; `durable_objects` and `migrations` in `hosted/wrangler.relay.jsonc` and `hosted/wrangler.jsonc`. Pinned by `hosted/server/tests/relay-room.test.ts`, which also runs the routing cases every Relay passes (`remote-lib-common/test/harness/relay-parity.mjs`), and `hosted/server/tests/workers.test.ts`. @@ -179,7 +179,7 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that 403 f - **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, **then close its live socket** ("Relay sockets"). -- **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. +- **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 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. @@ -189,7 +189,7 @@ Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccount **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 suites (the rendezvous, Pocket's serving, the three Workers' boundary, and push egress); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts`, `relay.test.ts`, and `relay-room.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 suites (the rendezvous, Pocket's serving, the three Workers' boundary, and push egress); the rest of `pnpm test:hosted`'s vitest half runs only there, `workers.test.ts`, `relay.test.ts`, and `relay-room.test.ts` needing Docker. Only test entries inject deterministic Better Auth. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery. **Must 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`. diff --git a/docs/specs/one-time.md b/docs/specs/one-time.md index acc0f8835..aa07ed878 100644 --- a/docs/specs/one-time.md +++ b/docs/specs/one-time.md @@ -1,13 +1,7 @@ # One-time connection -> See `docs/specs/glossary.md` for Burrow, Client, Relay, Pane, and Baseboard vocabulary; this spec uses them bare. -> Owns the one-time connection: the link a Burrow shows, the Hosted rendezvous that carries only its handshake, and the direct-only session that follows. Defers the ceremony's trust rules to `docs/specs/remote-security-model.md` -> "One-time connection" and the audited checks to `docs/specs/security-remote.md` -> "One-time connection" and `docs/specs/security-hosted.md` -> "Rendezvous boundary". - -A phone reaches a laptop with no account, no Relay, and no passkey: the laptop -shows a link, the phone opens it, a person types on the laptop the two digits the -phone shows, and the session runs over a direct WebRTC path the network policy -allows (`docs/specs/remote-network.md`). -Hosted's rendezvous carries the handshake and nothing after it. +> See `docs/specs/glossary.md` for Burrow, Client, Relay, Pane, and Baseboard vocabulary. +> Owns one-time links, Hosted rendezvous, and direct-only sessions. Trust rules: `docs/specs/remote-security-model.md` -> "One-time connection"; audited checks: `docs/specs/security-remote.md` -> "One-time connection" and `docs/specs/security-hosted.md` -> "Rendezvous boundary". ## Flow @@ -20,8 +14,8 @@ Hosted's rendezvous carries the handshake and nothing after it. ([Laptop UI](#laptop-ui)). 4. The phone page takes and erases the fragment, then waits for the Connect tap ([Phone page](#phone-page)). -5. The tap runs `connectOnce` ([Phone client](#phone-client)), and its first - message 1 reserves the link ([Burrow runtime](#burrow-runtime)). +5. The tap runs `connectOnce` ([Phone client](#phone-client)), which completes the + handshake ([Burrow runtime](#burrow-runtime)). 6. The page shows the two digits; the laptop's approval modal takes the one attempt. 7. A match promotes the session, and the page shows "Connecting directly…" @@ -57,13 +51,10 @@ reaches a server. `docs/specs/relay.md` -> "Setup tokens and the pairing QR" states, with the path exactly `ONE_TIME_PAGE_PATH` (`/connect/`) and the fragment right after `#`; then the field rules, the expiry, and the X25519 import last. -- **A link is live through its expiry second** (`oneTimeLinkExpired`), and the +- **Must keep a link live through `expiry * 1000`, expiring the following millisecond** (`oneTimeLinkExpired`), and the parser refuses on that same rule. A caller that must tell an expired link from a wrong one parses at `now = 0` and asks `oneTimeLinkExpired`. -- The prologue is `lengthPrefixedConcat` of `dormouse/e2e/v1`, `one-time`, - `roomId`, then `v`, `expiry`, `ephPub` in link order, from one builder both - ends call (the rule: `docs/specs/remote-security-model.md` -> "One-time - connection"). +- The prologue contract: `docs/specs/remote-security-model.md` -> "One-time connection". Which desktop release may carry a link version: `docs/specs/deploy.md` -> "Release checklist". @@ -80,7 +71,7 @@ Source of truth: `formatOneTimeLinkUrl` / `parseOneTimeLinkUrl` / `BurrowRuntime` has a reader for it. Two WebSocket routes on the one-time origin: `ONE_TIME_WS_ROUTES.burrow` (`/api/one-time/burrow`) mints a room, and `ONE_TIME_WS_ROUTES.client` (`/api/one-time/client?room=`) joins one. -Each message is one JSON frame with exact keys, forwarded verbatim by the room: +**Must send each message as one JSON frame with exact keys**, forwarded verbatim by the room: | Frame | Direction | Shape | | --- | --- | --- | @@ -104,7 +95,7 @@ no deadline. inside the room's hard deadline, `expiresAt + ONE_TIME_EXPIRY_GRACE_MS` (45 s). - **The ceremony's messages are padded `control` messages** on the Noise session (`docs/specs/relay.md` -> "E2E framing"): `OneTimeRequestV1 {code, label}` - phone → Burrow, `label` one of `ONE_TIME_DEVICE_LABELS`, then one + phone → Burrow, the page sending a `label` from `ONE_TIME_DEVICE_LABELS`, then one `OneTimeOutcomeV1` — `{ok: true, burrowLabel}`, or `{ok: false, code}` with `code` one of `ONE_TIME_DENIAL_CODES` (`user-denied`, `confirmation-mismatch`, `link-expired`, `burrow-error`). @@ -120,7 +111,8 @@ The room closes a socket with one of six codes, each exported with a `_REASON`: | 4014 | `WS_CLOSE_ONE_TIME_DEADLINE` | a phone joined, and the hard deadline passed | | 4015 | `WS_CLOSE_ONE_TIME_VIOLATION` | a binary frame, one over the length bound, or one past the message cap | -Source of truth: `remote-lib-common/src/remote/one-time-wire.ts`; +Source of truth: `OneTimeRoomFrame` / `OneTimeClientFrame` / `OneTimeBurrowFrame` +and their guards in `remote-lib-common/src/remote/one-time-wire.ts`; `parseOneTimeFrame` in `lib/src/remote/one-time-rendezvous.ts`; `OneTimeRequestV1` / `OneTimeOutcomeV1` in `remote-lib-common/src/security/e2e-ceremony.ts`. Pinned by @@ -140,7 +132,7 @@ resumes. | `confirming {label, expiresAt}` | a phone's request awaits the approval modal | | `connecting {label}` | confirmed; the direct path has `DIRECT_ONLY_DEADLINE_MS` | | `connected {label, since}` | both directions are direct and the rendezvous is closed | -| `ended {reason}` | terminal | +| `ended {reason, refusal?}` | terminal; `refusal` is `network-not-allowed`'s alone | `unavailable {reason}` and `idle` complete the type for the service, which decides them; a runtime never enters either. @@ -155,6 +147,8 @@ decides them; a runtime never enters either. stops reading the room. Frames run through one FIFO, one at a time, and **the socket's close rides the same FIFO**, so a phone's `direct-switch` is read before the room's report that the phone left. +- **Must reserve the link and erase its key only after message 1, message 2, and + Noise Split succeed; a failed handshake leaves it live.** - **Must spend an `E2E_INIT_BURST` `TokenBucket` token before an `init`'s WebCrypto.** The first non-keepalive transport message must be `OneTimeRequestV1`, else `burrow-error`. **Its label passes @@ -236,7 +230,7 @@ Every failure resolves `{ok: false, message}` with fixed copy: | an expired link, room close `4010` or `4014`, any other close before an outcome past the link's expiry, or no answer by the room's deadline | `ONE_TIME_LINK_EXPIRED_MESSAGE` | | between an `ok` outcome and the switch: a decline, a lost session, the deadline, or any other close | `ONE_TIME_DIRECT_FAILED_MESSAGE` | | between an `ok` outcome and the switch: the laptop's goodbye | `networkNotAllowedMessage` where it names the phone's address, `ONE_TIME_DIRECT_FAILED_MESSAGE` where it names the path and no address, else `ONE_TIME_ENDED_MESSAGE` | -| a socket that never opened | `ONE_TIME_UNREACHABLE_MESSAGE` | +| a socket refused or lost before opening | `ONE_TIME_UNREACHABLE_MESSAGE` | | any other close, `close()`, or a session lost between the switch and the resolve | `ONE_TIME_ENDED_MESSAGE` | | a payload on message 2, or an outcome its guard refuses | `ONE_TIME_DENIAL_MESSAGES['burrow-error']` | @@ -322,7 +316,7 @@ The relay Worker serves the phone's half at `ONE_TIME_PAGE_PATH` (`/connect/`): 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 +- **Must mount the wall only on `ok`**, through `mountRemoteWall`. End, Cancel, a reported ending, a failed mount, or a failed attachment closes the client and releases the adapter; the first ending's copy stays. - **The label the page sends is `oneTimeDeviceLabel`, never Pocket's @@ -369,7 +363,7 @@ Source of truth: `OneTimeApp` and `oneTimeDeviceLabel` in `lib/src/remote/pocket-app/pocket-theme.ts`; `assertPocketShell` 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/one-time.ts`; `relayRules` / `oneTimePagePolicy` 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`, @@ -462,7 +456,7 @@ sits in the Baseboard's right cluster (`docs/specs/layout.md` -> "Baseboard"). | State | The panel shows | Actions | | --- | --- | --- | -| `idle` | the **One-time connection** button; "Open a link on your phone for a one-off connection. Your phone must be on an allowed network. No account needed." | the button opens | +| `idle` | the **One-time connection** button; "Open a link on your phone for a one-off connection. Your phone must be on an allowed network. No account needed.", its middle sentence "Your phone can be on any network." under `phoneOnAnyNetwork` | the button opens | | `unavailable` | the button disabled, the reason's copy for the hint | — | | `opening` | "Getting a link…" | Cancel | | `waiting` | the link as a QR code and as selectable text; "Good for one phone. Expires in N min." | Copy link, New link, Cancel | diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 2186a578a..1bd9538c8 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -2,12 +2,12 @@ > See `docs/specs/glossary.md` for Burrow, Client, and Relay vocabulary. > 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. +> Read `docs/specs/security.md` first; live production controls and real-provider acceptance still require verification. ## Origin boundary - **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 `cookieAdmin` in `hosted/server/account-gate.ts` and the packed adapter. +- **FAIL IF** a cookie route admits any presented `Origin` but its own exactly, sibling origins under `dormouse.sh` included, or a state-changing cookie request lacks that Origin, a state-changing auth request skips the CSRF check, or any Worker grants credentialed CORS; inspect `cookieAdmin` in `hosted/server/account-gate.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 account's or relay's passes `ELEVENLABS_API_KEY`, the account's or voice's passes `RELAY_ENROLL_SECRET` or `RELAY_VAPID_PRIVATE_KEY`, or the relay or voice entry imports Better Auth; inspect `hosted/server/bindings.ts` and each entry's import graph. - **FAIL IF** authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`. - **FAIL IF** the account origin's policy permits third-party scripts, framing, inline script execution, or any worker (`worker-src 'none'`); a voice response, or a relay response under a `RELAY_NON_PAGE_PREFIXES` prefix (`/api`, `/ws`), carries any policy but `RUNS_NOTHING_POLICY`; any response but a 101 WebSocket upgrade bypasses `secureHeaders`, a misconfigured deployment's error included; or a response is cached past its class: immutable only for a content-hashed file under the account's `/assets/` or the relay's `/assets/` and `/connect/assets/`, `no-cache` only on Pocket's other paths, `no-store` everywhere else, the account's SPA shell included. Inspect `secureHeaders` / `accountRules` / `relayRules` / `relayPathKind` in `hosted/server/headers.ts`, binding resolution in `hosted/server/worker-app.ts`, and asset routing in `hosted/wrangler.jsonc` and `hosted/wrangler.relay.jsonc`. @@ -62,15 +62,13 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy. - **FAIL IF** `RelayRoom` stores, logs, or decodes a frame or its `ct`, reads a frame other than through the shared frame layer (`readClientFrame`, `readBurrowFrame`), which parses only the routing envelope through the shared guards and copies `ct` field by field and reads it nowhere else, parses a frame before measuring its UTF-8 bytes against the shared `MAX_RELAY_FRAME_BYTES`, or stores anything but its account id; `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules. - **FAIL IF** a `RelayRoom` is named from anything but the account an authenticated token resolved to, or serves a request or RPC naming an account other than the one it stored. - **FAIL IF** the client socket upgrade admits an `Origin` other than exactly the relay's `APP_ORIGIN`, the Burrow upgrade admits any `Origin`, either reaches the object before its token resolves to a live session or Burrow of an entitled account, or the Worker hands the object any header, token, or parameter of the caller's. -- **FAIL IF** the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`, so a stalled database resets every socket of the account; a removed Burrow's live socket outlives its removal's `closeBurrow`, or a removed or de-entitled Burrow's outlives the next sweep, at most `RELAY_ROOM_SWEEP_MS` while any Burrow socket is held; or a route reaches `closeBurrow`, `onlineBurrows`, `RelayRows`, or any other `RelayRoom` method but the two upgrades. +- **FAIL IF** the object accepts a Burrow socket without rechecking its row (enrolled, the account's, owner entitled) under `blockConcurrencyWhile`, or awaits any row read past `RELAY_ROW_READ_TIMEOUT_MS`; a removed Burrow's live socket outlives its removal's `closeBurrow`, or a removed or de-entitled Burrow's outlives the next sweep, at most `RELAY_ROOM_SWEEP_MS` while any Burrow socket is held; `RelayRoom.fetch` exposes non-upgrade routes; or a public route reaches `RelayRows` or any `RelayRoom` method but the two upgrades, except authenticated, account-scoped `closeBurrow` on account-Worker removal and `onlineBurrows` on relay-Worker `GET /api/burrows`. - **FAIL IF** a Pocket path's response lacks Pocket's policy (`pocketContentSecurityPolicy` in `remote-lib-common/src/remote/relay-common.ts`, taken only from an `APP_ORIGIN` that is exactly an origin), any response but a Pocket path's allows the camera, `/connect/` included, or a response is classified on any path but the decoded one Hono routes on, so `/%63onnect/` would take Pocket's; inspect `relayRules` / `relayPathKind` and `secureHeaders` in `hosted/server/headers.ts`. Pinned by `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-push.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/pocket.test.ts`, `hosted/server/tests/boundary.test.ts`, and `remote-lib-common/test/web-push.test.mjs`. ## Deployment boundary -**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`, `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. @@ -84,6 +82,6 @@ Pinned by `hosted/server/tests/artifacts.test.ts`, `hosted/server/tests/workers. ## Future -**Production activation**, not checked until Hosted is provisioned: the live Hyperdrive and role values that `preflight` reads, and Cloudflare script injection excluded for the Hosted hostname (`hosted/README.md`). Checked-in placeholders prove none of them. +**Live production acceptance**: verify Hyperdrive and role values that `preflight` reads, and Cloudflare script injection excluded for the Hosted hostname (`hosted/README.md`). Recorded configuration proves neither live controls nor browser acceptance. -Public hosted voice and Relay need their own abuse, authorization, data-disclosure, and recovery checks first, and paid use an independent review of the remote model; `docs/specs/hosted.md` owns the staged work. +Public voice and Relay need abuse, authorization, data-disclosure, and recovery checks, and paid use independent remote-model review; `docs/specs/hosted.md` owns the staged work. diff --git a/hosted/README.md b/hosted/README.md index d5c927b3a..80e925cc5 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -7,8 +7,9 @@ connection's rendezvous and `/connect/` phone page at `https://relay.dormouse.sh` (`dormouse-relay`; [the one-time spec](../docs/specs/one-time.md)), and managed-voice speech at `https://voice.dormouse.sh` (`dormouse-voice`). The marketing website is a separate application. Managed voice and the Relay -admit only the admin account; the Relay enrolls no Burrow and carries no -terminal traffic yet. See +admit only the admin account; device-code Burrow enrollment and account-scoped +encrypted terminal routing are implemented. Real-provider and live production +acceptance remain separate release gates. See [the spec](../docs/specs/hosted.md), whose "Application boundary" owns what each Worker serves. @@ -82,8 +83,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`, `dormouse-relay-pr-N` (with its own `OneTimeRoom` Durable Object namespace), and `dormouse-voice-pr-N` Workers, one uncached Hyperdrive all three share, and a Neon branch, all reused until close; rate-limit namespaces `1001`–`1007` shared by every relay and account preview | -| Production | Dedicated Dormouse Postgres database, separate runtime/migration roles, uncached Hyperdrive shared by all three Workers; Workers `dormouse-hosted` (`hosted.dormouse.sh`, with rate-limit namespace `7`), `dormouse-relay` (`relay.dormouse.sh`, with its `OneTimeRoom` Durable Object namespace and rate-limit namespaces `1`–`6`), and `dormouse-voice` (`voice.dormouse.sh`, with the history-sweep Cron Trigger), each on its custom domain; GitHub `hosted-production` environment | +| Each PR | `dormouse-hosted-pr-N`, `dormouse-relay-pr-N` (with its own `OneTimeRoom` and `RelayRoom` Durable Object namespaces), and `dormouse-voice-pr-N` Workers, one uncached Hyperdrive all three share, and a Neon branch, all reused until close; rate-limit namespaces `1001`–`1007` shared by every relay and account preview | +| Production | Dedicated Dormouse Postgres database, separate runtime/migration roles, uncached Hyperdrive shared by all three Workers; Workers `dormouse-hosted` (`hosted.dormouse.sh`, with rate-limit namespace `7`), `dormouse-relay` (`relay.dormouse.sh`, with its `OneTimeRoom` and `RelayRoom` Durable Object namespaces and rate-limit namespaces `1`–`6`), and `dormouse-voice` (`voice.dormouse.sh`, with the history-sweep 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 | @@ -196,9 +197,9 @@ accounts. 2. Create a Cloudflare Hyperdrive configuration for that database with **query 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`, - `wrangler.relay.jsonc`, and `wrangler.voice.jsonc` with the resulting public ID for local operator - deployment. CI overrides both with the `HYPERDRIVE_ID` variable. + directly in Cloudflare; set the resulting public Hyperdrive ID in `wrangler.jsonc`, + `wrangler.relay.jsonc`, and `wrangler.voice.jsonc` for local operator + deployment. CI overrides all three 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 diff --git a/hosted/server/account-gate.ts b/hosted/server/account-gate.ts index 2ff373f4d..d1ec0308f 100644 --- a/hosted/server/account-gate.ts +++ b/hosted/server/account-gate.ts @@ -29,11 +29,12 @@ export interface AccountLogin { } /** - * The account Worker's cookie routes' gate: a state-changing request carries - * exactly this origin — same-site pages, the relay and voice origins among - * them, share the login cookie — then the Better Auth handler's `get-session` - * answers the login (401 without one), and only the verified admin passes - * (`refuse` answers anyone else). Sets `login`. + * The account Worker's cookie routes' gate: a presented `Origin` is exactly + * this origin, and a state-changing request must present one — same-site + * pages, the relay and voice origins among them, share the login cookie — + * then the Better Auth handler's `get-session` answers the login (401 + * without one), and only the verified admin passes (`refuse` answers anyone + * else). Sets `login`. */ export function cookieAdmin( host: (c: Context) => AccountHost, @@ -41,11 +42,10 @@ export function cookieAdmin( ): MiddlewareHandler<{ Variables: { login: AccountLogin } }> { return async (c, next) => { const origin = new URL(c.req.url).origin; - if ( - c.req.method !== "GET" && - c.req.method !== "HEAD" && - c.req.header("origin") !== origin - ) + const presented = c.req.header("origin"); + // A read may omit `Origin`; nothing may present a foreign one. + const safe = c.req.method === "GET" || c.req.method === "HEAD"; + if (presented === undefined ? !safe : presented !== origin) return c.json({ message: "Invalid origin." }, 403); const headers = new Headers(); for (const name of ["cookie", "cf-connecting-ip"]) { diff --git a/hosted/server/tests/boundary.test.ts b/hosted/server/tests/boundary.test.ts index 4b7fa0629..32556980d 100644 --- a/hosted/server/tests/boundary.test.ts +++ b/hosted/server/tests/boundary.test.ts @@ -496,6 +496,30 @@ test("the relay bundle reads no cookie and never asks auth", () => { expect(readFileSync("server/account-gate.ts", "utf8")).toMatch(/["'`]cookie["'`]/); }); +test("the cookie gate refuses a presented foreign Origin on every method", async () => { + const origin = "https://account.example.test"; + const app = new Hono(); + const gate = cookieAdmin( + () => ({ + databaseUrl: "postgres://user:pass@127.0.0.1:9/none", + auth: async () => + Response.json({ user: { id: "admin", email: ADMIN_EMAIL, emailVerified: true }, session: {} }), + }), + () => new Response(null, { status: 403 }), + ); + app.on(["GET", "HEAD", "POST"], "/gated", gate, (c) => c.body(null, 204)); + const sibling = "https://relay.example.test"; + for (const method of ["GET", "HEAD", "POST"]) { + const send = (headers: Record) => + app.request(`${origin}/gated`, { method, headers }); + expect((await send({ origin: sibling })).status, method).toBe(403); + expect((await send({ origin: "null" })).status, method).toBe(403); + expect((await send({ origin })).status, method).toBe(204); + // Only a read may omit `Origin`. + expect((await send({})).status, method).toBe(method === "POST" ? 403 : 204); + } +}); + test("the cookie gate needs no login creation time; approval reads it and fails closed", async () => { // `get-session` as the packed adapter answers it, `createdAt` as given. const host = (createdAt?: unknown) => () => ({