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
18 changes: 8 additions & 10 deletions .github/audit/canaries/passkey-removal-keeps-sessions.patch
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ index 319cd5c41..b5300f378 100644
-- Down Migration
-ALTER TABLE dormouse_relay_sessions ALTER COLUMN "credentialId" DROP NOT NULL;
diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts
index a68a75d36..d6f13c53a 100644
index 0ad1a0fc3..67e0c5ad5 100644
--- a/hosted/server/relay-api.ts
+++ b/hosted/server/relay-api.ts
@@ -746,17 +746,14 @@ export async function admit(
@@ -762,17 +762,14 @@ export async function admit(
});
}

Expand All @@ -45,10 +45,10 @@ index a68a75d36..d6f13c53a 100644
);
return { sessionToken, expiresAt: expiresAt! };
diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts
index 09806ad75..cb7005345 100644
index 8670a8a38..a465c8d79 100644
--- a/hosted/server/tests/migrations.test.ts
+++ b/hosted/server/tests/migrations.test.ts
@@ -32,9 +32,9 @@ const PINNED: Record<string, string> = {
@@ -32,7 +32,7 @@ const PINNED: Record<string, string> = {
"009_relay_passkey_removal.sql":
"c6c220960187e33a1350dc85e93d24f99866a72a579ed9def6a3663643f3e928",
"010_relay_session_credential_required.sql":
Expand All @@ -57,9 +57,7 @@ index 09806ad75..cb7005345 100644
"011_enrollment_confirm_digits.sql":
"54afe9ae8a2efd3c654503d479fce96c1ce8ff4f2df249aa93b5a91e925def6e",
"012_relay_burrow_label.sql":
"45b3df1530402622edeb12e12e1572698840ecc8ea6b9cfc585f3a9e28b4f230",
};
@@ -49,7 +49,7 @@ test("no merged Dormouse migration changes, and every one is pinned", () => {
@@ -53,7 +53,7 @@ test("no merged Dormouse migration changes, and every one is pinned", () => {
}
});

Expand All @@ -68,17 +66,17 @@ index 09806ad75..cb7005345 100644
onTestFinished,
}) => {
// The Dormouse migrations through 009, then 010 over the rows they allowed.
@@ -75,5 +75,4 @@ test("010 deletes the sessions that name no passkey, and every later one must na
@@ -79,5 +79,4 @@ test("010 deletes the sessions that name no passkey, and every later one must na
copyFileSync(join(source, "010_relay_session_credential_required.sql"), join(before, "010_relay_session_credential_required.sql"));
await migrate(context.database.url, await readMigrations([betterAuthMigrations, before]));
expect(await sql(`SELECT "tokenHash" FROM dormouse_relay_sessions`)).toEqual([{ tokenHash: "named" }]);
- await expect(session("later", null)).rejects.toThrow(/credentialId/);
});
diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts
index 424ccd2ba..3441c7e86 100644
index 86d1036ad..c7660b441 100644
--- a/hosted/server/tests/relay.test.ts
+++ b/hosted/server/tests/relay.test.ts
@@ -456,32 +456,20 @@ test("a passkey names the computer whose code registered it and outlives it; rem
@@ -472,32 +472,20 @@ test("a passkey names the computer whose code registered it and outlives it; rem
expect(await minter(phone)).toEqual([{ burrowId: null }]);
expect((await f.call("GET", API_ROUTES.burrows, { bearer: setupSession })).status).toBe(200);

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A multitasking terminal for VS Code and the desktop — a real tiling layout, tm
- [Compatible agents](https://dormouse.sh/compatible-agents) — conversation recovery, watching, and contributing an agent integration
- [Agent skill](https://dormouse.sh/agent-skill) — the operating guide Dormouse bundles for coding agents
- [Self-host](https://dormouse.sh/self-host) — run the coordinating Relay on your own tailnet
- [Hosted](https://dormouse.sh/hosted/) — the Hosted Relay and Natural Voice, from $10 a month
- [Hosted](https://dormouse.sh/hosted/?ref=readme) — the Hosted Relay and Natural Voice, from $10 a month
- [Security](https://dormouse.sh/security) — what Dormouse guarantees, what it does not, and how that is checked

## Features
Expand Down
21 changes: 20 additions & 1 deletion docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Every Worker answers `/api/health` and `/api/ready`, 404s anything else under it

**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, one per Worker.

**Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user columns, and the relay inserts approved voice tokens ("Managed voice"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`).
**Must grant `dormouse_relay` and `dormouse_voice` only what `hosted/server/runtime-roles.sql` lists, never default privileges**, so a new table needs a grant there; each reaches only its Worker's tables and the entitlement's user columns, the relay inserts approved voice tokens ("Managed voice"), and both add to the daily counts ("Metrics"). **Must bind each production Worker to its own Hyperdrive connecting as its role**: account `dormouse_app`, relay `dormouse_relay`, voice `dormouse_voice` (`PRODUCTION` in `hosted/scripts/production.mjs`).

**Must install released core/auth packages from npm and commit their lockfile integrity hashes.** The installed packages' `dist/provenance.json` must name the same clean pgstencil commit; no runtime import depends on a sibling checkout. The auth migrations remain owned by the package; Dormouse's own tables migrate from `hosted/server/dormouse-migrations/`. **Never edit a merged migration**: a migrated database never reruns one, so append the next number (pinned by `hosted/server/tests/migrations.test.ts`).

Expand Down Expand Up @@ -243,6 +243,25 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that their

Source of truth: `relayApiRoutes` in `hosted/server/relay-api.ts`; `relayAccountRoutes` in `hosted/server/relay-account.ts`; `takeEnrollment` in `hosted/src/enrollment.ts`.

## Metrics

Hosted counts what its Workers serve in aggregate daily rows, server-side; no page or desktop sends any.

| Event | Labels | Counted when |
|---|---|---|
| `enroll.approved` | `remote`, `voice` | an approval succeeds, under its purpose |
| `burrow.enrolled` | none | a remote poll enrolls a Burrow |
| `voice.speak` | `ok`, `capped`, `error` | speak answers 200, 429, or 502 |
| `voice.fallback-cap` | none | a speak spends its owner's last call of the UTC day |
| `push.sent` | none | a send delivers, once per delivery |
| `pocket.signin` | none | a passkey sign-in mints a session |

- **Never count a label off its event's list** (`METRIC_LABELS`): the write throws instead. What a row may hold, and who reads it: `docs/specs/security-hosted.md` -> "Account boundary".
- **Never fail or delay the request a metric counts**: the write runs after the response, through `waitUntil`, and its failure is only logged.
- Rows are kept forever, since none describes anyone. The account Worker's `/admin/metrics` shows the last 30 UTC days and all-time totals, "today" by the database's clock, which dates every row.

Source of truth: `hosted/server/metric-labels.ts`; `recordMetric` in `hosted/server/metrics.ts`; `hosted/server/dormouse-migrations/013_metrics.sql`; `AdminMetrics` in `hosted/src/AdminMetrics.tsx`.

## Development and release

**Must run local development with `dor tool hosted` inside Dormouse.** Its loopback `http://localhost:<port>` origin serves Vite and Node auth on a disposable development database, with the account's Natural Voice and Relay routes but never the voice origin's. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. **It also runs the relay Worker on that database, with this origin as `ACCOUNT_ORIGIN`** (`docs/specs/one-time.md` -> "Dev loop").
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

**Settings is the front door.** The spoken-alarm row's managed-voice link lands on `/hosted#voice`, and the Pocket playground tutorial on `/hosted#remote-control`. `#pricing` (the plan cards, which each JSON-LD `Offer` url names), `#remote-control`, and `#voice` keep resolving as section ids.

**Must give every link to the page a `?ref=` from `HOSTED_REFS`, one per link, ahead of its fragment, and strip it from the address bar on arrival**, so a copied link never carries it. Nothing counts or sends it yet; `takeVisitRef` answers it, an unlisted value as `other`, for whatever will. Source of truth: `lib/src/lib/hosted-links.ts`; `takeVisitRef` in `website/src/lib/hosted-ref.ts`.

**Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers stay `PreOrder` while checkout is unbuilt.**

**Every price on the site has one owner**, `website/src/lib/hosted-pricing.ts`; site code reads it rather than restating a number.
Expand Down
9 changes: 5 additions & 4 deletions docs/specs/security-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
| **Each Hosted Worker answers only its own origin and runs only its own code.** No third-party script, analytics, or framing; sign-in cookies stay on the account origin, and its session never reaches browser storage. | One-time link, Hosted phone | `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.test.ts` |

- **FAIL IF** `S77` 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** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale).
- **FAIL IF** `S78` an account cookie request — a cookie route's, or the auth handler's but a provider callback or `/api/auth/csrf` — presents an `Origin` but its own exactly, sibling origins under `dormouse.sh` included; is a read (`GET` or `HEAD`; to auth, anything but `POST`) that reaches its handler without `Sec-Fetch-Site: same-origin` rather than answering 403, so no header, `none`, and a sibling's `same-site` are refused; is state-changing and lacks that Origin, or to auth skips the CSRF check; is an auth read that skips `prelaunchAuth`'s lookup, or reaches it ungated; or any Worker grants credentialed CORS. Inspect `cookieEntitled` / `cookieAdmin` / `ownPageRead` / `openAuthRead` in `hosted/server/account-gate.ts`, their use in `accountApp` in `hosted/server/account-app.ts` and `prelaunchAuth` in `hosted/server/prelaunch.ts`, and the packed adapter (rationale).
- **FAIL IF** `S79` 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, or `WORKERS` in `hosted/scripts/workers.mjs` provisions either an auth secret; inspect `hosted/server/bindings.ts` and each entry's import graph.
- **FAIL IF** `S80` authentication cookies have a Domain attribute, lack `__Host-`, Secure, HttpOnly, or Path=/ in HTTPS, or account (Better Auth) session tokens appear in browser JSON or persistent browser storage; inspect the adapter and `hosted/src/api.ts`.
- **FAIL IF** `S81` 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`.
Expand All @@ -24,14 +24,15 @@ Pinned by `hosted/server/tests/boundary.test.ts`, `hosted/server/tests/workers.t

| Guarantee | Feature | Pinned by |
| --- | --- | --- |
| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts` |
| **A Hosted account signs you in and grants nothing more.** Login never mints terminal access or stands in for pairing and presence; only an entitled account reaches managed voice, whose Worker neither logs nor stores the text it speaks. Hosted's own counts are daily totals that name no one, and only the admin reads them. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts` |

- **FAIL IF** `S83` the consumer changes `authPolicy` away from explicit linking or multiple independent logins, or accepts an explicit connection callback after its initiating login was revoked; inspect `hosted/server/policy.ts` and the packed adapter.
- **FAIL IF** `S84` an unused provider credential enables login, an unknown provider name is accepted, or incomplete enabled credentials silently degrade; inspect `providerBindings` in `hosted/server/policy.ts`.
- **FAIL IF** `S85` speak or a voice token's mint admits an account the entitlement (`docs/specs/hosted.md` -> "Entitlement") does not, without rechecking per request, stores a voice token other than as its SHA-256, accepts one idle past `VOICE_TOKEN_IDLE_DAYS`, lets a bearer delete any token but its own, logs or stores speak text, forwards an ElevenLabs error body or status, or lets a binding or request field choose the upstream origin, path prefix, model, or format, or a voice outside `MANAGED_VOICES`; or the relay mints a voice token without redeeming a voice-purpose approval, or remote enrollment mints voice credentials; inspect `hosted/server/entitlement.ts`, `hosted/server/voice.ts`, and `hosted/server/relay-api.ts`.
- **FAIL IF** `S278` a `dormouse_metrics_daily` row can hold anything but a UTC day, an event, a label on that event's `METRIC_LABELS` list, and a count; or `GET /api/admin/metrics` answers anyone but the verified `ADMIN_EMAIL` (401 without a login, 404 to any other), skips the cookie read gate, or answers more than those rows. Inspect `hosted/server/dormouse-migrations/013_metrics.sql`, `countMetric` in `hosted/server/metrics.ts`, `metricLabel` in `hosted/server/metric-labels.ts`, `cookieAdmin` in `hosted/server/account-gate.ts`, and `adminSql` in `hosted/server/entitlement.ts`.
- **FAIL IF** `S86` Hosted account login mints a Burrow ACL grant or substitutes for the existing encrypted pairing/presence proof. The one-time rendezvous carries only handshake ciphertext and authorizes nothing; the ends' handshake and the laptop's confirmation do.

What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended". Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy.test.ts`.
What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended". Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, and `hosted/server/tests/metrics.test.ts`.

## Rendezvous boundary

Expand Down Expand Up @@ -76,7 +77,7 @@ What ElevenLabs keeps of it is `docs/specs/security.md` -> "What is not defended
- **FAIL IF** `S107` a push endpoint is registered or fetched that `knownPushEndpoint` does not admit (`https:`, default port, no credentials, a listed push service's host), or a delivery follows a redirect or keeps more than 1 KiB of a response body; inspect `deliverPush` and `reasonOf`.
- **FAIL IF** `S108` `RELAY_VAPID_PRIVATE_KEY` is anything but a relay Worker secret, a Wrangler `vars` entry or a preview config included, or push answers a key while the private key does not sign for it; inspect `pushConfigOf` in `hosted/server/relay-push.ts`, `vapidSigner` in `remote-lib-common/src/remote/web-push.ts`, and `hosted/scripts/preview.mjs`.
- **FAIL IF** `S109` the WebCrypto sender stops reproducing the RFC 8291 Appendix A message byte for byte in `remote-lib-common/test/web-push.test.mjs`, or that test takes an expected value from the code under test.
- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables and `user` (voice-token insertion under "Managed voice" excepted), or reads a user column but the entitlement's; inspect the relay bundle's imports and queries.
- **FAIL IF** `S110` the relay Worker reads a cookie, asks auth, queries a table but its own `dormouse_relay_*` tables and `user` (voice-token insertion under "Managed voice" and the metric upsert (`docs/specs/hosted.md` -> "Metrics") excepted), or reads a user column but the entitlement's; inspect the relay bundle's imports and queries.
- **FAIL IF** `S111` `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 puts anything but its account id in `ctx.storage` (routing state lives in hibernation attachments); `scripts/e2e-lint.mjs` holds the name, parse, decode, log, storage, and attachment half textually in both modules.
- **FAIL IF** `S112` a `scripts/e2e-lint.mjs` rule whose `spec` is this file names a line it does not enforce, or one outside "Rendezvous boundary" and "Relay boundary".
- **FAIL IF** `S113` 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.
Expand Down
11 changes: 8 additions & 3 deletions hosted/server/account-app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { Context, ExecutionContext, Hono } from "hono";
import { openAuthRead, ownPageRead } from "./account-gate";
import { cookieAdmin, openAuthRead, ownPageRead } from "./account-gate";
import type { AccountEnv } from "./bindings";
import { accountRules } from "./headers";
import { ADMIN_METRICS_PATH } from "./metric-labels";
import { readMetrics } from "./metrics";
import { relayAccountRoutes, type RelayAccountHost } from "./relay-account";
import { relayRoom } from "./relay-room-contract";
import { termsRoutes } from "./terms";
Expand All @@ -10,8 +12,8 @@ import { workerApp } from "./worker-app";

/**
* The account Worker (`hosted.dormouse.sh`): auth, providers, readiness,
* terms acceptance, Natural Voice on or off, the Relay's account routes, and the
* frontend. The production and preview entries differ only in `fetchAuth`'s
* terms acceptance, Natural Voice on or off, the Relay's account routes, the
* admin's metrics, and the frontend. The production and preview entries differ only in `fetchAuth`'s
* mail and in `bindings`.
*/
export function accountApp(
Expand Down Expand Up @@ -52,6 +54,9 @@ export function accountApp(
termsRoutes(app, host);
voiceAccountRoutes(app, host);
relayAccountRoutes(app, host);
app.get(ADMIN_METRICS_PATH, cookieAdmin(host), async (c) =>
c.json(await readMetrics(c.env.HYPERDRIVE.connectionString)),
);
},
// Every desktop since v1.2.0 opens `/account` by a hard-coded path
// (`ACCOUNT_PAGE_PATH`), which this SPA fallback answers.
Expand Down
Loading
Loading