Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ Every rule above holds for both engines.
- **Must bound the host request** at `MANAGED_VOICE_REQUEST_TIMEOUT_MS` (inside `SPEECH_ENGINE_TIMEOUT_MS`, so a fallback still fits) and `MAX_AUDIO_BYTES`, accepting only `audio/mpeg`, and store only a token of the `dmv_…` shape and a voice of the curated set (`MANAGED_VOICES`).
- **Must cache clips by voice and text in the host's memory, bounded in count and bytes, never persisted**; a hit makes no request, and a token change empties it. **A 403 latches `notEntitled`**, which bypasses the cache until a speak succeeds, so a lapsed member hears the system voice; **a 401 forgets the token it refused, never one saved since**. An answer to an older token changes nothing.
- **Must disable managed voice in memory immediately on Disconnect, including cached and in-flight results, even if clearing its persisted token fails.** A failure stays visible and retryable; it must never report a successful disconnect while the token remains on disk.
- **Disconnect must ask Hosted to revoke the token** (`docs/specs/hosted.md` -> "Managed voice"), as must a host discarding one its approval delivered (canceled, overtaken, or unsaved); the answer never delays or undoes the local clear, and a failure or Nowhere is only logged.

Source of truth: `withFallback` in `lib/src/lib/speech-engine.ts`; `createManagedVoiceEngine` in `lib/src/lib/managed-voice-engine.ts`; `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts`; `MANAGED_VOICES` in `remote-lib-common/src/remote/managed-voice.ts`.

Expand Down
14 changes: 10 additions & 4 deletions 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, and the relay mints approved voice tokens and evicts past their cap ("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 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 @@ -81,12 +81,16 @@ A desktop with managed voice enabled exchanges its voice token for ElevenLabs sp
| `POST /api/voice/tokens` | login cookie, exact `Origin` | 201 `{ id, token, createdAt }` |
| `DELETE /api/voice/tokens/:id` | login cookie, exact `Origin` | 204; 404 for another account's or an unknown ID |
| `POST /api/voice/speak` | `Authorization: Bearer dmv_…`, JSON `{ text, voiceId }` | 200 `audio/mpeg`, `Cache-Control: no-store` |
| `POST /api/voice/revoke` | `Authorization: Bearer dmv_…` | 204 for a live, revoked, or unknown token; 401 only for a missing or malformed one |

Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 for an account not entitled ("Entitlement").

**Must store only a token's SHA-256.** A token is `dmv_` plus base64url of 32 random bytes, returned only by the mint response: the account's `POST`, or the poll that redeems an enrollment. Revocation is permanent.
**Must store only a token's SHA-256.** A token is `dmv_` plus base64url of 32 random bytes, returned only by the mint response: the account's `POST`, or the poll that redeems an enrollment. **Never clear or move a revocation**: a trigger refuses it (migration 008), since the relay's and voice's roles may write `revokedAt`.

**Must keep managed voice and remote control independent.** Voice approval mints only a voice token, remote enrollment only a Burrow; disconnecting or revoking either preserves the other. The relay's role may insert only the voice token's owner and hash. Client controls and disclosure: `docs/specs/alert.md` -> "Settings dialog".
- **Revoke must reach only the presented token's row and ask nothing but the token**, no cookie or entitlement, so a lapsed member still disconnects.
- **Must keep at most `MAX_VOICE_TOKENS_PER_ACCOUNT` live tokens per account**: each mint, in its own statement, revokes the owner's least recently used past it, a never-used token ranked by its mint (rationale).

**Must keep managed voice and remote control independent.** Voice approval mints only a voice token, remote enrollment only a Burrow; disconnecting or revoking either preserves the other. The relay's role may insert only the voice token's owner and hash, and revoke its owner's past the cap; it never reads a hash. Client controls and disclosure: `docs/specs/alert.md` -> "Settings dialog".

**Speak must answer in this order:**

Expand All @@ -105,7 +109,7 @@ Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403
- **Never touch the database or any binding but `ELEVENLABS_API_KEY` in a sweep**, so an idle deployment lets Postgres suspend. Without the key nothing runs; development and previews never sweep.
- **Must fail the cron invocation when its pass cannot list or any delete fails; the after-speech pass only logs** (rationale).

Source of truth: `hosted/server/voice.ts`; `hosted/server/voice-app.ts`; `mintVoiceToken` in `hosted/server/voice-token.ts`.
Source of truth: `hosted/server/voice.ts`; `hosted/server/voice-app.ts`; `hosted/server/voice-token.ts`; `hosted/server/dormouse-migrations/008_voice_revocation_permanent.sql`.

## Relay

Expand Down Expand Up @@ -271,3 +275,5 @@ Source of truth: `.github/workflows/hosted-production.yml`; `hosted/scripts/prod
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 for every member: the subscription as the entitlement in place of `ADMIN_EMAIL` (`docs/specs/pricing.md` -> "Checkout and entitlement"), per-account quotas, usage accounting, spending bounds beyond the fixed daily cap, retiring the account page's hand-minted tokens, and explicit text/redaction disclosure.
4. Open sign-in: delete `hosted/server/prelaunch.ts` and the frontend's pre-launch notice.

**Orphaned voice tokens.** A token whose `enabled` poll answer is lost is held by no one and stays live until the cap evicts it; expire never-used tokens, or have the desktop confirm receipt.
2 changes: 2 additions & 0 deletions docs/specs/hosted.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

Separate approval (2026-10): enabling voice must not enroll remote control or reset phone pairings. Purpose-separated code derivation prevents a code approved for voice from authorizing remote enrollment.

Token cap (2026-10): Disconnect revokes its own token, but one it could not reach (offline, under Nowhere, a lost poll answer) stays live, so every Enable could add one. The cap evicts rather than refuses, unlike the Burrow cap: no desktop lists voice tokens, so an account at the cap could not see what to remove, and the least recently used is almost always one no desktop holds. Evicting per device was rejected because a token carries no device identity, and revoking every older token at each mint would disconnect the member's other machines. The cap equals the Burrow abuse ceiling so it never acts as a device limit (`docs/specs/pricing.md` -> "Checkout and entitlement"). The eviction shares the mint's snapshot, so two concurrent mints may each leave one over; the next mint trims it.

History sweep (sources checked 2026-09-22):

- ElevenLabs stores every text-to-speech generation, including its text, in the account's speech history. Turning that off per request (`enable_logging=false`, zero-retention mode) is available to enterprise accounts only, so deletion is the remaining control. The sweep has no per-application filter, so every history item visible to its key is eligible for deletion. On 2026-10-07 the operator confirmed testing that the dedicated Dormouse service account isolates its history from other usage in the same workspace. This is operator verification, not a claim that every service-account sharing configuration has the same isolation.
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 @@ -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, which never logs what you say. | 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, which never logs what you say, and a voice token once revoked never speaks again. | Hosted phone | `hosted/server/tests/policy.test.ts`, `hosted/server/tests/workers.test.ts`, `hosted/server/tests/runtime-roles.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` a managed-voice route 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, logs 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** `S269` voice revoke reaches a row but the presented token's own, a mint's cap eviction revokes another account's token, or any role but the account Worker's can clear or move a `revokedAt` (the trigger migration 008 adds, which neither role may drop); inspect `revokeRoute` in `hosted/server/voice.ts`, `evictPastCap` in `hosted/server/voice-token.ts` and its callers, `hosted/server/runtime-roles.sql`, and `hosted/server/dormouse-migrations/008_voice_revocation_permanent.sql`.
- **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.

Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy.test.ts`.
Pinned by `hosted/server/tests/workers.test.ts`, `hosted/server/tests/policy.test.ts`, `hosted/server/tests/runtime-roles.test.ts`, and `hosted/server/tests/relay.test.ts`.

## Rendezvous boundary

Expand Down Expand Up @@ -60,7 +61,7 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy.
| --- | --- | --- |
| **Hosted's Relay keeps each account's computers, passkeys, and push registrations to that account.** It stores tokens only hashed, rechecks the owner's entitlement on every request, closes a removed computer's socket, and neither decodes a frame nor reads a push. | Hosted phone | `hosted/server/tests/relay.test.ts`, `hosted/server/tests/relay-room.test.ts`, `hosted/server/tests/relay-push.test.ts` |

**The Hosted Relay** on the relay Worker, and its account routes on the account Worker: `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment" own them; these are the checks on them. Inspect `relayApiRoutes` in `hosted/server/relay-api.ts`, `hosted/server/relay-auth.ts`, `relaySocketRoutes` in `hosted/server/relay-sockets.ts`, `RelayRoom` in `hosted/server/relay-room.ts`, the frame layer in `remote-lib-common/src/remote/relay-routing.ts`, `relayAccountRoutes` in `hosted/server/relay-account.ts`, `cookieEntitled` in `hosted/server/account-gate.ts`, `hosted/server/relay-push.ts`, and `hosted/server/dormouse-migrations/002_relay.sql`, `003_relay_push.sql`, and `004_relay_enrollment_redeemed.sql`.
**The Hosted Relay** on the relay Worker, and its account routes on the account Worker: `docs/specs/hosted.md` -> "Relay", "Relay sockets", and "Burrow enrollment" own them; these are the checks on them. Inspect `relayApiRoutes` in `hosted/server/relay-api.ts`, `hosted/server/relay-auth.ts`, `relaySocketRoutes` in `hosted/server/relay-sockets.ts`, `RelayRoom` in `hosted/server/relay-room.ts`, the frame layer in `remote-lib-common/src/remote/relay-routing.ts`, `relayAccountRoutes` in `hosted/server/relay-account.ts`, `cookieEntitled` in `hosted/server/account-gate.ts`, `hosted/server/relay-push.ts`, and `hosted/server/dormouse-migrations/002_relay.sql`, `003_relay_push.sql`, `004_relay_enrollment_redeemed.sql`, `007_independent_voice.sql`, and `008_voice_revocation_permanent.sql`.

- **FAIL IF** `S97` a session, Burrow, or setup token is stored other than as its SHA-256, an enrollment device code is stored at all, or an account's Relay rows outlive its user row.
- **FAIL IF** `S98` a query reading a Burrow, passkey, presence nonce, or setup token is not scoped to the caller's account (the session's user, the Burrow token's owner, or the setup token's Burrow's owner; `RelayRows` by its consumer's account check; sign-in excepted), a passkey registers to any account but the minting Burrow's owner, or a setup challenge redeems with another Burrow's token.
Expand All @@ -75,7 +76,7 @@ Pinned by `hosted/server/tests/workers.test.ts` and `hosted/server/tests/policy.
- **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` (a voice token's mint and its cap's eviction under "Managed voice" excepted, never reading a hash), 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
Loading
Loading