Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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.

Expand All @@ -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:<port>` 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`.

Expand Down
40 changes: 17 additions & 23 deletions docs/specs/one-time.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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…"
Expand Down Expand Up @@ -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".
Expand All @@ -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=<roomId>`) 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 |
| --- | --- | --- |
Expand All @@ -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`).
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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']` |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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 |
Expand Down
Loading
Loading