diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 65b34767e..33be364d4 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -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`. diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 49a0fe7e2..23a5b6bd9 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -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`). @@ -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:** @@ -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 @@ -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. diff --git a/docs/specs/hosted.rationale.md b/docs/specs/hosted.rationale.md index 60c7152a5..74b4c2f80 100644 --- a/docs/specs/hosted.rationale.md +++ b/docs/specs/hosted.rationale.md @@ -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. diff --git a/docs/specs/security-hosted.md b/docs/specs/security-hosted.md index 497fe41f5..ad6a034e1 100644 --- a/docs/specs/security-hosted.md +++ b/docs/specs/security-hosted.md @@ -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 @@ -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. @@ -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. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 8c30ca403..407773d55 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -131,7 +131,7 @@ Source of truth: `startCapabilityViewer` in `dor-tools-builtin/src/viewer-server The exposure is traffic the user never chose, which under Nothing is none. `docs/specs/remote-network.md` -> "Policy" owns the rule these checks audit. -- **FAIL IF** `S156` anything opens a connection on its own while the network policy is `nothing` or unread. `BurrowService` in `lib/src/host/remote/service.ts` must route every socket, request, and direct peer through its transport guard, which refuses at the call, and `BurrowRuntime` and `burrowFetch` must take theirs from it with no default to the global `WebSocket` or `fetch`; start no `BurrowRuntime` under `nothing`; and refuse `enroll`, `enrollOffer`, `beginHostedEnrollment`, and `oneTimeOpen` before any request. `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` must ask `networkAllowed` before every speak and voice-approval request, and `runUpdateCheck` in `standalone/src/updater.ts` must not call `check()` unless the policy it read is not `nothing` and `autoUpdate` is on, a failed read counting as `nothing`. Every other network primitive in shipped code is an `OUTBOUND_SITES` entry `scripts/outbound-lint.mjs` prints, its `FORMS` the inventory: read each `guarded:` entry back to its choke point, each `user:` entry's trigger to a click, a command, or the user's own terminal, browser pane, or agent, and each `loopback`, `local-ipc`, `phone`, or `dev-only` entry to what it claims (rationale). Pinned by the lint; by `lib/src/host/remote/outbound.test.ts` and `vscode-ext/test/outbound.test.ts`, each host's real service watched at the Node layer; and by `lib/src/host/remote/service.test.ts`, `lib/src/remote/burrow/burrow-fetch.test.ts`, `lib/src/remote/burrow/burrow-relay-socket.test.ts`, `lib/src/host/managed-voice-host.test.ts`, and `standalone/src/updater.test.ts`. +- **FAIL IF** `S156` anything opens a connection on its own while the network policy is `nothing` or unread. `BurrowService` in `lib/src/host/remote/service.ts` must route every socket, request, and direct peer through its transport guard, which refuses at the call, and `BurrowRuntime` and `burrowFetch` must take theirs from it with no default to the global `WebSocket` or `fetch`; start no `BurrowRuntime` under `nothing`; and refuse `enroll`, `enrollOffer`, `beginHostedEnrollment`, and `oneTimeOpen` before any request. `createManagedVoiceHost` in `lib/src/host/managed-voice-host.ts` must ask `networkAllowed` before every speak, voice-approval, and revoke request, and `runUpdateCheck` in `standalone/src/updater.ts` must not call `check()` unless the policy it read is not `nothing` and `autoUpdate` is on, a failed read counting as `nothing`. Every other network primitive in shipped code is an `OUTBOUND_SITES` entry `scripts/outbound-lint.mjs` prints, its `FORMS` the inventory: read each `guarded:` entry back to its choke point, each `user:` entry's trigger to a click, a command, or the user's own terminal, browser pane, or agent, and each `loopback`, `local-ipc`, `phone`, or `dev-only` entry to what it claims (rationale). Pinned by the lint; by `lib/src/host/remote/outbound.test.ts` and `vscode-ext/test/outbound.test.ts`, each host's real service watched at the Node layer; and by `lib/src/host/remote/service.test.ts`, `lib/src/remote/burrow/burrow-fetch.test.ts`, `lib/src/remote/burrow/burrow-relay-socket.test.ts`, `lib/src/host/managed-voice-host.test.ts`, and `standalone/src/updater.test.ts`. - **FAIL IF** `S157` a webview or viewer CSP omits `default-src`, `base-uri`, or `form-action` (neither falls back), or admits a source other than its own origin, IPC, `data:`, `blob:`, a nonce or hash, VS Code's `cspSource`, or loopback in any `-src` directive, `base-uri`, `form-action`, or `frame-ancestors`: `app.security.csp` in `standalone/src-tauri/tauri.conf.json`, a header that must also carry `frame-ancestors`, the directives `getWebviewHtml` in `vscode-ext/src/webview-html.ts` builds, and every `*CSP` constant in `dor-tools-builtin/src/`. Pinned by `scripts/outbound-lint.mjs`. - **FAIL IF** `S158` the Tauri host can reach the network but through its updater: `standalone/src-tauri/src/lib.rs` registers a plugin but shell and updater, a file under `standalone/src-tauri/src/` names a Rust socket or HTTP client, `[dependencies]` in `standalone/src-tauri/Cargo.toml` gains an HTTP, WebSocket, upload, or opener plugin or an HTTP client crate, an `updater:` permission leaves `standalone/src-tauri/capabilities/main-only.json`, a capability carries a `remote` scope, or `withGlobalTauri` is on. Pinned by `scripts/outbound-lint.mjs`; the updater's own endpoint and key are `docs/specs/security-ci.md` -> "Desktop Releases". - **FAIL IF** `S159` shipped code listens beyond loopback: a `.listen(` or `serve({` with no spelled-out loopback host, a Unix socket or named pipe aside; a `0.0.0.0` or `::` host; or a UDP socket but the direct path's, which only the Burrow service's guarded factory builds (`docs/specs/remote-network.md` -> "Local networks"). Pinned by `scripts/loopback-lint.mjs` (check 4). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 5d2b24582..4b46c0bd3 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -178,7 +178,7 @@ Source of truth: `vscode-ext/scripts/stage-native-direct.mjs`; `initBurrow` in ` **Every window's extension host runs its own managed-voice host**, the one standalone's sidecar runs (`docs/specs/alert.md` -> "Managed voice"), answering its own webviews (`docs/specs/transport.md` -> "Managed voice"); its clip cache and refusal latch are per window. -- **The token and the chosen voice live in `SecretStorage`, each under a key of its own**: the token is a bearer credential, and the voice rides there only because `secrets.onDidChange` reaches every window. **Every window re-reads on that change**, dropping its clips and latch when the token changed, so an approval or Disconnect in one window reaches all of them. +- **The token and the chosen voice live in `SecretStorage`, each under a key of its own**: the token is a bearer credential, and the voice rides there only because `secrets.onDidChange` reaches every window. **Every window re-reads on that change**, dropping its clips and latch when the token changed, so an approval or Disconnect in one window reaches all of them. **A Disconnect whose delete fails reaches them through Hosted's revocation** instead: each window's next speak request is refused 401, which turns managed voice off there; until then a clip it cached still plays. - **An approval runs in the window that asked**, its code shown only there; the token it saves reaches the rest by the re-read. - **Never re-arm on a re-read a token this window's failed Disconnect left in the store**; a different token, a sibling's later approval, supersedes it. - **A window asks the network policy before every speak and approval request**: the service in the broker, the stored policy elsewhere, a failed read counting as Nothing. diff --git a/hosted/server/dormouse-migrations/008_voice_revocation_permanent.sql b/hosted/server/dormouse-migrations/008_voice_revocation_permanent.sql new file mode 100644 index 000000000..b8d585b56 --- /dev/null +++ b/hosted/server/dormouse-migrations/008_voice_revocation_permanent.sql @@ -0,0 +1,21 @@ +-- Up Migration +-- Revocation is permanent (docs/specs/hosted.md -> "Managed voice"), now that +-- the relay and voice roles may write "revokedAt": a revoked token's stays. +CREATE FUNCTION dormouse_voice_tokens_keep_revoked() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF OLD."revokedAt" IS NOT NULL AND NEW."revokedAt" IS DISTINCT FROM OLD."revokedAt" THEN + RAISE EXCEPTION 'a revoked voice token stays revoked' USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END +$$; +-- A trigger fires without EXECUTE, so no role needs to call it. +REVOKE ALL ON FUNCTION dormouse_voice_tokens_keep_revoked() FROM PUBLIC; +CREATE TRIGGER dormouse_voice_tokens_keep_revoked + BEFORE UPDATE OF "revokedAt" ON dormouse_voice_tokens + FOR EACH ROW EXECUTE FUNCTION dormouse_voice_tokens_keep_revoked(); + +-- Down Migration +DROP TRIGGER dormouse_voice_tokens_keep_revoked ON dormouse_voice_tokens; +DROP FUNCTION dormouse_voice_tokens_keep_revoked(); diff --git a/hosted/server/relay-api.ts b/hosted/server/relay-api.ts index 70b908ad2..d60ffc06a 100644 --- a/hosted/server/relay-api.ts +++ b/hosted/server/relay-api.ts @@ -60,7 +60,7 @@ import { allowed } from "./one-time"; import { ENROLLMENT_TTL_MS } from "./policy-constants"; import { relayRoom } from "./relay-room-contract"; import { relayPushRoutes } from "./relay-push"; -import { mintVoiceToken } from "./voice-token"; +import { evictPastCap, mintVoiceToken } from "./voice-token"; import { OWNER_COLUMNS, database, @@ -542,13 +542,16 @@ export function relayApiRoutes(app: Hono<{ Bindings: RelayEnv }>) { if (!owner.entitled) return c.json({ error: NOT_ENTITLED_ERROR }, 403); if (purpose === "voice") { const voiceToken = mintVoiceToken(); - // One statement spends the approval and mints only a voice credential. + // One statement spends the approval, mints only a voice credential, + // and evicts the owner's tokens past the cap. const { rowCount } = await db.query( `WITH spent AS ( UPDATE dormouse_relay_enrollment_approvals SET "redeemedAt" = now() WHERE "userCode" = $1 AND "userId" = $2 AND purpose = 'voice' AND "expiresAt" > now() AND "redeemedAt" IS NULL RETURNING "userId" + ), evicted AS ( + ${evictPastCap(`(SELECT "userId" FROM spent)`)} ) INSERT INTO dormouse_voice_tokens ("userId", hash) SELECT "userId", $3 FROM spent`, [userCode, owner.userId, digest(voiceToken)], diff --git a/hosted/server/runtime-roles.sql b/hosted/server/runtime-roles.sql index f95232f24..b02597b4d 100644 --- a/hosted/server/runtime-roles.sql +++ b/hosted/server/runtime-roles.sql @@ -83,12 +83,16 @@ GRANT UPDATE ("redeemedBurrowId", "redeemedAt") ON dormouse_relay_enrollment_app GRANT SELECT, INSERT, DELETE ON dormouse_relay_push_subscriptions TO dormouse_relay; GRANT UPDATE (endpoint, p256dh, auth, "vapidPublicKey", "subscribedAt") ON dormouse_relay_push_subscriptions TO dormouse_relay; --- A voice approval mints only a token: no read, revocation, or count. +-- A voice approval mints a token and evicts its owner's past the cap: never +-- a hash read, and a revocation 008's trigger keeps permanent. GRANT INSERT ("userId", hash) ON dormouse_voice_tokens TO dormouse_relay; +GRANT SELECT (id, "userId", "createdAt", "lastUsedAt", "revokedAt") ON dormouse_voice_tokens TO dormouse_relay; +GRANT UPDATE ("revokedAt") ON dormouse_voice_tokens TO dormouse_relay; --- The voice Worker: speak's token lookup and the daily count. +-- The voice Worker: speak's token lookup and the daily count, and revoke's +-- write, which 008's trigger keeps from clearing a revocation. GRANT SELECT (id, "userId", hash, "revokedAt") ON dormouse_voice_tokens TO dormouse_voice; -GRANT UPDATE ("lastUsedAt") ON dormouse_voice_tokens TO dormouse_voice; +GRANT UPDATE ("lastUsedAt", "revokedAt") ON dormouse_voice_tokens TO dormouse_voice; GRANT SELECT, INSERT ON dormouse_voice_usage TO dormouse_voice; GRANT UPDATE (count) ON dormouse_voice_usage TO dormouse_voice; diff --git a/hosted/server/tests/migrations.test.ts b/hosted/server/tests/migrations.test.ts index 5ceed48a2..97ab9c039 100644 --- a/hosted/server/tests/migrations.test.ts +++ b/hosted/server/tests/migrations.test.ts @@ -20,6 +20,8 @@ const PINNED: Record = { "37bf7777f10416e9bbcb3df11d1cc355e13462ce99b1e49f2481436617d9eccc", "007_independent_voice.sql": "6b6d95fd3e3e5d71f3915913d483edebd6837e0481388b4f2af11a8249364264", + "008_voice_revocation_permanent.sql": + "488881ca63fec81657ca161614b3c7293fafa898bbd913b1876a6d4a765e1a8a", }; const directory = new URL("../dormouse-migrations/", import.meta.url); diff --git a/hosted/server/tests/relay.test.ts b/hosted/server/tests/relay.test.ts index ab1cc6c7e..e5a8dfd72 100644 --- a/hosted/server/tests/relay.test.ts +++ b/hosted/server/tests/relay.test.ts @@ -49,6 +49,7 @@ import { } from "../relay-api"; import type { Client } from "../relay-auth"; import { upsertSubscription } from "../relay-push"; +import { MAX_VOICE_TOKENS_PER_ACCOUNT } from "../voice-token"; import { workerDatabases } from "./worker-roles"; import { ENTRIES, @@ -1741,3 +1742,47 @@ test("voice approval is independent of remote enrollment and cannot be redeemed await f.sql(`DELETE FROM dormouse_relay_burrows`); expect(await f.sql(`SELECT hash, "revokedAt" FROM dormouse_voice_tokens`)).toEqual([{ hash: digest(token), revokedAt: null }]); }); + +test("a voice redemption evicts its owner's least recently used tokens past the cap, and no one else's", async ({ onTestFinished }) => { + const f = await fixture(); + onTestFinished(f.close); + const owner = await f.account(ADMIN_EMAIL); + const other = await f.account(); + // The cap's worth of live tokens: one never used since an old mint, the + // rest used a minute apart. A revoked one and another account's count for nothing. + await f.sql( + `INSERT INTO dormouse_voice_tokens ("userId", hash, "lastUsedAt") + SELECT $1, 'used-' || n, now() - n * interval '1 minute' FROM generate_series(1, $2 - 1) n`, + [owner, MAX_VOICE_TOKENS_PER_ACCOUNT], + ); + await f.sql( + `INSERT INTO dormouse_voice_tokens ("userId", hash, "createdAt", "revokedAt") VALUES + ($1, 'orphan', now() - interval '1 year', NULL), + ($1, 'revoked', now() - interval '2 years', now() - interval '1 year'), + ($2, 'other', now() - interval '3 years', NULL)`, + [owner, other], + ); + const live = async () => + (await f.sql(`SELECT hash FROM dormouse_voice_tokens WHERE "revokedAt" IS NULL ORDER BY hash`)).map( + (row) => row.hash, + ); + const before = await live(); + + const voice = await f.begin("voice"); + await f.approve(voice.userCode, owner, "voice"); + const enabled = await f.poll(voice.deviceCode, "voice"); + expect(enabled.json!.status).toBe("enabled"); + const token = digest(enabled.json!.voiceToken as string); + expect(await live()).toEqual([...before.filter((hash) => hash !== "orphan"), token].sort()); + expect( + await f.sql(`SELECT count(*)::int AS n FROM dormouse_voice_tokens WHERE "userId" = $1 AND "revokedAt" IS NULL`, [owner]), + ).toEqual([{ n: MAX_VOICE_TOKENS_PER_ACCOUNT }]); + + // The next goes by least recent use. + const again = await f.begin("voice"); + await f.approve(again.userCode, owner, "voice"); + expect((await f.poll(again.deviceCode, "voice")).json!.status).toBe("enabled"); + expect(await live()).not.toContain(`used-${MAX_VOICE_TOKENS_PER_ACCOUNT - 1}`); + expect(await live()).toContain(`used-${MAX_VOICE_TOKENS_PER_ACCOUNT - 2}`); + expect(await live()).toContain("other"); +}); diff --git a/hosted/server/tests/runtime-roles.test.ts b/hosted/server/tests/runtime-roles.test.ts index e43f574b3..d117ef0bd 100644 --- a/hosted/server/tests/runtime-roles.test.ts +++ b/hosted/server/tests/runtime-roles.test.ts @@ -88,8 +88,12 @@ const EXPECTED: Record = { "dormouse_relay_sessions: DELETE, INSERT, SELECT", "dormouse_relay_setup_tokens.claimed: UPDATE", "dormouse_relay_setup_tokens: DELETE, INSERT, SELECT", + "dormouse_voice_tokens.createdAt: SELECT", "dormouse_voice_tokens.hash: INSERT", - "dormouse_voice_tokens.userId: INSERT", + "dormouse_voice_tokens.id: SELECT", + "dormouse_voice_tokens.lastUsedAt: SELECT", + "dormouse_voice_tokens.revokedAt: SELECT, UPDATE", + "dormouse_voice_tokens.userId: INSERT, SELECT", "schema public: USAGE", "user.email: SELECT", "user.emailVerified: SELECT", @@ -100,7 +104,7 @@ const EXPECTED: Record = { "dormouse_voice_tokens.hash: SELECT", "dormouse_voice_tokens.id: SELECT", "dormouse_voice_tokens.lastUsedAt: UPDATE", - "dormouse_voice_tokens.revokedAt: SELECT", + "dormouse_voice_tokens.revokedAt: SELECT, UPDATE", "dormouse_voice_tokens.userId: SELECT", "dormouse_voice_usage.count: UPDATE", "dormouse_voice_usage: INSERT, SELECT", @@ -127,7 +131,7 @@ const outcome = (worker: Worker, text: string) => (error: { code?: string }) => error.code, ); -test("the relay's role is refused the account's tables, the user row's other columns and writes, voice but a token's mint, and Burrow removal", async () => { +test("the relay's role is refused the account's tables, the user row's other columns and writes, voice but a token's mint and eviction, and Burrow removal", async () => { // It does log in, and reads what the entitlement check reads. expect(await outcome("relay", `SELECT id, email, "emailVerified" FROM "user"`)).toBe("ok"); for (const text of [ @@ -136,8 +140,9 @@ test("the relay's role is refused the account's tables, the user row's other col `SELECT name FROM "user"`, `UPDATE "user" SET email = email`, `UPDATE "user" SET "emailVerified" = true`, - `SELECT id FROM dormouse_voice_tokens`, - `UPDATE dormouse_voice_tokens SET "revokedAt" = now()`, + `SELECT hash FROM dormouse_voice_tokens`, + `UPDATE dormouse_voice_tokens SET "lastUsedAt" = now()`, + `UPDATE dormouse_voice_tokens SET "userId" = "userId"`, `DELETE FROM dormouse_voice_tokens`, `INSERT INTO dormouse_voice_tokens ("userId", hash, "revokedAt") VALUES ('x', 'x', now())`, `INSERT INTO dormouse_voice_usage ("userId", day, count) VALUES ('x', now()::date, 0)`, @@ -160,13 +165,46 @@ test("the voice role is refused the relay's tables, the account's, and its token `SELECT name FROM "user"`, `UPDATE "user" SET "emailVerified" = true`, `SELECT "createdAt" FROM dormouse_voice_tokens`, - `UPDATE dormouse_voice_tokens SET "revokedAt" = NULL`, + `UPDATE dormouse_voice_tokens SET "userId" = "userId"`, + `UPDATE dormouse_voice_tokens SET hash = hash`, `INSERT INTO dormouse_voice_tokens ("userId", hash) VALUES ('x', 'x')`, `DELETE FROM dormouse_voice_usage`, ]) expect([text, await outcome("voice", text)]).toEqual([text, "42501"]); }); +test("neither role can clear or move a revocation, though both may revoke", async () => { + const [{ id: userId }] = await queryDatabase<{ id: string }>( + context.database.url, + `INSERT INTO "user" (id, name, email, "emailVerified", "createdAt", "updatedAt") + VALUES ('revocation-owner', 'Owner', 'revocation@example.test', true, now(), now()) RETURNING id`, + ); + await queryDatabase( + context.database.url, + `INSERT INTO dormouse_voice_tokens ("userId", hash, "revokedAt") + VALUES ($1, 'live', NULL), ($1, 'revoked', now() - interval '1 day')`, + [userId], + ); + try { + for (const worker of ["relay", "voice"] as const) { + for (const text of [ + `UPDATE dormouse_voice_tokens SET "revokedAt" = NULL WHERE "revokedAt" IS NOT NULL`, + `UPDATE dormouse_voice_tokens SET "revokedAt" = now() WHERE "revokedAt" IS NOT NULL`, + ]) + expect([worker, text, await outcome(worker, text)]).toEqual([worker, text, "23514"]); + } + // Revoking a live token, and a no-op on a revoked one, still work. + expect( + await outcome("voice", `UPDATE dormouse_voice_tokens SET "revokedAt" = now() WHERE "revokedAt" IS NULL`), + ).toBe("ok"); + expect( + await outcome("relay", `UPDATE dormouse_voice_tokens SET "revokedAt" = "revokedAt"`), + ).toBe("ok"); + } finally { + await queryDatabase(context.database.url, `DELETE FROM "user" WHERE id = $1`, [userId]); + } +}); + test("reapplying revokes a grant made since, and keeps a role's login and password", async () => { await queryDatabase(context.database.url, `GRANT ALL ON ALL TABLES IN SCHEMA public TO dormouse_relay`); await queryDatabase(context.database.url, `GRANT SELECT (name) ON "user" TO dormouse_voice`); diff --git a/hosted/server/tests/workers.test.ts b/hosted/server/tests/workers.test.ts index 2575f95ce..a3d535f85 100644 --- a/hosted/server/tests/workers.test.ts +++ b/hosted/server/tests/workers.test.ts @@ -22,6 +22,7 @@ import type { Session } from "../../src/api"; import { ADMIN_EMAIL } from "../entitlement"; import { PRELAUNCH_REFUSAL, SIGN_IN_ALLOWLIST } from "../prelaunch"; import { CRON_SWEEP_CAP, SPEECH_SWEEP_CAP, VOICE_DAILY_CAP } from "../voice"; +import { MAX_VOICE_TOKENS_PER_ACCOUNT } from "../voice-token"; import { ALREADY_APPROVED, RECENT_LOGIN_REQUIRED } from "../relay-account"; import { TERMS_VERSION } from "../policy-constants"; import { @@ -391,6 +392,11 @@ async function fixture( }, body: typeof body === "string" ? body : JSON.stringify(body), }); + const revoke = async (token: string | undefined) => + (await voice()).dispatchFetch(voiceOrigin + "/api/voice/revoke", { + method: "POST", + headers: token ? { authorization: `Bearer ${token}` } : {}, + }); const mint = async () => (await (await tokens("POST")).json()) as { id: string; token: string }; /** The account's Relay routes, from this origin unless `from` names another. */ @@ -412,7 +418,7 @@ async function fixture( body: JSON.stringify({ version }), headers: { origin: from, "content-type": "application/json" }, }); - return { request, post, session, email, oauth, tokens, speak, mint, approve, computers, remove, accept }; + return { request, post, session, email, oauth, tokens, speak, revoke, mint, approve, computers, remove, accept }; } return { ...context, @@ -991,6 +997,99 @@ test("managed voice: only the verified admin mints, speaks, and revokes", async ).toBe(403); }); +test("managed voice: a token revokes only itself, for good, entitled or not", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const admin = f.browser(), + other = f.browser(); + await admin.email(ADMIN_EMAIL); + await other.email("other@example.test"); + const first = await admin.mint(), + second = await admin.mint(); + const otherToken = "dmv_" + "B".repeat(43); + await queryDatabase( + f.database.url, + `INSERT INTO dormouse_voice_tokens ("userId", hash) VALUES ($1, $2)`, + [(await other.session())!.user.id, digest(otherToken)], + ); + const revokedAt = async () => + Object.fromEntries( + ( + await queryDatabase<{ hash: string; revokedAt: Date | null }>( + f.database.url, + `SELECT hash, "revokedAt" FROM dormouse_voice_tokens`, + ) + ).map((row) => [row.hash, row.revokedAt?.toISOString() ?? null]), + ); + + for (const bad of [undefined, "dmv_short", "Basic x"]) + expect((await admin.revoke(bad)).status).toBe(401); + expect((await admin.revoke(first.token)).status).toBe(204); + expect((await admin.speak(first.token, hi)).status).toBe(401); + expect((await admin.speak(second.token, hi)).status).toBe(200); + const once = await revokedAt(); + expect(once[digest(first.token)]).not.toBeNull(); + expect(once[digest(second.token)]).toBeNull(); + expect(once[digest(otherToken)]).toBeNull(); + + // A retry, or a token Hosted never minted, answers the same and changes nothing. + expect((await admin.revoke(first.token)).status).toBe(204); + expect((await admin.revoke("dmv_" + "C".repeat(43))).status).toBe(204); + expect(await revokedAt()).toEqual(once); + // The account page's revoke keeps the first revocation's time. + expect((await admin.tokens("DELETE", "/" + first.id)).status).toBe(204); + expect(await revokedAt()).toEqual(once); + + // A lapsed member still disconnects. + await queryDatabase( + f.database.url, + `UPDATE "user" SET "emailVerified" = false WHERE email = $1`, + [ADMIN_EMAIL], + ); + expect((await admin.revoke(second.token)).status).toBe(204); + expect((await revokedAt())[digest(second.token)]).not.toBeNull(); + expect((await revokedAt())[digest(otherToken)]).toBeNull(); +}); + +test("managed voice: the account's mint evicts its least recently used token past the cap", async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(f.close); + const admin = f.browser(), + other = f.browser(); + await admin.email(ADMIN_EMAIL); + await other.email("other@example.test"); + const [owner, stranger] = [ + (await admin.session())!.user.id, + (await other.session())!.user.id, + ]; + await queryDatabase( + f.database.url, + `INSERT INTO dormouse_voice_tokens ("userId", hash, "lastUsedAt") + SELECT $1, 'used-' || n, now() - n * interval '1 minute' FROM generate_series(1, $2 - 1) n`, + [owner, MAX_VOICE_TOKENS_PER_ACCOUNT], + ); + await queryDatabase( + f.database.url, + `INSERT INTO dormouse_voice_tokens ("userId", hash, "createdAt") VALUES + ($1, 'orphan', now() - interval '1 year'), ($2, 'other', now() - interval '2 years')`, + [owner, stranger], + ); + const { token } = await admin.mint(); + const live = ( + await queryDatabase<{ hash: string }>( + f.database.url, + `SELECT hash FROM dormouse_voice_tokens WHERE "revokedAt" IS NULL`, + ) + ).map((row) => row.hash); + expect(live).toHaveLength(MAX_VOICE_TOKENS_PER_ACCOUNT + 1); + expect(live).not.toContain("orphan"); + expect(live).toEqual(expect.arrayContaining([digest(token), "other", "used-1"])); +}); + test("managed voice fails closed in production without an ElevenLabs key", async ({ onTestFinished, }) => { diff --git a/hosted/server/voice-app.ts b/hosted/server/voice-app.ts index f9fd2f162..d450422b3 100644 --- a/hosted/server/voice-app.ts +++ b/hosted/server/voice-app.ts @@ -1,11 +1,11 @@ import type { VoiceEnv } from "./bindings"; import { NO_PAGE_RULES } from "./headers"; -import { TOKEN_OWNER, elevenLabs, speakRoute, sweepOnCron } from "./voice"; +import { TOKEN_OWNER, elevenLabs, revokeRoute, speakRoute, sweepOnCron } from "./voice"; import { workerApp } from "./worker-app"; /** - * The voice Worker (`voice.dormouse.sh`): readiness, bearer-token speech, and - * the Cron Trigger's ElevenLabs history sweep. No cookie, no auth, no assets. + * The voice Worker (`voice.dormouse.sh`): readiness, bearer-token speech and + * revocation, and the Cron Trigger's ElevenLabs history sweep. No cookie, no auth, no assets. * `sweepDelayMs` exists for the test entry; production keeps the default. */ export function voiceApp( @@ -20,6 +20,9 @@ export function voiceApp( ready: `${TOKEN_OWNER} WHERE t.hash IS NULL AND t."revokedAt" IS NULL LIMIT 0`, unavailable: "Managed voice is temporarily unavailable. Please try again.", routes(app) { + revokeRoute(app, (c) => ({ + databaseUrl: c.env.HYPERDRIVE.connectionString, + })); speakRoute(app, (c) => { const key = c.env.ELEVENLABS_API_KEY; return { diff --git a/hosted/server/voice-token.ts b/hosted/server/voice-token.ts index c04d4d5c0..6582f2293 100644 --- a/hosted/server/voice-token.ts +++ b/hosted/server/voice-token.ts @@ -3,3 +3,28 @@ import { SecureRandom, token as randomToken } from "pgstencil"; /** A fresh voice token, `dmv_` plus base64url of 32 random bytes; only its SHA-256 is stored. */ export const mintVoiceToken = () => "dmv_" + randomToken(new SecureRandom()); + +/** + * The most live voice tokens one account keeps: the Burrow cap's abuse + * ceiling (`MAX_BURROWS_PER_ACCOUNT`), far above what a person owns. A desktop + * revokes its own token at Disconnect; this bounds the ones it could not (a + * Disconnect offline or under Nowhere, a poll whose answer was lost). A mint + * evicts rather than refuses, as the Burrow cap does, because no desktop shows + * these tokens for a person to remove (hosted.rationale.md). + */ +export const MAX_VOICE_TOKENS_PER_ACCOUNT = 1000; + +/** + * A data-modifying CTE body revoking `owner`'s live tokens past the + * `MAX_VOICE_TOKENS_PER_ACCOUNT - 1` most recently used (a never-used one by + * its mint), so the mint in the same statement, which this snapshot cannot + * see, leaves the cap live. `owner` is SQL naming the owner's id. Two + * concurrent mints may each leave one over; the next trims it. + */ +export const evictPastCap = (owner: string) => `UPDATE dormouse_voice_tokens SET "revokedAt" = now() + WHERE id IN ( + SELECT id FROM dormouse_voice_tokens + WHERE "userId" = ${owner} AND "revokedAt" IS NULL + ORDER BY coalesce("lastUsedAt", "createdAt") DESC, id + OFFSET ${MAX_VOICE_TOKENS_PER_ACCOUNT - 1} + )`; diff --git a/hosted/server/voice.ts b/hosted/server/voice.ts index 734b22d93..fddc9a1f2 100644 --- a/hosted/server/voice.ts +++ b/hosted/server/voice.ts @@ -5,7 +5,7 @@ import { withClient } from "pgstencil/postgres"; import { isManagedVoiceId, isManagedVoiceToken, parseBearer } from "remote-lib-common"; import { accountQuery, cookieEntitled, type AccountHost } from "./account-gate"; import { entitledSql } from "./entitlement"; -import { mintVoiceToken } from "./voice-token"; +import { evictPastCap, mintVoiceToken } from "./voice-token"; export const VOICE_DAILY_CAP = 500; /** Speak's token lookup, its owner's entitlement joined, before its WHERE. */ @@ -94,7 +94,8 @@ export function voiceTokenRoutes( const token = mintVoiceToken(); const [row] = await accountQuery<{ id: string; createdAt: Date }>( host(c), - `INSERT INTO dormouse_voice_tokens ("userId", hash) VALUES ($1, $2) + `WITH evicted AS (${evictPastCap("$1")}) + INSERT INTO dormouse_voice_tokens ("userId", hash) VALUES ($1, $2) RETURNING id, "createdAt"`, [c.get("login").userId, digest(token)], ); @@ -195,6 +196,31 @@ export function speakRoute(app: Hono, host: (c: Context) => SpeakHost) { }); } +/** + * Registers the voice origin's bearer-only POST /api/voice/revoke, a desktop's + * Disconnect: the presented token revokes itself and no other. 204 whether it + * was live, already revoked, or unknown, so a retry never fails; no + * entitlement check, so a lapsed member still disconnects. Call before any + * /api/* catch-all. + */ +export function revokeRoute( + app: Hono, + host: (c: Context) => Pick, +) { + app.post("/api/voice/revoke", async (c) => { + const bearer = parseBearer(c.req.header("authorization")); + if (!isManagedVoiceToken(bearer)) return tokenRequired(c); + await withClient(host(c).databaseUrl, (db) => + db.query( + `UPDATE dormouse_voice_tokens SET "revokedAt" = now() + WHERE hash = $1 AND "revokedAt" IS NULL`, + [digest(bearer)], + ), + ); + return c.body(null, 204); + }); +} + /** Deletions per pass. Fits Workers Free's 50 subrequests (hosted.rationale.md). */ export const CRON_SWEEP_CAP = 40; /** Smaller: the speak request also spends its database connection and synthesize call. */ diff --git a/lib/src/host/managed-voice-host.test.ts b/lib/src/host/managed-voice-host.test.ts index 511087134..64858949b 100644 --- a/lib/src/host/managed-voice-host.test.ts +++ b/lib/src/host/managed-voice-host.test.ts @@ -18,6 +18,7 @@ import { DEFAULT_RELAY_ORIGIN } from './relay-origin'; * origin"): changing it changes where every shipped binary sends the token. */ const MANAGED_VOICE_SPEAK_URL = 'https://voice.dormouse.sh/api/voice/speak'; +const MANAGED_VOICE_REVOKE_URL = 'https://voice.dormouse.sh/api/voice/revoke'; /** The next `readFile` fails with this, once: a Windows antivirus lock, say. */ const readFault = vi.hoisted(() => ({ next: null as NodeJS.ErrnoException | null })); @@ -51,6 +52,7 @@ const TOKEN = `dmv_${'A'.repeat(43)}`; let dir: string; let fetchMock: ReturnType; let broadcasts: unknown[]; +let logged: string[]; let host: ReturnType; function make(options: { timeoutMs?: number; stateDir?: string; networkAllowed?: () => Promise } = {}) { @@ -58,6 +60,7 @@ function make(options: { timeoutMs?: number; stateDir?: string; networkAllowed?: store: 'stateDir' in options ? (options.stateDir ? fileManagedVoiceStore(options.stateDir) : undefined) : fileManagedVoiceStore(dir), onStatus: (status) => void broadcasts.push(status), fetch: fetchMock as unknown as typeof fetch, + log: (message) => void logged.push(message), timeoutMs: options.timeoutMs, relay: { origin: DEFAULT_RELAY_ORIGIN, mode: 'hosted' }, networkAllowed: options.networkAllowed ?? (async () => true), @@ -68,6 +71,7 @@ beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'managed-voice-')); fetchMock = vi.fn(); broadcasts = []; + logged = []; host = make(); }); @@ -89,6 +93,17 @@ async function seedToken(token = TOKEN): Promise { await writeFile(join(dir, MANAGED_VOICE_FILE), JSON.stringify({ token, voiceId: DEFAULT_MANAGED_VOICE_ID }), { mode: 0o600 }); } +/** The tokens each revoke request asked Hosted to revoke, in order, once every one is out. */ +async function revoked(count: number): Promise { + const calls = () => fetchMock.mock.calls.filter(([url]) => url === MANAGED_VOICE_REVOKE_URL) as [string, RequestInit][]; + await vi.waitFor(() => expect(calls()).toHaveLength(count)); + return calls().map(([, init]) => { + expect(init).toMatchObject({ method: 'POST', redirect: 'error' }); + expect(init.body).toBeUndefined(); + return (init.headers as Record).authorization; + }); +} + const begun = () => ({ purpose: 'voice', deviceCode: 'A'.repeat(43), userCode: '7KQM-X4TD', expiresAt: Date.now() + 600_000, interval: 1 }); async function approveVoice(target = host, token = TOKEN) { @@ -152,6 +167,44 @@ describe('configuration', () => { expect(JSON.parse(await readFile(join(dir, MANAGED_VOICE_FILE), 'utf8')).token).toBeNull(); }); + it('revokes the token on Hosted at disconnect, and clears it whatever Hosted answers', async () => { + const answers: Array<[string, () => Promise]> = [ + ['revoked', async () => new Response(null, { status: 204 })], + ['refused', async () => new Response('{}', { status: 503 })], + ['unreachable', async () => { throw new TypeError('fetch failed'); }], + ]; + for (const [, answer] of answers) { + await seedToken(); + const restarted = make(); + fetchMock.mockReset(); + fetchMock.mockImplementation(answer); + expect(await restarted.handle({ op: 'disconnect' })).toMatchObject({ ok: true, configured: false }); + expect(await revoked(1)).toEqual([`Bearer ${TOKEN}`]); + expect(JSON.parse(await readFile(join(dir, MANAGED_VOICE_FILE), 'utf8')).token).toBeNull(); + } + await vi.waitFor(() => expect(logged).toEqual([ + '[managed-voice] revoke on Hosted failed: HTTP 503', + '[managed-voice] revoke on Hosted failed: network', + ])); + + // Under Nowhere it sends nothing, and still clears. + await seedToken(); + fetchMock.mockReset(); + expect(await make({ networkAllowed: async () => false }).handle({ op: 'disconnect' })).toMatchObject({ ok: true, configured: false }); + await vi.waitFor(() => expect(logged).toContain('[managed-voice] not revoked on Hosted: network is set to Nowhere')); + expect(fetchMock).not.toHaveBeenCalled(); + expect(JSON.parse(await readFile(join(dir, MANAGED_VOICE_FILE), 'utf8')).token).toBeNull(); + }); + + it('keeps the saved voice through a disconnect whose read fails', async () => { + await writeFile(join(dir, MANAGED_VOICE_FILE), JSON.stringify({ token: TOKEN, voiceId: OTHER_VOICE }), { mode: 0o600 }); + const restarted = make(); + readFault.next = Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' }); + expect(await restarted.handle({ op: 'disconnect' })).toMatchObject({ ok: true, configured: false, voiceId: OTHER_VOICE }); + expect(JSON.parse(await readFile(join(dir, MANAGED_VOICE_FILE), 'utf8'))).toEqual({ token: null, voiceId: OTHER_VOICE }); + expect(broadcasts.at(-1)).toEqual(idle(false, OTHER_VOICE)); + }); + it('treats a corrupt or hand-edited file as unconfigured, and a retired voice as the default', async () => { await writeFile(join(dir, MANAGED_VOICE_FILE), JSON.stringify({ token: 'dmv_short', voiceId: 7 })); expect(await make().handle({ op: 'status' })).toEqual(idle(false)); @@ -177,6 +230,8 @@ describe('configuration', () => { const unavailable = make({ stateDir: undefined }); await approveVoice(unavailable); expect(await unavailable.handle({ op: 'status' })).toMatchObject({ configured: false, enrollment: { status: 'ended', message: expect.stringContaining('Could not save') } }); + // Held by no one, so revoked rather than left for the account page. + expect(await revoked(1)).toEqual([`Bearer ${TOKEN}`]); }); }); @@ -199,8 +254,9 @@ describe('speak', () => { it('answers unconfigured without a request', async () => { await host.handle({ op: 'disconnect' }); + await revoked(1); expect(await host.handle({ op: 'speak', text: 'x' })).toEqual({ ok: false, reason: 'unconfigured' }); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledOnce(); }); it('forgets a token Hosted no longer knows, so Settings asks for a fresh enrollment', async () => { @@ -437,7 +493,8 @@ describe('a store another process shares', () => { await vi.waitFor(() => expect(broadcasts.at(-1)).toEqual(idle(true, OTHER_VOICE))); fetchMock.mockImplementation(async () => audioResponse()); expect(await window.handle({ op: 'speak', text: 'build finished' })).toMatchObject({ ok: true }); - expect(fetchMock.mock.calls[0]![1].headers.authorization).toBe(`Bearer ${OTHER_TOKEN}`); + expect(fetchMock.mock.calls.at(-1)![0]).toBe(MANAGED_VOICE_SPEAK_URL); + expect(fetchMock.mock.calls.at(-1)![1].headers.authorization).toBe(`Bearer ${OTHER_TOKEN}`); }); }); @@ -527,7 +584,9 @@ describe('independent managed-voice lifecycle', () => { for (const text of ['cached label', 'new private label']) { expect(await host.handle({ op: 'speak', text })).toEqual({ ok: false, reason: 'unconfigured' }); } - expect(fetchMock).not.toHaveBeenCalled(); + // Revoked on Hosted although the file still holds it; nothing else is sent. + expect(await revoked(1)).toEqual([`Bearer ${TOKEN}`]); + expect(fetchMock).toHaveBeenCalledOnce(); expect(JSON.parse(await readFile(file, 'utf8')).token).toBe(TOKEN); expect(broadcasts.at(-1)).toMatchObject({ configured: false, disconnectFailed: true }); expect(await host.handle({ op: 'disconnect' })).toMatchObject({ ok: true, configured: false }); @@ -608,7 +667,9 @@ describe('independent managed-voice lifecycle', () => { answer(Response.json({ status: 'enabled', voiceToken: TOKEN })); await vi.advanceTimersByTimeAsync(5000); expect(await host.handle({ op: 'status' })).toEqual(idle(false)); - expect(fetchMock).toHaveBeenCalledTimes(2); + // The late token is revoked, and polling stopped. + expect(await revoked(1)).toEqual([`Bearer ${TOKEN}`]); + expect(fetchMock).toHaveBeenCalledTimes(3); }); it('disconnect during the saving notification cannot re-enable a late approval', async () => { @@ -630,6 +691,8 @@ describe('independent managed-voice lifecycle', () => { await disconnect; expect(await host.handle({ op: 'status' })).toEqual(idle(false)); expect(await make().handle({ op: 'status' })).toEqual(idle(false)); + // The token the disconnect overtook, never saved, is revoked. + expect(await revoked(1)).toEqual([`Bearer ${TOKEN}`]); }); it('stops approval polling when the network policy changes to Nowhere', async () => { diff --git a/lib/src/host/managed-voice-host.ts b/lib/src/host/managed-voice-host.ts index d9201a4cf..68a790895 100644 --- a/lib/src/host/managed-voice-host.ts +++ b/lib/src/host/managed-voice-host.ts @@ -1,9 +1,9 @@ /** * The host half of managed voice (`docs/specs/alert.md` -> "Managed voice"): * holds the token its own Hosted approval obtains, adds it and the voice - * id to the webview's text, caches the clips, and answers with audio or a - * diagnostic failure. Where the request may go: - * `docs/specs/security-local.md` -> "Persisted state". + * id to the webview's text, caches the clips, answers with audio or a + * diagnostic failure, and at Disconnect asks Hosted to revoke the token. + * Where the request may go: `docs/specs/security-local.md` -> "Persisted state". */ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -29,6 +29,7 @@ import { export const MANAGED_VOICE_FILE = 'managed-voice.json'; const MANAGED_VOICE_SPEAK_PATH = '/api/voice/speak'; +const MANAGED_VOICE_REVOKE_PATH = '/api/voice/revoke'; /** Hosted's own bound on `text`. */ const MAX_TEXT_LENGTH = 200; /** `docs/specs/standalone.md` -> "Rust ↔ sidecar bridge". */ @@ -53,25 +54,34 @@ export interface ManagedVoiceStore { * lock cached as defaults would let the next edit overwrite the token. */ read(): Promise; - /** Persist `next`, of which only `changed` differs from what was read. */ + /** + * Persist `next[changed]` alone, keeping the other field as stored: a + * caller whose read failed passes defaults there, which must never land. + */ write(next: StoredManagedVoice, changed: keyof StoredManagedVoice): Promise; } -/** The sidecar's store: `managed-voice.json` in the owner-only state directory, rewritten whole. */ +/** + * The sidecar's store: `managed-voice.json` in the owner-only state directory, + * rewritten whole from a fresh read, so a write fails rather than reset the + * field it was not given. + */ export function fileManagedVoiceStore(stateDir: string): ManagedVoiceStore { const file = join(stateDir, MANAGED_VOICE_FILE); + async function read(): Promise { + try { + return JSON.parse(await readFile(file, 'utf8')); + } catch (error) { + // Only a missing or unparsable file is "no config": a transient lock + // (EBUSY, EPERM, EMFILE) is retried on the next read. + if ((error as NodeJS.ErrnoException).code === 'ENOENT' || error instanceof SyntaxError) return null; + throw error; + } + } return { - async read() { - try { - return JSON.parse(await readFile(file, 'utf8')); - } catch (error) { - // Only a missing or unparsable file is "no config": a transient lock - // (EBUSY, EPERM, EMFILE) is retried on the next read. - if ((error as NodeJS.ErrnoException).code === 'ENOENT' || error instanceof SyntaxError) return null; - throw error; - } - }, - write: (next) => writeJsonAtomic(stateDir, file, next), + read, + write: async (next, changed) => + writeJsonAtomic(stateDir, file, { ...normalizeStored(await read()), [changed]: next[changed] }), }; } @@ -166,6 +176,7 @@ export function createManagedVoiceHost(options: { // every edit and speak is refused. const voice = hostedVoiceOrigin(options.relay); const speakUrl = voice === null ? null : voice + MANAGED_VOICE_SPEAK_PATH; + const revokeUrl = voice === null ? null : voice + MANAGED_VOICE_REVOKE_PATH; let loaded: Promise | null = null; // Each edit reads, then rewrites the whole file: two at once would drop a field. const serialize = createSerialQueue(); @@ -219,19 +230,51 @@ export function createManagedVoiceHost(options: { } } - /** Clear the stored token, memory before disk: a failed write must never leave speech armed. Runs on `serialize`. */ - async function clearToken(): Promise { - const current = await load().catch(() => normalizeStored(null)); - const next = { ...current, token: null }; - loaded = Promise.resolve(next); + /** + * Ask Hosted to revoke `token`, so no copy of it (a sibling window's, or + * one this host failed to clear) speaks again. Never rejects: a failure is + * only logged, and the account page can still revoke it. + */ + async function revokeOnHosted(token: string): Promise { + if (revokeUrl === null) return; + if (!(await options.networkAllowed().catch(() => false))) { + log('[managed-voice] not revoked on Hosted: network is set to Nowhere'); + return; + } + const signal = AbortSignal.timeout(timeoutMs); + try { + const response = await fetchImpl(revokeUrl, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + redirect: 'error', + signal, + }); + await response.body?.cancel().catch(() => {}); + if (!response.ok) log(`[managed-voice] revoke on Hosted failed: HTTP ${response.status}`); + } catch { + log(`[managed-voice] revoke on Hosted failed: ${signal.aborted ? 'timeout' : 'network'}`); + } + } + + /** + * Clear the stored token, memory before disk: a failed write must never + * leave speech armed. `revoke` also asks Hosted to revoke it, whatever the + * write does. Runs on `serialize`. + */ + async function clearToken(revoke: boolean): Promise { + // Unread, the token is unknown: nothing to revoke, and the store keeps the voice. + const current = await load().catch(() => null); + const held = current?.token ?? uncleared; + if (current) loaded = Promise.resolve({ ...current, token: null }); disconnectFailed = true; - uncleared = current.token ?? uncleared; - options.onStatus(status(next)); + uncleared = held; + if (revoke && held) void revokeOnHosted(held); + await announce(); if (!store) throw new Error('no owner-only store'); - await store.write(next, 'token'); + await store.write({ ...current ?? normalizeStored(null), token: null }, 'token'); disconnectFailed = false; uncleared = null; - options.onStatus(status(next)); + await announce(); } /** Any token change forgets every clip and every refusal the old one earned. */ @@ -243,7 +286,7 @@ export function createManagedVoiceHost(options: { return serialize(async () => { if (speakUrl === null) throw new Error('this build has no managed voice'); if (token === null) { - await clearToken(); + await clearToken(true); } else { await save('token', (config) => ({ ...config, token }), false); if (generation === tokenGeneration) disabled = false; @@ -263,7 +306,8 @@ export function createManagedVoiceHost(options: { disabled = true; clips.clear(); notEntitled = false; - await clearToken(); + // Already refused: Hosted holds nothing of it to revoke. + await clearToken(false); }); /** Latch what Hosted last said of the entitlement, announcing a change. */ @@ -393,7 +437,12 @@ export function createManagedVoiceHost(options: { // Check outside the retrying exchange too: Nowhere ends this run. await requireEnrollmentNetwork(); const answer = await pollHostedApproval(options.relay.origin, body.deviceCode, 'voice', enrollmentFetch, parseVoiceApproval); - if (sequence !== enrollmentSequence) return; + // Below, a token this host discards (canceled, or never saved) is + // held by no one, so it is revoked rather than left for the user. + if (sequence !== enrollmentSequence) { + if (answer.status === 'enabled') void revokeOnHosted(answer.voiceToken); + return; + } if (answer.status === 'retry') { if (answer.slowDown) intervalMs = slowDownHostedApproval(intervalMs); schedule(); @@ -405,11 +454,15 @@ export function createManagedVoiceHost(options: { if (answer.status === 'enabled') { enrollment = { status: 'saving' }; await announce(); - if (sequence !== enrollmentSequence) return; - // A token never saved, or never received below, is held by no one, - // so the user is never asked to revoke it. + if (sequence !== enrollmentSequence) { + void revokeOnHosted(answer.voiceToken); + return; + } try { await setToken(answer.voiceToken); } - catch { throw new Error('Could not save managed voice on this computer. Try again.'); } + catch { + void revokeOnHosted(answer.voiceToken); + throw new Error('Could not save managed voice on this computer. Try again.'); + } if (sequence === enrollmentSequence) { enrollment = null; await announce(); } return; } diff --git a/scripts/outbound-lint.mjs b/scripts/outbound-lint.mjs index 154190105..2d7ee48b0 100644 --- a/scripts/outbound-lint.mjs +++ b/scripts/outbound-lint.mjs @@ -151,7 +151,7 @@ const OUTBOUND_SITES = { // --- guarded: managed voice --- 'lib/src/host/managed-voice-host.ts': { class: 'guarded:managed-voice', - reason: 'Asks networkAllowed before every speak and voice-approval request.', + reason: 'Asks networkAllowed before every speak, voice-approval, and revoke request.', }, // --- guarded: the updater --- 'standalone/src/updater.ts': { diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index 2ab8934c3..18985fdaf 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 269, + "next": 270, "retired": [ 265 ] diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 1dc2ab76b..87764f247 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -3,7 +3,7 @@ "SECURITY.md": 200, "SELF_HOST.md": 4600, "docs/compatible-agents.md": 1800, - "docs/specs/alert.md": 7050, + "docs/specs/alert.md": 7100, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 2200, "docs/specs/dor-browser.md": 5800, @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1650, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 4300, + "docs/specs/hosted.md": 4450, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4100, @@ -26,7 +26,7 @@ "docs/specs/reopen.md": 1200, "docs/specs/security-audit.md": 4700, "docs/specs/security-ci.md": 3350, - "docs/specs/security-hosted.md": 2900, + "docs/specs/security-hosted.md": 3000, "docs/specs/security-local.md": 5600, "docs/specs/security-remote.md": 6800, "docs/specs/security-supply-chain.md": 1600, @@ -40,7 +40,7 @@ "docs/specs/tiling-engine.md": 2500, "docs/specs/transport.md": 4450, "docs/specs/tutorial.md": 1750, - "docs/specs/vscode.md": 4300, + "docs/specs/vscode.md": 4350, "docs/specs/webgl-text.md": 750, "docs/specs/website-docs.md": 3100 } diff --git a/vscode-ext/src/managed-voice.ts b/vscode-ext/src/managed-voice.ts index 9661d7d9c..a10cd981b 100644 --- a/vscode-ext/src/managed-voice.ts +++ b/vscode-ext/src/managed-voice.ts @@ -4,7 +4,9 @@ * one the standalone sidecar runs, over a store in `SecretStorage`. The token * is a bearer credential, and `SecretStorage` is the store whose changes every * window of the extension hears, so an approval or Disconnect in one window - * reaches every window's status, cache, and refusal latch. + * reaches every window's status, cache, and refusal latch. A Disconnect whose + * delete fails changes nothing here; Hosted's revocation of the token stops + * the other windows instead, at their next speak request. */ import type * as vscode from 'vscode'; diff --git a/vscode-ext/test/managed-voice.test.ts b/vscode-ext/test/managed-voice.test.ts index a5e235677..94a52eaf6 100644 --- a/vscode-ext/test/managed-voice.test.ts +++ b/vscode-ext/test/managed-voice.test.ts @@ -21,21 +21,45 @@ vi.mock('../../lib/src/host/relay-origin', async (importOriginal) => { const TOKEN = `dmv_${'A'.repeat(43)}`; -/** One `SecretStorage` every window shares, firing `onDidChange` in all of them. */ +/** One `SecretStorage` every window shares, firing `onDidChange` in all of them; `failDeletes` makes a delete reject, changing nothing. */ function sharedSecrets() { const values = new Map(); const watchers = new Set<(event: { key: string }) => void>(); const announce = (key: string) => { for (const watcher of watchers) watcher({ key }); }; + const faults = { failDeletes: false }; const secrets = { get: async (key: string) => values.get(key), store: async (key: string, value: string) => { values.set(key, value); announce(key); }, - delete: async (key: string) => { values.delete(key); announce(key); }, + delete: async (key: string) => { + if (faults.failDeletes) throw new Error('keychain locked'); + values.delete(key); + announce(key); + }, onDidChange: (watcher: (event: { key: string }) => void) => { watchers.add(watcher); return { dispose: () => void watchers.delete(watcher) }; }, } as unknown as vscode.SecretStorage; - return { values, secrets }; + return { values, secrets, faults }; +} + +/** Hosted's voice Worker, as far as a window sees it: speak refuses a token once revoked. */ +function fakeHosted() { + const revoked = new Set(); + const speaks: string[] = []; + const fetch = vi.fn(async (url: string, init: RequestInit) => { + const token = (init.headers as Record).authorization.replace('Bearer ', ''); + if (url.endsWith('/api/voice/revoke')) { + revoked.add(token); + return new Response(null, { status: 204 }); + } + speaks.push(token); + return revoked.has(token) + ? new Response('{}', { status: 401 }) + : new Response(new Uint8Array([9, 8, 7]), { headers: { 'content-type': 'audio/mpeg' } }); + }); + vi.stubGlobal('fetch', fetch); + return { revoked, speaks }; } /** One window: a fresh copy of the module, as each extension host loads its own. */ @@ -54,6 +78,7 @@ async function openWindow(secrets: vscode.SecretStorage) { const opened: Array<{ dispose(): void }> = []; afterEach(() => { for (const disposable of opened.splice(0)) disposable.dispose(); + vi.unstubAllGlobals(); }); describe('managed voice in the VS Code extension host', () => { @@ -98,4 +123,29 @@ describe('managed voice in the VS Code extension host', () => { expect(await sibling.module.handleVoiceCommand({ op: 'speak', text: 'build finished' })) .toEqual({ ok: false, reason: 'unconfigured' }); }); + + it('stops the other windows through Hosted when Disconnect cannot delete the token', async () => { + const hosted = fakeHosted(); + const { values, secrets, faults } = sharedSecrets(); + const first = await openWindow(secrets); + const sibling = await openWindow(secrets); + opened.push(first.disposable, sibling.disposable); + await secrets.store(first.module.MANAGED_VOICE_TOKEN_KEY, TOKEN); + await vi.waitFor(() => expect(sibling.statuses.at(-1)).toMatchObject({ configured: true })); + expect(await sibling.module.handleVoiceCommand({ op: 'speak', text: 'build finished' })).toMatchObject({ ok: true }); + + // The delete fails, so no window hears a change; the token stays in SecretStorage. + faults.failDeletes = true; + expect(await first.module.handleVoiceCommand({ op: 'disconnect' })).toMatchObject({ ok: false }); + expect(values.get(first.module.MANAGED_VOICE_TOKEN_KEY)).toBe(TOKEN); + await vi.waitFor(() => expect([...hosted.revoked]).toEqual([TOKEN])); + + // The sibling's next request is refused, which turns it off there too. + expect(await sibling.module.handleVoiceCommand({ op: 'speak', text: 'tests failed' })) + .toEqual({ ok: false, reason: 'HTTP 401' }); + await vi.waitFor(() => expect(sibling.statuses.at(-1)).toMatchObject({ configured: false })); + expect(await sibling.module.handleVoiceCommand({ op: 'speak', text: 'deploy finished' })) + .toEqual({ ok: false, reason: 'unconfigured' }); + expect(hosted.speaks).toEqual([TOKEN, TOKEN]); + }); });