Repository navigation
Independent Hosted enrollment for remote control and managed voice - #999
Conversation
Every Hosted gate reads the entitlement through entitledSql / entitled (hosted/server/entitlement.ts): the Relay's bearer lookups resolve it in the same query, speak joins it, and the account's cookie gate (cookieEntitled) reads it per request. Still admin-only; billing swaps the predicate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Server: the device-code redemption mints the desktop's voice token in the statement that enrolls its Burrow (migration 005: the token names its Burrow and is deleted with it), and the poll's enrolled answer carries it. The relay role gains INSERT on exactly those three columns. Speak refuses any voice outside the curated set in remote-lib-common. Desktop: the Burrow service hands the token to the managed-voice host in-process, and clearEnrollment (Disconnect / Sign out) clears it. The webview can no longer write a token; it picks a voice from the curated set. The host caches clips by voice and text in memory (32 clips, 4 MiB) and latches a 403 as notEntitled, bypassing the cache until a speak succeeds. Settings: Notifications' managed voice shows the disclosure, then Sign in to Dormouse Hosted (the Hosted enroll flow, now shared from HostedSignIn.tsx with Remote control), an explanation pointing at Network under Nothing, "no Hosted plan" with the pricing link, the voice picker, and Sign out. The dev-only token paste is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hosted.md: member voice minted at redemption, the curated set, the relay's narrow voice grant; Future item 3 now names only the subscription, quotas, and accounting. pricing.md: the built voice items leave Future for their owners. alert.md: the sent payload, the clip cache, the notEntitled latch, and the Settings group. relay.md, transport.md, remote-network.md, security-local.md, security-hosted.md follow. Stories for every new Settings state; the account page names signed-in computers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DisconnectConfirm takes a label and warning, and managed voice's Sign out uses it; signInAgain in the status store is the one clear-then-begin, its error kept above the signed-in flip. The clip cache guards a stale request with a token generation, not a reread. Only a Hosted build hands the service a voice credential. ADMIN_EMAIL moves into entitlement.ts; unused exports go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n signs voice out An answer to a request sent under a replaced token neither latches notEntitled nor caches a clip. A 401 from speak (the computer removed or its token revoked at the account page) forgets the token, and a start with no enrollment clears a token pasted before sign-in existed, so Settings never shows signed-out while voice still speaks. SELF_HOST.md names the new button; the select style and account-page URL use the shared helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
53d2259
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://dae92768.mouseterm.pages.dev |
| Branch Preview URL: | https://hosted-signin.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
The account page still lists every voice token with a Revoke button, and a token minted at sign-in shows up there beside the others. Revoking one leaves its Burrow enrolled. The next speak gets a 401, the desktop forgets the token, and Settings says "This computer signed in before managed voice. Sign out and sign in again." Signing in again mints a new burrowId, so BurrowAcl drops every paired phone's record. A machine signed in before this change takes the same path. The PR body lists that path as a decision to check, but doesn't mention that following it means re-pairing every phone.
Two smaller ways to close the gap: show a Burrow-linked token under its computer with no Revoke of its own, since removing the computer already revokes it; or have the Relay mint a token for an enrolled Burrow that lacks one, instead of requiring re-enrollment.
The mint path (POST /api/voice/tokens and the "Copy this token now" notice) now has no consumer. The desktop paste field is gone, and BurrowService start clears any token held without an enrollment. A minted token can only be copied and then go unused. Unless something else consumes it, dropping the route and its UI removes a token class this PR no longer supports.
Conflicts resolved toward main's later work: - Per-Worker roles and the cookie gate's optional refuse: the gate keeps `cookieEntitled`, and `TOKEN_OWNER` (voice's readiness probe) now selects `entitledSql`. `ADMIN_EMAIL` moved to entitlement.ts, so prelaunch.ts imports it from there. - Migration 005_voice_token_burrow becomes 007, after main's 005 and 006. - Copy: main's corrected ElevenLabs retention wording and "Nowhere" for the network level. - Specs: main's legal pricing.md wording; the built managed-voice rules point at hosted.md, and the unbuilt label disclosure stays under Future. Main's mechanism trims stand. - The outbound lint, its self-test, and the cookie-gate canary follow the Hosted links and the gate's rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
After the merge, docs/specs/pricing.md -> "Managed voice" says the built disclosure lives in docs/specs/hosted.md -> "Managed voice" and docs/specs/alert.md -> "Managed voice", but neither section mentions it. So "show what is sent before the first request" is now stated only in the comment on MANAGED_VOICE_DISCLOSURE in lib/src/components/ManagedVoiceSection.tsx, and that comment cites the pricing section, which states nothing about it. A later edit could drop the disclosure without contradicting any spec. Add the rule to alert.md -> "Managed voice", which owns the Settings section, and point the comment there. Otherwise, remove "the disclosure" from the pricing pointer.
Ned's call: an account enrolls every computer its member uses. Hosted's enrollment cap moves from the shared 32 (`MAX_ENROLLED_BURROWS`, which the self-host Relay keeps) to `MAX_BURROWS_PER_ACCOUNT` = 1000, an abuse ceiling far above any person's machines. The /hosted page, Terms and pricing.md now say there is no device limit; the material limit left to disclose is managed voice's daily cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
The disclosure rule now sits in docs/specs/alert.md -> "Settings dialog" ("Must state what is sent to Hosted … before the sign-in that enables it"). But the two pointers to it still name the sections that don't hold it: the comment on MANAGED_VOICE_DISCLOSURE cites pricing.md -> "Managed voice", and that section sends the disclosure to hosted.md and alert.md -> "Managed voice". A reader who follows either pointer won't find the rule. The spec lint only checks that a heading exists, so it won't catch this.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hosted enrollment from the desktop goes back to main's vocabulary: the button is "Enroll with hosted.dormouse.sh", Managed voice offers Disconnect and Enroll again, and the ended and redeeming copy says enrolled. Comments, specs, and identifiers follow (HostedSignIn.tsx -> HostedEnrollment.tsx, signInAgain -> reenrollBurrow). Browser sign-in on hosted.dormouse.sh and Pocket's phone sign-in keep "sign in". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
docs/specs/pricing.md -> "Checkout and entitlement" still says "A desktop signs in from Settings by device code" under ## Future. This PR builds that flow and took "desktop sign-in" out of the hosted-sales scope, so this bullet is built design left below the fold. It also uses the sign-in wording this push dropped everywhere else. docs/specs/relay.md -> "Burrow side" and docs/specs/alert.md -> "Managed voice" already state the token handoff and the never-a-webview rule, so the bullet can be deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
With the rename, the checkout notice tells a buyer that enrolling from the app is "the next stage of work". This PR ships that: Settings → Managed voice enrolls through HostedEnrollment.tsx, and Remote control already enrolled before it. Only purchase is unbuilt, so a reader who has already enrolled a machine sees the page call it unfinished. The suggestions drop enrollment from both the notice and the CheckoutTodo doc comment.
Conflicts: - docs/specs/security-hosted.md: main gave every FAIL IF a rule ID; kept this branch's rule text with main's IDs in order (S85, the managed-voice rule, keeps its ID across the entitlement rewrite). - scripts/spec-word-budgets.json: main's security budgets; alert, hosted, relay, and security-hosted re-ratcheted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re and the checkout notice dormouse-bot: the disclosure rule lives in alert.md -> "Settings dialog", so the MANAGED_VOICE_DISCLOSURE comment and pricing.md's pointer name it there; pricing.md's Future no longer lists the desktop's device-code enrollment this PR builds; and the /hosted checkout notice says only purchase is unbuilt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormouse-bot: the account page offered Revoke on a token an enrollment minted. Revoked alone, its next speak got 401, Settings asked for a fresh enrollment, and enrolling again minted a new burrowId that every paired phone had to pair with again. The token list now marks an enrollment's token, the page shows it without Revoke, and DELETE answers 409 for it; removing the computer already deletes the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
Both 007_voice_token_burrow.sql and the new 008_independent_voice.sql are added by this PR, and main stops at 006. So no production database has ever had a computer-linked voice token, but 008 is written as if one had. It adds a nullable burrowId column, an ON DELETE CASCADE FK, and a migration that nulls it "so rolling deployments can still read it". Around that it keeps the "burrowId" IS NOT NULL AS computer projection in GET /api/voice/tokens, the computer field in hosted/src/App.tsx, a workers test for "a legacy computer-linked token", and the hosted.md sentence "Existing computer-linked tokens are detached by migration". Since 007 isn't merged, "Never edit a merged migration" doesn't apply to it. Folding the purpose column and constraint into 007 and dropping burrowId removes the dead column and every one of those paths.
It also avoids a numbering collision in the stack. #1001 (via alarm-upsell) adds its own 008_billing_founders.sql on top of a base that has only 007, and #1002 then adds 009. If this PR keeps an 008, both of them have to renumber.
The voice approval loop in createManagedVoiceHost re-implements what beginHostedEnrollment / pollHostedEnrollment in lib/src/remote/burrow/enrollment.ts and the run in BurrowService already do: the 15-minute local deadline (HOSTED_ENROLLMENT_MAX_WAIT_MS there, 15 * 60_000 here), the +5 s slow-down on 429 capped at MAX_ENROLL_POLL_INTERVAL_S, 5xx and transport retries, and the retry when a 2xx body is lost mid-read. The two copies already differ: on a 403, the remote poll checks the body for NOT_ENTITLED_ERROR, while the voice poll treats every 403 as "no Hosted plan". A later fix to either loop's lost-answer handling won't reach the other, so the poll belongs in one place, parameterized by purpose and by how a success is parsed.
credential (save / clear) is still returned from createManagedVoiceHost, but the sidecar no longer passes it to anything, so its only callers are tests. It's a second token writer, which the specs now say doesn't exist ("only managed-voice approval writes it"). The tests could seed the token through beginEnrollment or the state file, and the surface could be dropped.
Superseded by the review on a later commit.
dormouse-bot
left a comment
There was a problem hiding this comment.
The branch now conflicts with main in scripts/spec-word-budgets.json (the docs/specs/alert.md budget: 7000 here, 6900 on main), so no CI has run on ee11fa3. The ratchet after merging will settle the right value.
Removing computer from VoiceToken left its doc comment behind, attached to nothing (suggestion inline).
dormouse-bot
left a comment
There was a problem hiding this comment.
Build & Test fails on 7222ab1 in scripts/security-audit-canary.test.mjs: the seed .github/audit/canaries/burrow-fetch-global-fallback.patch no longer applies (patch failed: scripts/outbound-lint.mjs:132). One of the seed's context lines is the lib/src/remote/burrow/enrollment.ts reason in OUTBOUND_SITES, and this PR rewrote that reason. main passes at 72ce7a8.
The fix is to change that context line in the seed, which sits outside the diff:
- reason: 'The enrollment exchange calls only the fetch parameter, which the service passes as its guarded fetch.',
+ reason: 'The enrollment exchange calls only its fetch parameter: the Burrow service or managed-voice host supplies a network-policy guard.',With only that line changed, git apply --check succeeds on the merged tree, and every other seed in .github/audit/canaries/ still applies. I can push this if you'd like.
Argos also reports visual changes on the Storybook builds, which need a maintainer to review them there.
CI failed — the burrow-fetch-global-fallback audit canary no longer applies
# Conflicts: # scripts/spec-word-budgets.json
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The voice Worker answered a voice-purpose poll with untyped JSON and the desktop parsed it with a private type. BurrowVoicePollResponse and its guard now sit beside BurrowEnrollPollResponse, so both ends hold to one shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Managed voice is seamless: the Disconnect hint no longer sends the user to revoke a token, and the save-failure, lost-answer, and failed-disconnect messages say what happened without one. A token never saved or never received is held by no one, so there is nothing for the user to revoke; revoking server-side on Disconnect is the next PR's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's #999 moved managed voice off the Burrow service: the voice host runs its own approval and Disconnect. VS Code now runs that host in every window over SecretStorage, so an approval or Disconnect in one window reaches the rest; the Burrow service no longer hands a token to managed voice. A re-read never re-arms a token this window's failed Disconnect left in the store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Desktop users can enable managed voice and enroll remote control independently. Each has its own Hosted approval, credential, and local Disconnect; enabling voice never enrolls a Burrow, and enrolling or disconnecting remote control never changes voice or its saved token.
Managed voice shows the pane-label/ElevenLabs disclosure before approval, offers the curated voices, and caches clips in host memory. Voice tokens stay in the host. Entitlement checks are centralized and remain admin-only until billing ships.
BurrowVoicePollResponsein the shared wire contract, beside the remote one, so the relay and the desktop hold to one shape.docs/specs/alert.md-> "Settings dialog"). Revoking the token server-side on Disconnect, and taking voice tokens off the account page, is the next PR.Validation: all 178 Hosted tests and 42 deployment tests passed; all non-Hosted package suites passed, including 5,894 lib, 296 standalone, 223 VS Code, and 414 website tests. The review follow-up passed 226 enrollment, service, and voice regressions, including the failed-clear case. Lib, standalone, and Hosted typechecks passed, as did the root spec/security lint stages. Verified the separate controls in the standalone browser harness. Package socket tests required execution outside the sandbox; the harness’s generated CLI bundle was temporarily moved aside to keep an unrelated source-only scanner from scanning bundled dependencies.
Live ElevenLabs synthesis against a deployed Hosted preview remains unverified.
🤖 Generated with Claude Code