From 97417c121cdab82474a3b9fb8a7ef67f8388ab3c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 22 Oct 2026 17:55:49 -0700 Subject: [PATCH 01/11] Hosted: tell an account with no plan before it types digits; actionable empty Computers copy An account without the entitlement followed an enrollment link, typed the two digits, and got the raw NOT_ENTITLED_ERROR with a Retry that could never help. The enroll page now reads the entitlement up front from GET /api/voice (a failed read never counts as unentitled), maps the approve route's 403 NOT_ENTITLED_ERROR to NoPlanError, and in either case shows why in place of the digits, with Use another account (sign out, code kept) beside Don't approve. The empty Computers list now names the desktop path to enroll one. Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 1 + hosted/src/App.test.tsx | 72 +++++++++++++++++++++++ hosted/src/App.tsx | 14 ++++- hosted/src/HostedPage.tsx | 63 ++++++++++++-------- hosted/src/api.ts | 9 ++- hosted/src/stories/Enrollment.stories.tsx | 10 ++++ hosted/src/stories/fixtures.tsx | 2 +- scripts/spec-word-budgets.json | 2 +- 8 files changed, 142 insertions(+), 31 deletions(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index 5d05777ad..cc8020c46 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -236,6 +236,7 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that their - **Must remove a passkey by deleting its row**, every session it signed in cascading, so it signs in to nothing again; every session names the passkey that signed it in, its `"credentialId"` `NOT NULL`. - **Must take the `/enroll` fragment before render and erase it from history, and hold the code in memory only**: through email sign-in and back, never into storage. Provider sign-in leaves the page, so the user opens the link again. - **Must preserve newer enrollments when earlier approvals complete.** +- **Never offer the digits to an account the server reads as unentitled**, by `GET /api/voice` or the approve route's 403 `NOT_ENTITLED_ERROR`, never by a failed read; the page offers another account instead. **Must bind every approval to its purpose, defaulting omitted purpose to remote control.** Voice approvals use a distinct code derivation and scope. Voice clients require begin to acknowledge their purpose before displaying or polling a code. Voice redemption atomically marks the approval and mints a token, without a Burrow or its cap; later polls report it spent. A remote poll cannot redeem a voice approval, or vice versa. Both purposes share the digits, approval gates, and expiry. diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index 1d549e1dd..f748d2990 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -7,6 +7,7 @@ import { App } from "./App"; import * as api from "./api"; import type { Enrollment } from "./enrollment"; import { ENROLLMENT_POLL_INTERVAL_S, TERMS_VERSION } from "../server/policy-constants"; +import { NOT_ENTITLED_ERROR } from "../../remote-lib-common/src/remote/wire.ts"; vi.mock("./api", async (original) => ({ ...await original(), @@ -202,3 +203,74 @@ describe("Natural Voice confirmation", () => { expect(button("Retry connection").disabled).toBe(false); }); }); + +describe("an account with no plan", () => { + const noPlanShown = () => container.textContent!.includes("not available to this account"); + beforeEach(() => { + vi.mocked(api.getSession).mockResolvedValue(login()); + }); + + it.each(["remote", "voice"] as const)("is told before typing digits for %s", async (purpose) => { + vi.mocked(api.getHostedVoice).mockResolvedValue({ entitled: false, on: false }); + await mount({ ...ENROLLMENT, purpose }); + expect(container.querySelector("#confirm-digits")).toBeNull(); + expect(noPlanShown()).toBe(true); + expect(button("Don’t approve").disabled).toBe(false); + }); + + it("is told when the approval is refused for want of a plan", async () => { + vi.mocked(api.approveEnrollment).mockRejectedValue(new api.NoPlanError(NOT_ENTITLED_ERROR)); + await mount(ENROLLMENT); + await approve(); + expect(container.querySelector("#confirm-digits")).toBeNull(); + expect(noPlanShown()).toBe(true); + expect(container.querySelector("[role=alert]")).toBeNull(); + expect(location.pathname).toBe("/enroll"); + }); + + it("is never assumed from a failed read or a failed approval", async () => { + vi.mocked(api.getHostedVoice).mockRejectedValue(new Error("Could not connect.")); + vi.mocked(api.getComputers).mockRejectedValue(new Error("Could not connect.")); + vi.mocked(api.approveEnrollment).mockRejectedValue(new Error("Could not connect.")); + await mount(ENROLLMENT); + expect(noPlanShown()).toBe(false); + await approve(); + expect(noPlanShown()).toBe(false); + expect(container.querySelector("#confirm-digits")).not.toBeNull(); + expect(container.querySelector("[role=alert]")?.textContent).toContain("Could not connect."); + }); + + it("keeps the code through Use another account", async () => { + vi.mocked(api.approveEnrollment).mockRejectedValueOnce(new api.NoPlanError(NOT_ENTITLED_ERROR)); + await mount(ENROLLMENT); + await approve(); + vi.mocked(api.getSession).mockResolvedValue(null); + await click("Use another account"); + expect(api.post).toHaveBeenCalledWith("sign-out"); + expect(location.pathname).toBe("/enroll"); + await signIn(); + await approve(); + expect(api.approveEnrollment).toHaveBeenLastCalledWith("7KQM-X4TD", "remote", "47"); + expect(location.pathname).toBe("/account"); + }); +}); + +describe("approveEnrollment", () => { + const actual = () => vi.importActual("./api"); + const answer = (status: number, message: string) => + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ message }, { status })); + afterEach(() => vi.restoreAllMocks()); + + it("reads a 403 NOT_ENTITLED_ERROR as no plan", async () => { + const { approveEnrollment, NoPlanError } = await actual(); + answer(403, NOT_ENTITLED_ERROR); + await expect(approveEnrollment("7KQM-X4TD", "remote", "47")).rejects.toBeInstanceOf(NoPlanError); + }); + it("reads any other refusal as its message", async () => { + const { approveEnrollment, NoPlanError } = await actual(); + answer(403, "Sign in again to approve."); + const refusal = approveEnrollment("7KQM-X4TD", "remote", "47"); + await expect(refusal).rejects.toThrow("Sign in again to approve."); + await expect(refusal).rejects.not.toBeInstanceOf(NoPlanError); + }); +}); diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index e2cf3c53b..3b1034c27 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -15,6 +15,7 @@ import { getPasskeys, getProviders, getSession, + NoPlanError, post, removeComputer, removePasskey, @@ -117,6 +118,9 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [enterCode, setEnterCode] = useState(false); + // The server refused this account's approval for want of a plan: until it + // signs out, the page offers it no approval, as when its read says so. + const [refusedPlan, setRefusedPlan] = useState(false); const actionPending = useRef(false); const refreshGeneration = useRef(0); // Bumped by every action and on unmount: stops re-reading for a Pending. @@ -246,7 +250,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { try { await action(); } catch (error) { - setError(pageError(error)); + if (error instanceof NoPlanError) setRefusedPlan(true); + else setError(pageError(error)); } finally { actionPending.current = false; setBusy(""); @@ -320,6 +325,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setHostedVoice(null); setComputers(null); setPasskeys(null); + setRefusedPlan(false); setEnterCode(false); setCode(""); setNotice({ @@ -385,6 +391,10 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { session && Date.now() - Date.parse(session.session.createdAt) < LOGIN_FRESH_AGE_MS; + // Natural Voice's read carries the one entitlement both purposes share; a + // failed read leaves it null, which never reads as no plan. + const noPlan = refusedPlan || hostedVoice?.entitled === false; + const changeEmail = () => { setEnterCode(false); setCode(""); @@ -395,7 +405,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { return {enrolling.code ? ( - <> - {fresh ? ( - void approve(enrolling.code!, digits)} - /> - ) : ( - <> -

- Approving a computer needs a login from the last{" "} - {RECENT_LOGIN_WINDOW}. Sign in again; this code stays - on this page. -

- - - )} - + noPlan || !fresh ? ( + <> +

+ {noPlan ? ( + "Natural Voice and remote control are not available to this account yet, so it cannot approve this computer. To use another account, sign in again; this code stays on this page." + ) : ( + <> + Approving a computer needs a login from the last{" "} + {RECENT_LOGIN_WINDOW}. Sign in again; this code stays + on this page. + + )} +

+ + + ) : ( + void approve(enrolling.code!, digits)} + /> + ) ) : (

This link has no valid code. Start again from Dormouse on @@ -363,8 +373,9 @@ export function HostedPage({

{computers.length === 0 && (

- No computers yet. Enroll one from Dormouse on that - computer. + No computers yet. To enroll one, open Dormouse on that + computer and go to Settings → Network → Phones → + Persistent Relay → Enroll remote control.

)} {computers.map((computer) => ( diff --git a/hosted/src/api.ts b/hosted/src/api.ts index 4244746e2..ec411f2d8 100644 --- a/hosted/src/api.ts +++ b/hosted/src/api.ts @@ -1,5 +1,6 @@ import { providerIds, providerNames } from "../server/providers.js"; import type { ProviderId } from "../server/providers.js"; +import { NOT_ENTITLED_ERROR } from "../../remote-lib-common/src/remote/wire.ts"; export const providers = providerIds; export type Provider = ProviderId; @@ -169,6 +170,8 @@ export async function removePasskey(credentialId: string) { }); if (!response.ok) throw await refused(response); } +/** An approval the server refused because this account has no plan. */ +export class NoPlanError extends Error {} export async function approveEnrollment( userCode: string, purpose: "remote" | "voice" | undefined, @@ -179,7 +182,11 @@ export async function approveEnrollment( headers: { "content-type": "application/json" }, body: JSON.stringify({ userCode, purpose: purpose ?? "remote", confirmDigits }), }); - if (!response.ok) throw await refused(response); + if (response.ok) return; + const error = await refused(response); + throw response.status === 403 && error.message === NOT_ENTITLED_ERROR + ? new NoPlanError(error.message) + : error; } /** What a poll made of this account's live approval of a code; neither when there is none. */ export interface ApprovalState { diff --git a/hosted/src/stories/Enrollment.stories.tsx b/hosted/src/stories/Enrollment.stories.tsx index 84804c3b3..ded12bceb 100644 --- a/hosted/src/stories/Enrollment.stories.tsx +++ b/hosted/src/stories/Enrollment.stories.tsx @@ -13,6 +13,16 @@ export const Remote: Story = {}; export const Voice: Story = { args: { enrolling: { ...ENROLLMENT, purpose: "voice" } } }; export const StaleRemote: Story = { args: { fresh: false } }; export const StaleVoice: Story = { args: { ...Voice.args, fresh: false } }; +export const NoPlan: Story = { + args: { noPlan: true, signOut: fn(async () => {}) }, + play: async ({ canvasElement, args }) => { + const canvas = await settledFrame(canvasElement); + await expect(canvas.queryByLabelText("Type the two digits Dormouse is showing")).toBeNull(); + await canvas.user.click(canvas.getByRole("button", { name: "Use another account" })); + await expect(args.signOut).toHaveBeenCalledOnce(); + }, +}; +export const NoPlanVoice: Story = { args: { ...Voice.args, noPlan: true } }; export const InvalidLink: Story = { args: { enrolling: { code: null } } }; export const InvalidLinkSignedOut: Story = { args: { session: null, enrolling: { code: null } } }; export const Approving: Story = { args: { busy: "approve" } }; diff --git a/hosted/src/stories/fixtures.tsx b/hosted/src/stories/fixtures.tsx index e9fe1e9ee..2bed45b23 100644 --- a/hosted/src/stories/fixtures.tsx +++ b/hosted/src/stories/fixtures.tsx @@ -31,7 +31,7 @@ export function HostedStory({ phone, light, ...args }: HostedStoryArgs) { const turnOffDialog = useRef(null); return { await action(); }} refresh={idle} approve={idle} signOut={idle} clearEnrollment={nothing} turnOffVoice={idle} diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 760b0c135..0e2151994 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -12,7 +12,7 @@ "docs/specs/dor-tools-builtin.md": 1750, "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 4600, + "docs/specs/hosted.md": 4650, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4200, From b0fd88a737610a0722dafd8632fce4ecabe5bfeb Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 22 Oct 2026 17:57:04 -0700 Subject: [PATCH 02/11] Spec: state the no-plan approval contract, not its routes Co-Authored-By: Claude Opus 5.5 --- docs/specs/hosted.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index cc8020c46..6a47d1f86 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -236,7 +236,7 @@ Errors are the managed-voice cookie routes' ("Managed voice"), except that their - **Must remove a passkey by deleting its row**, every session it signed in cascading, so it signs in to nothing again; every session names the passkey that signed it in, its `"credentialId"` `NOT NULL`. - **Must take the `/enroll` fragment before render and erase it from history, and hold the code in memory only**: through email sign-in and back, never into storage. Provider sign-in leaves the page, so the user opens the link again. - **Must preserve newer enrollments when earlier approvals complete.** -- **Never offer the digits to an account the server reads as unentitled**, by `GET /api/voice` or the approve route's 403 `NOT_ENTITLED_ERROR`, never by a failed read; the page offers another account instead. +- **Never offer the digits to an account the server says is unentitled**, and never conclude that from a failed read; the page offers another account instead. **Must bind every approval to its purpose, defaulting omitted purpose to remote control.** Voice approvals use a distinct code derivation and scope. Voice clients require begin to acknowledge their purpose before displaying or polling a code. Voice redemption atomically marks the approval and mints a token, without a Burrow or its cap; later polls report it spent. A remote poll cannot redeem a voice approval, or vice versa. Both purposes share the digits, approval gates, and expiry. From 2afe1f7f9e6aee35d3ff2331e99525004888c0fd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 11:36:43 -0700 Subject: [PATCH 03/11] Hosted: an account with no plan sees what a plan gives, not a dead end The no-plan enroll state was a paragraph and a sign-out button. It now shows an offer card leading with what Dormouse asked for (Natural Voice or remote control), what any plan includes, the founding price against list from website/src/lib/hosted-pricing.ts, and See Hosted plans in a new tab, since the code lives only in this page's memory. Use another account stays as the secondary path. Coming back to the tab re-reads the account, and a refusal from the approve route now clears once that read finds a plan, so a plan bought in the other tab offers the digits again. Co-Authored-By: Claude Opus 5.5 --- hosted/src/App.test.tsx | 17 +++++++++- hosted/src/App.tsx | 2 ++ hosted/src/HostedPage.tsx | 48 +++++++++++++++++---------- hosted/src/NoPlanOffer.tsx | 67 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 19 deletions(-) create mode 100644 hosted/src/NoPlanOffer.tsx diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index f748d2990..871e92347 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -205,7 +205,7 @@ describe("Natural Voice confirmation", () => { }); describe("an account with no plan", () => { - const noPlanShown = () => container.textContent!.includes("not available to this account"); + const noPlanShown = () => container.textContent!.includes("has no Hosted plan yet"); beforeEach(() => { vi.mocked(api.getSession).mockResolvedValue(login()); }); @@ -216,6 +216,9 @@ describe("an account with no plan", () => { expect(container.querySelector("#confirm-digits")).toBeNull(); expect(noPlanShown()).toBe(true); expect(button("Don’t approve").disabled).toBe(false); + const plans = container.querySelector('a[href="https://dormouse.sh/hosted/#pricing"]')!; + // In another tab, so this one keeps the code that lives only in memory. + expect(plans.target).toBe("_blank"); }); it("is told when the approval is refused for want of a plan", async () => { @@ -228,6 +231,18 @@ describe("an account with no plan", () => { expect(location.pathname).toBe("/enroll"); }); + it("offers the digits again once a plan is bought in another tab", async () => { + vi.mocked(api.approveEnrollment).mockRejectedValueOnce(new api.NoPlanError(NOT_ENTITLED_ERROR)); + await mount(ENROLLMENT); + await approve(); + expect(noPlanShown()).toBe(true); + vi.mocked(api.getHostedVoice).mockResolvedValue({ entitled: true, on: false }); + await act(async () => window.dispatchEvent(new Event("focus"))); + expect(noPlanShown()).toBe(false); + await approve(); + expect(api.approveEnrollment).toHaveBeenLastCalledWith("7KQM-X4TD", "remote", "47"); + }); + it("is never assumed from a failed read or a failed approval", async () => { vi.mocked(api.getHostedVoice).mockRejectedValue(new Error("Could not connect.")); vi.mocked(api.getComputers).mockRejectedValue(new Error("Could not connect.")); diff --git a/hosted/src/App.tsx b/hosted/src/App.tsx index 3b1034c27..5a4b40db8 100644 --- a/hosted/src/App.tsx +++ b/hosted/src/App.tsx @@ -164,6 +164,8 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setEnabled(providers); setAccounts(linked); setHostedVoice(voice); + // A plan bought since the approval was refused: offer the digits again. + if (voice?.entitled) setRefusedPlan(false); setComputers(enrolled); setPasskeys(registered); history.replaceState( diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx index c5bfae820..24ba4d1bd 100644 --- a/hosted/src/HostedPage.tsx +++ b/hosted/src/HostedPage.tsx @@ -24,6 +24,7 @@ import { providerNames, type Account, type Computer, type HostedVoice, type Pass import { RECENT_LOGIN_WINDOW, TERMS_VERSION } from "../server/policy-constants"; import { HELP_CLASS, INPUT_CLASS, LABEL_CLASS, LABEL_TEXT_CLASS, PRIMARY_CLASS } from "./classes"; import { ConfirmDigits } from "./ConfirmDigits"; +import { NoPlanOffer } from "./NoPlanOffer"; import type { Enrollment } from "./enrollment"; import type { Notice, PageError } from "./settle"; @@ -147,9 +148,13 @@ export function HostedPage({ ? !enrolling.code ? "This link cannot be approved." : session - ? enrolling.purpose === "voice" - ? "Dormouse asked to turn on Natural Voice with this account." - : "Dormouse asked to enroll remote control with this account." + ? noPlan + ? enrolling.purpose === "voice" + ? "Dormouse asked to turn on Natural Voice, but this account has no Hosted plan yet." + : "Dormouse asked to enroll remote control, but this account has no Hosted plan yet." + : enrolling.purpose === "voice" + ? "Dormouse asked to turn on Natural Voice with this account." + : "Dormouse asked to enroll remote control with this account." : "Sign in to approve the computer that sent you here." : session ? "Manage how you sign in." @@ -189,29 +194,36 @@ export function HostedPage({ {enrolling && (session || !enrolling.code) ? (
{enrolling.code ? ( - noPlan || !fresh ? ( + noPlan ? ( + <> + +

+ Subscribed with another account? Sign in with it; this + code stays on this page. +

+
+ +
+ + ) : !fresh ? ( <>

- {noPlan ? ( - "Natural Voice and remote control are not available to this account yet, so it cannot approve this computer. To use another account, sign in again; this code stays on this page." - ) : ( - <> - Approving a computer needs a login from the last{" "} - {RECENT_LOGIN_WINDOW}. Sign in again; this code stays - on this page. - - )} + Approving a computer needs a login from the last{" "} + {RECENT_LOGIN_WINDOW}. Sign in again; this code stays + on this page.

) : ( diff --git a/hosted/src/NoPlanOffer.tsx b/hosted/src/NoPlanOffer.tsx new file mode 100644 index 000000000..bba16b56a --- /dev/null +++ b/hosted/src/NoPlanOffer.tsx @@ -0,0 +1,67 @@ +import { ArrowSquareOutIcon, CheckIcon } from "@phosphor-icons/react"; +import { + ACCENT_TEXT_CLASS, + ACTION_CLASS, + CARD_ACCENT_CLASS, + CARD_MUTED_TEXT_CLASS, + DASHED_ROW_CLASS, +} from "dormouse-lib/site/docs-tokens"; +import { foundingTier, LIST_MONTHLY } from "../../website/src/lib/hosted-pricing.ts"; + +/** The Hosted page's plan cards, in a new tab so this one keeps its code. */ +export const HOSTED_PLANS_URL = "https://dormouse.sh/hosted/#pricing"; + +const VOICE = "A natural ElevenLabs voice for spoken alarms, on every computer you enroll"; +const POCKET = "Dormouse Pocket on your phone through the managed Relay, no tailnet needed"; +const PUSH = "Sealed push notifications when a terminal needs you"; +const NO_LIMIT = "Every computer you use: no device limit, no per-device charge"; + +/** + * What a plan would give the account that could not approve this computer, + * leading with what Dormouse asked for. Coming back to this tab re-reads the + * account, so a plan bought in the other one shows the digits here. + */ +export function NoPlanOffer({ purpose }: { purpose: "remote" | "voice" | undefined }) { + const voice = purpose === "voice"; + const founding = foundingTier(); + return ( +
+

+ {voice ? "Natural Voice" : "Remote control"} comes with Dormouse Hosted +

+
    + {(voice ? [VOICE, POCKET, PUSH] : [POCKET, PUSH, NO_LIMIT, VOICE]).map((item) => ( +
  • +
  • + ))} +
+

+ ${founding.listPrice} + ${founding.price} + {founding.per} +

+

+ Founding price, locked while you stay subscribed. Or ${LIST_MONTHLY}/month. +

+ + See Hosted plans + +

+ 30-day refund. This code waits here while you look. +

+
+ ); +} From 251e217b1bbbe3d90560dc27f92a7471eb25dd03 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 11:46:40 -0700 Subject: [PATCH 04/11] No-plan offer: drop "no tailnet needed" Co-Authored-By: Claude Opus 5.5 --- hosted/src/NoPlanOffer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosted/src/NoPlanOffer.tsx b/hosted/src/NoPlanOffer.tsx index bba16b56a..f7dd7fb3a 100644 --- a/hosted/src/NoPlanOffer.tsx +++ b/hosted/src/NoPlanOffer.tsx @@ -12,7 +12,7 @@ import { foundingTier, LIST_MONTHLY } from "../../website/src/lib/hosted-pricing export const HOSTED_PLANS_URL = "https://dormouse.sh/hosted/#pricing"; const VOICE = "A natural ElevenLabs voice for spoken alarms, on every computer you enroll"; -const POCKET = "Dormouse Pocket on your phone through the managed Relay, no tailnet needed"; +const POCKET = "Dormouse Pocket on your phone through the managed Relay"; const PUSH = "Sealed push notifications when a terminal needs you"; const NO_LIMIT = "Every computer you use: no device limit, no per-device charge"; From 9e63f4efbd93ef3f19f423934d355bc593010c98 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 11:46:40 -0700 Subject: [PATCH 05/11] Storybook: Hosted stories scroll lib/src/index.css hides the body's overflow for the app, and Storybook's preview inherits it; preview-head.html gave scrolling back only to docs pages. HostedFrame grows its iframe to the page it shows, past the viewport, so the bottom of a Hosted story was unreachable. Pages holding a Hosted frame now scroll too. Every Hosted snapshot is byte-identical. Co-Authored-By: Claude Opus 5.5 --- lib/.storybook/preview-head.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/.storybook/preview-head.html b/lib/.storybook/preview-head.html index 8ba7f3075..5d1a33a5b 100644 --- a/lib/.storybook/preview-head.html +++ b/lib/.storybook/preview-head.html @@ -12,9 +12,13 @@ `#storybook-docs` in the DOM at all times and toggles `display` between them, so `:has(#storybook-docs)` alone would match story pages too and hand every snapshot an unwanted scrollbar. + + Hosted's stories get it back too: `HostedFrame` grows its iframe to the page + it shows, which runs past the viewport, so only the outer document can scroll. --> From 164998e0e9b0295826f1375e2b476ed69ad679c5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 12:20:59 -0700 Subject: [PATCH 06/11] No-plan remote control: offer self-hosting a Relay too Natural Voice has no self-hosted equivalent, so its no-plan state does not. Co-Authored-By: Claude Opus 5.5 --- hosted/src/App.test.tsx | 2 ++ hosted/src/HostedPage.tsx | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index 871e92347..ce0b39a7a 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -219,6 +219,8 @@ describe("an account with no plan", () => { const plans = container.querySelector('a[href="https://dormouse.sh/hosted/#pricing"]')!; // In another tab, so this one keeps the code that lives only in memory. expect(plans.target).toBe("_blank"); + // Natural Voice has no self-hosted equivalent; a Relay does. + expect(container.querySelector('a[href="https://dormouse.sh/self-host/"]') !== null).toBe(purpose === "remote"); }); it("is told when the approval is refused for want of a plan", async () => { diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx index 24ba4d1bd..737c96da9 100644 --- a/hosted/src/HostedPage.tsx +++ b/hosted/src/HostedPage.tsx @@ -29,6 +29,7 @@ import type { Enrollment } from "./enrollment"; import type { Notice, PageError } from "./settle"; export const SUBSCRIBE_URL = "https://nedshed.dev/subscribe"; +const SELF_HOST_URL = "https://dormouse.sh/self-host/"; /** The site header's own links (`NAV_LINKS` in website/src/lib/site-nav.ts), * absolute because this is another origin. Playground also gives way on a @@ -210,6 +211,21 @@ export function HostedPage({ {busy === "logout" ? "Signing out…" : "Use another account"} + {/* Natural Voice has no self-hosted equivalent. */} + {enrolling.purpose !== "voice" && ( +

+ Rather run your own Relay? Self-hosting stays free:{" "} + + how to self-host + + . +

+ )} ) : !fresh ? ( <> From 28012612e21d7e5f8f14c8a68561182328ad6985 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 12:27:45 -0700 Subject: [PATCH 07/11] No-plan remote control: the self-host line sits right under the card Co-Authored-By: Claude Opus 5.5 --- hosted/src/HostedPage.tsx | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx index 737c96da9..9d32a53ef 100644 --- a/hosted/src/HostedPage.tsx +++ b/hosted/src/HostedPage.tsx @@ -198,23 +198,10 @@ export function HostedPage({ noPlan ? ( <> -

- Subscribed with another account? Sign in with it; this - code stays on this page. -

-
- -
{/* Natural Voice has no self-hosted equivalent. */} {enrolling.purpose !== "voice" && (

- Rather run your own Relay? Self-hosting stays free:{" "} + Rather run your own Relay for free? See{" "} )} +

+ Subscribed with another account? Sign in with it; this + code stays on this page. +

+
+ +
) : !fresh ? ( <> From 72085699a98e50d5eba92710ba06ea7149a604c8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 12:38:22 -0700 Subject: [PATCH 08/11] No-plan remote control: the self-host line also follows the intro Co-Authored-By: Claude Opus 5.5 --- hosted/src/App.test.tsx | 2 +- hosted/src/HostedPage.tsx | 28 ++++++++++++++++------------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/hosted/src/App.test.tsx b/hosted/src/App.test.tsx index ce0b39a7a..380962f66 100644 --- a/hosted/src/App.test.tsx +++ b/hosted/src/App.test.tsx @@ -220,7 +220,7 @@ describe("an account with no plan", () => { // In another tab, so this one keeps the code that lives only in memory. expect(plans.target).toBe("_blank"); // Natural Voice has no self-hosted equivalent; a Relay does. - expect(container.querySelector('a[href="https://dormouse.sh/self-host/"]') !== null).toBe(purpose === "remote"); + expect(container.querySelectorAll('a[href="https://dormouse.sh/self-host/"]').length).toBe(purpose === "remote" ? 2 : 0); }); it("is told when the approval is refused for want of a plan", async () => { diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx index 9d32a53ef..627ecf738 100644 --- a/hosted/src/HostedPage.tsx +++ b/hosted/src/HostedPage.tsx @@ -56,6 +56,20 @@ const FOOTNOTE_CLASS = `mt-10 text-sm ${MUTED_TEXT_CLASS}`; /** A computer's id in the shape the app draws a minimized Pane: a door. */ const DOOR_CLASS = `${DOOR_SHAPE_CLASS} ml-1.5 inline-flex items-center justify-center border-b border-[var(--color-text)]/30 bg-[var(--color-text)]/[0.12] px-2.5`; +/** The free alternative to a plan, for remote control only: Natural Voice has + * no self-hosted equivalent. */ +function SelfHostLine() { + return ( + <> + Rather run your own Relay for free? See{" "} +
+ how to self-host + + . + + ); +} + /** A state already in words, ticked as the plan cards tick theirs. */ function Ticked({ children }: { children: string }) { return ( @@ -152,7 +166,7 @@ export function HostedPage({ ? noPlan ? enrolling.purpose === "voice" ? "Dormouse asked to turn on Natural Voice, but this account has no Hosted plan yet." - : "Dormouse asked to enroll remote control, but this account has no Hosted plan yet." + : <>Dormouse asked to enroll remote control, but this account has no Hosted plan yet. : enrolling.purpose === "voice" ? "Dormouse asked to turn on Natural Voice with this account." : "Dormouse asked to enroll remote control with this account." @@ -198,19 +212,9 @@ export function HostedPage({ noPlan ? ( <> - {/* Natural Voice has no self-hosted equivalent. */} {enrolling.purpose !== "voice" && (

- Rather run your own Relay for free? See{" "} - - how to self-host - - . +

)}

From 6129ea3b9db2579194fd36eee1f71d52fbf461f8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 14:43:32 -0700 Subject: [PATCH 09/11] No-plan offer: shorter voice and device-limit lines Co-Authored-By: Claude Opus 5.5 --- hosted/src/NoPlanOffer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hosted/src/NoPlanOffer.tsx b/hosted/src/NoPlanOffer.tsx index f7dd7fb3a..bf58a1122 100644 --- a/hosted/src/NoPlanOffer.tsx +++ b/hosted/src/NoPlanOffer.tsx @@ -11,10 +11,10 @@ import { foundingTier, LIST_MONTHLY } from "../../website/src/lib/hosted-pricing /** The Hosted page's plan cards, in a new tab so this one keeps its code. */ export const HOSTED_PLANS_URL = "https://dormouse.sh/hosted/#pricing"; -const VOICE = "A natural ElevenLabs voice for spoken alarms, on every computer you enroll"; +const VOICE = "Natural Voice powered by ElevenLabs for spoken alarms"; const POCKET = "Dormouse Pocket on your phone through the managed Relay"; const PUSH = "Sealed push notifications when a terminal needs you"; -const NO_LIMIT = "Every computer you use: no device limit, no per-device charge"; +const NO_LIMIT = "No device limit, no per-device charge"; /** * What a plan would give the account that could not approve this computer, From ad1cf458e29684ea60c7e0537dafd34f437e898f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 14:54:12 -0700 Subject: [PATCH 10/11] Name it the Hosted Relay, not the managed Relay The product is Dormouse Hosted, and the account page, the server's refusal and the specs already called its Relay the Hosted Relay; only the marketing copy said "managed". The Hosted page's heading, contents and meta, the JSON-LD, both READMEs, the Pocket playground, the no-plan offer, and the Terms and Privacy pages now say Hosted Relay, the pair to the "self-hosted Relay" the same pages offer. Terms and Privacy are revised for it, so TERMS_VERSION and both pages' "Last updated" move to 2026-10-27, as the Natural Voice rename did. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/specs/glossary.md | 2 +- docs/specs/pricing.md | 6 +++--- docs/specs/website-docs.md | 4 ++-- hosted/server/policy-constants.ts | 2 +- hosted/src/NoPlanOffer.tsx | 2 +- vscode-ext/README.md | 2 +- website/src/components/HostedPolicyLayout.tsx | 2 +- website/src/components/HostingRequirementNotice.tsx | 2 +- website/src/lib/docs-rail.test.tsx | 2 +- website/src/lib/hosted-pricing.ts | 2 +- website/src/pages/Hosted.tsx | 6 +++--- website/src/pages/PocketPlayground.tsx | 7 ++++--- website/src/pages/Privacy.tsx | 4 ++-- website/src/pages/Terms.tsx | 2 +- 15 files changed, 24 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 68e13954f..c253fc42a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A multitasking terminal for VS Code and the desktop — a real tiling layout, tm - [Compatible agents](https://dormouse.sh/compatible-agents) — conversation recovery, watching, and contributing an agent integration - [Agent skill](https://dormouse.sh/agent-skill) — the operating guide Dormouse bundles for coding agents - [Self-host](https://dormouse.sh/self-host) — run the coordinating Relay on your own tailnet -- [Hosted](https://dormouse.sh/hosted/) — managed Relay and ElevenLabs voice, from $10 a month +- [Hosted](https://dormouse.sh/hosted/) — the Hosted Relay and Natural Voice, from $10 a month - [Security](https://dormouse.sh/security) — what Dormouse guarantees, what it does not, and how that is checked ## Features diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 8b216a25d..a60f45626 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -92,7 +92,7 @@ Remote control has exactly three roles. `docs/specs/remote-security-model.md` ow |---|---|---| | **Burrow** | The app that owns terminal Surfaces and the processes behind them: the Standalone app, or the VS Code extension. **Two on one machine are two Burrows**, enrolled and paired separately, and Pocket lists them as two rows. | Every remote-access grant. Pairing approval and the ACL live here and nowhere else. | | **Client** | What controls a Burrow from somewhere else. **Pocket** is the phone Client (`docs/specs/pocket-app.md`); the **one-time page** Hosted serves is a Client for one session (`docs/specs/one-time.md`); a VR headset is a future one (`docs/specs/remote-api.md` → Future). | Nothing on its own — a Client asks. | -| **Relay** | The coordinating server: accounts, presence, push fan-out, and an encrypted byte pipe between Client and Burrow (`docs/specs/relay.md`). **Dormouse Hosted** is the managed Relay; `SELF_HOST.md` runs your own. | Routing. It holds no terminal and no authorization. | +| **Relay** | The coordinating server: accounts, presence, push fan-out, and an encrypted byte pipe between Client and Burrow (`docs/specs/relay.md`). **Dormouse Hosted** runs the Hosted Relay; `SELF_HOST.md` runs your own. | Routing. It holds no terminal and no authorization. | **Hosted's one-time room is a rendezvous, not a fourth role**: it forwards only a one-time connection's handshake frames, then closes (`docs/specs/one-time.md` -> "Hosted rendezvous"). diff --git a/docs/specs/pricing.md b/docs/specs/pricing.md index c861ca41f..490c64caa 100644 --- a/docs/specs/pricing.md +++ b/docs/specs/pricing.md @@ -69,7 +69,7 @@ What each plan grants once checkout can sell it; [Published prices](#published-p - **Must lock the founding base yearly price in USD while the subscription remains active**, excluding applicable taxes; a lapsed founder re-subscribes at list. **Must preserve the lock through billing-provider migrations and failures caused by DiffPlug**, allowing payment restoration. - **Cohorts close by count, never by date.** The count is completed purchases at the billing provider; a full refund or finally reversed payment returns the seat to its cohort. - **When a cohort closes the price rises one step and the counter resets to 100.** -- **Founding closes only when the ladder reaches list.** Founding means bought at launch pricing; the hosted Relay shipping does not close it. +- **Founding closes only when the ladder reaches list.** Founding means bought at launch pricing; the Hosted Relay shipping does not close it. - **Checkout honors the price it opened at.** Concurrent checkouts may oversell a cohort by a few seats; the overage is the customer's, and the next cohort still opens at a full 100. - **List may rise while founding is open, and never falls.** Monthly and yearly move together the same day, so list is always a price someone can buy at; the ladder keeps climbing $10 per cohort toward the new list; every founder's lock and the struck price they were shown are unchanged. - **Never reopen the ladder at a lower step** once a cohort has closed. @@ -81,11 +81,11 @@ What each plan grants once checkout can sell it; [Published prices](#published-p |---|---| | Natural Voice for spoken alarms on every machine the member signs in on | live | | A member default voice, chosen from a curated set | live | -| Dormouse Hosted: the managed Relay, enrollment of the member's Burrows, sealed push, Pocket without a tailnet | live | +| The Hosted Relay: enrollment of the member's Burrows, sealed push, Pocket without a tailnet | live | | Founding badge | live for founding | - **The plan never grants team or enterprise capability** — org accounts, SSO, SCIM, BYOT, audit export. -- **The hosted Relay is part of the plan, never a second purchase.** Reserved: the **hosted-sales** scope reads the plan from the Hosted account's subscription ("Checkout and entitlement"), so a member never signs up twice. +- **The Hosted Relay is part of the plan, never a second purchase.** Reserved: the **hosted-sales** scope reads the plan from the Hosted account's subscription ("Checkout and entitlement"), so a member never signs up twice. - **Nothing shipped free is ever gated**: the terminal, `dor`, browser panes, the notepad, alerts with the system voice, the self-host Relay, and Pocket over a self-hosted Relay stay free, with no login. ### Checkout and entitlement diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md index 272a652cc..1676f2769 100644 --- a/docs/specs/website-docs.md +++ b/docs/specs/website-docs.md @@ -130,9 +130,9 @@ Source of truth: `DOCS_PAGES` in `website/src/lib/docs-pages.ts`; `DocsLayout` i **Must describe the account service and link its account app, privacy policy, and terms.** -**Must describe the managed Relay as Pocket's Relay:** terminals stay on an awake, online computer, and self-hosting remains. +**Must describe the Hosted Relay as Pocket's Relay:** terminals stay on an awake, online computer, and self-hosting remains. -**Must open `/self-host` with the Relay boundary:** Dormouse needs none; a new install opens no connection on its own (`docs/specs/remote-network.md` -> "Policy"); push and a paired phone need a Relay, and a one-time connection only Hosted's rendezvous of its handshake; it links `/hosted`. `/hosted`'s managed Relay section discloses metadata, labels the review pending, and links the model. `website/src/lib/docs-rail.test.tsx` pins both. +**Must open `/self-host` with the Relay boundary:** Dormouse needs none; a new install opens no connection on its own (`docs/specs/remote-network.md` -> "Policy"); push and a paired phone need a Relay, and a one-time connection only Hosted's rendezvous of its handshake; it links `/hosted`. `/hosted`'s Hosted Relay section discloses metadata, labels the review pending, and links the model. `website/src/lib/docs-rail.test.tsx` pins both. **Must also link it from** Pocket marketing/tutorial, self-host docs, and the speech and remote-control settings; `linkedFrom` owns the rest. diff --git a/hosted/server/policy-constants.ts b/hosted/server/policy-constants.ts index 63645cfd6..f2c8c23e3 100644 --- a/hosted/server/policy-constants.ts +++ b/hosted/server/policy-constants.ts @@ -32,4 +32,4 @@ export const ENROLLMENT_POLL_INTERVAL_S = 5; // (`website/src/components/HostedPolicyLayout.tsx`), which // `hosted/server/tests/policy.test.ts` pins this to. A new version is a new // acceptance row, never an edit of an old one. -export const TERMS_VERSION = "2026-10-20"; +export const TERMS_VERSION = "2026-10-27"; diff --git a/hosted/src/NoPlanOffer.tsx b/hosted/src/NoPlanOffer.tsx index bf58a1122..b2c06d183 100644 --- a/hosted/src/NoPlanOffer.tsx +++ b/hosted/src/NoPlanOffer.tsx @@ -12,7 +12,7 @@ import { foundingTier, LIST_MONTHLY } from "../../website/src/lib/hosted-pricing export const HOSTED_PLANS_URL = "https://dormouse.sh/hosted/#pricing"; const VOICE = "Natural Voice powered by ElevenLabs for spoken alarms"; -const POCKET = "Dormouse Pocket on your phone through the managed Relay"; +const POCKET = "Dormouse Pocket on your phone through the Hosted Relay"; const PUSH = "Sealed push notifications when a terminal needs you"; const NO_LIMIT = "No device limit, no per-device charge"; diff --git a/vscode-ext/README.md b/vscode-ext/README.md index 893834f11..df695f476 100644 --- a/vscode-ext/README.md +++ b/vscode-ext/README.md @@ -82,7 +82,7 @@ The [shortcut reference](https://github.com/diffplug/dormouse/blob/main/docs/spe ## Dormouse Hosted -Optional and paid: a managed Relay so Pocket works without running your own, and an ElevenLabs voice for spoken alarms. [See plans and pricing](https://dormouse.sh/hosted). +Optional and paid: the Hosted Relay, so Pocket works without running your own, and Natural Voice, an ElevenLabs voice for spoken alarms. [See plans and pricing](https://dormouse.sh/hosted). ## Standalone app diff --git a/website/src/components/HostedPolicyLayout.tsx b/website/src/components/HostedPolicyLayout.tsx index 5521b6176..6f2ea8f65 100644 --- a/website/src/components/HostedPolicyLayout.tsx +++ b/website/src/components/HostedPolicyLayout.tsx @@ -18,7 +18,7 @@ export default function HostedPolicyLayout({ path, title, applicability, section

Dormouse Hosted

{title}

-

Last updated . {applicability}

+

Last updated . {applicability}

{sections.map(({ id, title, body }) => (

{title}

diff --git a/website/src/components/HostingRequirementNotice.tsx b/website/src/components/HostingRequirementNotice.tsx index 539c48c3d..7947c87cd 100644 --- a/website/src/components/HostingRequirementNotice.tsx +++ b/website/src/components/HostingRequirementNotice.tsx @@ -4,7 +4,7 @@ import { sitePath } from "../lib/site-meta"; /** * The Relay boundary `/self-host` opens with: Dormouse needs no server, and * the remote features that do need one make no requests until it exists. - * `/hosted` opens with its plan cards instead, and its managed Relay section + * `/hosted` opens with its plan cards instead, and its Hosted Relay section * carries the hosted half (docs/specs/website-docs.md -> `/hosted`). */ export function HostingRequirementNotice() { diff --git a/website/src/lib/docs-rail.test.tsx b/website/src/lib/docs-rail.test.tsx index 64728d30a..3ae6c2282 100644 --- a/website/src/lib/docs-rail.test.tsx +++ b/website/src/lib/docs-rail.test.tsx @@ -77,7 +77,7 @@ describe("every page in the rail", () => { } }); - it("discloses Hosted's metadata in the managed Relay section", () => { + it("discloses Hosted's metadata in the Hosted Relay section", () => { const markup = renderToStaticMarkup(); const relay = markup.slice(markup.indexOf('id="remote-control"')); expect(relay).toContain("connection metadata"); diff --git a/website/src/lib/hosted-pricing.ts b/website/src/lib/hosted-pricing.ts index 8c1781c86..ff91105ec 100644 --- a/website/src/lib/hosted-pricing.ts +++ b/website/src/lib/hosted-pricing.ts @@ -113,7 +113,7 @@ export function pricingJsonLd(pageUrl: string): string { "@type": "Product", name: "Dormouse Hosted", description: - "The Individual plan for Dormouse: the managed Relay for Pocket and Natural Voice " + "The Individual plan for Dormouse: the Hosted Relay for Pocket and Natural Voice " + "for spoken alarms. The terminal itself stays free.", brand: { "@type": "Brand", name: "Dormouse" }, url: pageUrl, diff --git a/website/src/pages/Hosted.tsx b/website/src/pages/Hosted.tsx index 6fdc66551..17b7d69af 100644 --- a/website/src/pages/Hosted.tsx +++ b/website/src/pages/Hosted.tsx @@ -68,14 +68,14 @@ export function meta({ location }: MetaArgs) { return siteMeta(location.pathname, { title: "Dormouse Hosted", description: - "The terminal is free. Dormouse Hosted adds a managed Relay for Pocket and Natural Voice " + "The terminal is free. Dormouse Hosted adds the Hosted Relay for Pocket and Natural Voice " + `for spoken alarms — from $${LIST_MONTHLY} a month, with founding prices while they last.`, }); } export const HOSTED_TOC: TocEntry[] = [ { id: "voice", text: "Natural Voice", children: [] }, - { id: "remote-control", text: "The managed Relay", children: [] }, + { id: "remote-control", text: "The Hosted Relay", children: [] }, { id: "self-hosting", text: "Self-hosting stays free", children: [] }, { id: "account", text: "Your Hosted account", children: [] }, { id: "faq", text: "Questions", children: [] }, @@ -519,7 +519,7 @@ export default function Hosted() { aria-hidden="true" /> - The managed Relay + The Hosted Relay

Included with any plan

diff --git a/website/src/pages/PocketPlayground.tsx b/website/src/pages/PocketPlayground.tsx index 29d47d6c8..6f0887216 100644 --- a/website/src/pages/PocketPlayground.tsx +++ b/website/src/pages/PocketPlayground.tsx @@ -50,9 +50,10 @@ function DesktopPocketPlaygroundPage() {

Run your own Relay, or{" "} - let me run one for you with Dormouse Hosted - . Your terminal still runs on your awake, online computer; Hosted removes - the server setup and maintenance. + use the Hosted Relay + {" "} + and let me run it for you. Your terminal still runs on your awake, online + computer; Hosted removes the server setup and maintenance.

-

DiffPlug LLC operates Dormouse Hosted and is the controller of the personal information described here: the account service at hosted.dormouse.sh, the managed Relay and Pocket at relay.dormouse.sh, and Natural Voice at voice.dormouse.sh. This policy covers those services, the paid subscription that unlocks them, and related support. Contact us at support@dormouse.sh about privacy or your account.

+

DiffPlug LLC operates Dormouse Hosted and is the controller of the personal information described here: the account service at hosted.dormouse.sh, the Hosted Relay and Pocket at relay.dormouse.sh, and Natural Voice at voice.dormouse.sh. This policy covers those services, the paid subscription that unlocks them, and related support. Contact us at support@dormouse.sh about privacy or your account.

Our business address is DiffPlug LLC, 447 Sutter St Ste 405, San Francisco, CA 94108, United States.

Dormouse itself runs on your computer and sends no usage telemetry. An update check fetches a version file from dormouse.sh without sending terminal contents or a Hosted account identifier. Automatic update checks run only if enabled. "Check now" makes a request when you click it. Creating an account does not upload your terminal contents, commands, files, or audio.

This website, dormouse.sh, is served by Cloudflare, which processes visitors’ IP addresses and request information to deliver it.

@@ -42,7 +42,7 @@ export const PRIVACY_SECTIONS: PolicySection[] = [

We have turned off model-training data use in the ElevenLabs workspace that contains Dormouse’s service account. This is separate from history deletion. ElevenLabs’ terms state that the opt-out takes effect after they process it and does not undo earlier uses of data or materials resulting from those uses. The opt-out does not eliminate the retention described above.

}, { id: "remote-control", title: "Remote control and Pocket", body: <> -

The managed Relay connects Dormouse on your computers to Pocket on your phone. Terminal traffic and push notification contents are end-to-end encrypted between your devices, so the Relay carries them without being able to read them.

+

The Hosted Relay connects Dormouse on your computers to Pocket on your phone. Terminal traffic and push notification contents are end-to-end encrypted between your devices, so the Relay carries them without being able to read them.

We store the computers you enroll (an identifier, a hash of each one’s Relay credential, and when it enrolled), the passkeys you register for Pocket (each one’s public key and the label you gave it), hashes of Pocket sign-in sessions, and short-lived sign-in and pairing challenges. If you turn on push notifications, we store each phone’s push subscription: the address your phone’s browser vendor gave it, the keys that seal notifications to it, and when it subscribed.

While it carries a connection, the Relay also sees connection metadata: IP addresses, which of your computers are online, which devices talk to which, and the timing and size of encrypted traffic. Once a session switches to a direct connection between your devices, the Relay sees only that the session exists. The trust model lists this in full.

A one-time connection link needs no account. Its rendezvous forwards the encrypted handshake between your phone and computer without reading or storing it.

diff --git a/website/src/pages/Terms.tsx b/website/src/pages/Terms.tsx index 85e3f2c12..b0c3aa821 100644 --- a/website/src/pages/Terms.tsx +++ b/website/src/pages/Terms.tsx @@ -12,7 +12,7 @@ export function meta({ location }: MetaArgs) { export const TERMS_SECTIONS: PolicySection[] = [ { id: "service", title: "The service", body: <> -

These terms govern Dormouse Hosted, provided and sold by DiffPlug LLC: the account service at hosted.dormouse.sh and the paid subscription that adds Natural Voice and the managed Relay for Pocket. You must be legally able to enter this agreement. If you act for an organization, you must have authority to bind it. The subscription provisions apply to purchases made when paid subscriptions become available.

+

These terms govern Dormouse Hosted, provided and sold by DiffPlug LLC: the account service at hosted.dormouse.sh and the paid subscription that adds Natural Voice and the Hosted Relay for Pocket. You must be legally able to enter this agreement. If you act for an organization, you must have authority to bind it. The subscription provisions apply to purchases made when paid subscriptions become available.

You agree to this version by continuing past the sign-in notice that names it; for a new account, it takes effect then. For an existing account, it replaces your previous terms on the effective date stated in the notice we give you, at least 30 days after that notice, unless you expressly agree to this version sooner. We obtain renewed agreement where required by law. Until then, your previous terms continue to apply. Publication alone does not replace your existing agreement, and this version does not apply retroactively to disputes.

The Hosted page describes what a subscription includes, what it costs, and its current usage limits. If it conflicts with these terms, these terms control. An account alone is free and grants no paid features.

The Dormouse software and self-hosted components remain subject to their own software licenses. These terms do not change those licenses or require a Hosted account to use local terminals.

From e2443450a28eb776a9872ebb65b8a3f24c74b807 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 14:54:12 -0700 Subject: [PATCH 11/11] Settings: a Hosted build's persistent choice is the Hosted Relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under Network → Phones, the choice beside One-time connection was "Persistent Relay" in every build. A Hosted build now labels it Hosted Relay, the product's name for what it enrolls with; a self-host build keeps Persistent Relay. The account page's empty Computers copy follows. Co-Authored-By: Claude Opus 5.5 --- docs/specs/relay.md | 2 +- docs/stories/hosted-remote-control.mdx | 2 +- hosted/src/HostedPage.tsx | 2 +- .../components/RemoteControlSection.test.tsx | 23 ++++++++++++------- lib/src/components/RemoteControlSection.tsx | 16 +++++++++---- .../stories/RemoteControlSection.stories.tsx | 11 +++++---- 6 files changed, 36 insertions(+), 20 deletions(-) diff --git a/docs/specs/relay.md b/docs/specs/relay.md index ed6d61e87..16a44c988 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -337,7 +337,7 @@ Source of truth: `BurrowService` in `lib/src/host/remote/service.ts`; `FileBurro ### Remote control, in the Settings dialog -The **Remote control** choices are **One-time connection** (`docs/specs/one-time.md` -> "Laptop UI") and **Persistent Relay**, whose enroll view follows the build's `relayMode`; which network policy levels offer them is `docs/specs/remote-network.md` -> "Settings → Network". +The **Remote control** choices are **One-time connection** (`docs/specs/one-time.md` -> "Laptop UI") and **Hosted Relay** (**Persistent Relay** in a self-host build), whose enroll view follows the build's `relayMode`; which network policy levels offer them is `docs/specs/remote-network.md` -> "Settings → Network". - **The offer card shows only where it can be pressed**: an unexpired local offer file naming the baked origin, on an un-enrolled self-host Burrow. - **The offer's token never enters a webview**: `status` carries only whether there is one (`docs/specs/security-remote.md` -> "Credentials at rest"). **The click re-reads the file**, so an old card cannot reuse a spent offer; `enrollOffer` takes `{ label }` alone. diff --git a/docs/stories/hosted-remote-control.mdx b/docs/stories/hosted-remote-control.mdx index 95fc84682..34a93ae07 100644 --- a/docs/stories/hosted-remote-control.mdx +++ b/docs/stories/hosted-remote-control.mdx @@ -20,7 +20,7 @@ In Settings → Network, a new install starts at Nowhere. Choose the policy appr ## Ask to enroll remote control -Under Phones, unfold Persistent Relay. A stock Dormouse build offers Hosted enrollment here, without a self-host setup password. +Under Phones, unfold Hosted Relay. A stock Dormouse build offers Hosted enrollment here, without a self-host setup password. diff --git a/hosted/src/HostedPage.tsx b/hosted/src/HostedPage.tsx index 627ecf738..1989b293d 100644 --- a/hosted/src/HostedPage.tsx +++ b/hosted/src/HostedPage.tsx @@ -407,7 +407,7 @@ export function HostedPage({

No computers yet. To enroll one, open Dormouse on that computer and go to Settings → Network → Phones → - Persistent Relay → Enroll remote control. + Hosted Relay → Enroll remote control.

)} {computers.map((computer) => ( diff --git a/lib/src/components/RemoteControlSection.test.tsx b/lib/src/components/RemoteControlSection.test.tsx index a95b0fb3a..82d29bdf3 100644 --- a/lib/src/components/RemoteControlSection.test.tsx +++ b/lib/src/components/RemoteControlSection.test.tsx @@ -252,17 +252,22 @@ function typedForm(): HTMLFormElement { return form; } -/** Unfold Persistent Relay the way a user does; un-enrolled with no offer, it starts folded. */ +/** The persistent choice's button: Hosted Relay in a stock build, Persistent Relay in a self-host one. */ +function persistentButton() { + return buttonLabelled('Persistent Relay') ?? buttonLabelled('Hosted Relay'); +} + +/** Unfold the persistent choice the way a user does; un-enrolled with no offer, it starts folded. */ async function openPersistent() { - await act(async () => buttonLabelled('Persistent Relay')!.click()); + await act(async () => persistentButton()!.click()); } -/** The panel Persistent Relay unfolds, which is what `hidden` toggles. */ +/** The panel the persistent choice unfolds, which is what `hidden` toggles. */ function persistentPanel(): HTMLElement { - const panel = buttonLabelled('Persistent Relay')?.parentElement?.querySelector( + const panel = persistentButton()?.parentElement?.querySelector( ':scope > div.rounded', ); - if (!panel) throw new Error('the Persistent Relay panel is not mounted'); + if (!panel) throw new Error('the persistent Relay panel is not mounted'); return panel; } @@ -309,7 +314,7 @@ describe('RemoteControlSection', () => { expect(container.innerHTML).toBe(''); }); - it('offers a stock build a one-time connection and a folded Persistent Relay with nothing to enroll', async () => { + it('offers a stock build a one-time connection and a folded Hosted Relay with nothing to enroll', async () => { const openExternal = vi.fn(); platform = { burrow: makeLink(async () => NOT_ENROLLED), openExternal }; await render(); @@ -318,11 +323,13 @@ describe('RemoteControlSection', () => { 'Open a link on your phone for a one-off connection. Your phone must be on an allowed ' + 'network. No account needed.', ); - expect(buttonLabelled('Persistent Relay')!.getAttribute('aria-expanded')).toBe('false'); + // Hosted's Relay goes by the product's name for it. + expect(buttonLabelled('Persistent Relay')).toBeUndefined(); + expect(buttonLabelled('Hosted Relay')!.getAttribute('aria-expanded')).toBe('false'); expect(persistentPanel().hidden).toBe(true); await openPersistent(); - expect(buttonLabelled('Persistent Relay')!.getAttribute('aria-expanded')).toBe('true'); + expect(buttonLabelled('Hosted Relay')!.getAttribute('aria-expanded')).toBe('true'); expect(persistentPanel().hidden).toBe(false); // Its one Relay is Hosted's (docs/specs/relay.md → "Relay origin"): no // password form, no offer card, and the self-host path named as a build. diff --git a/lib/src/components/RemoteControlSection.tsx b/lib/src/components/RemoteControlSection.tsx index 01dbb3736..29e4ee558 100644 --- a/lib/src/components/RemoteControlSection.tsx +++ b/lib/src/components/RemoteControlSection.tsx @@ -491,7 +491,15 @@ export function RemoteControlSection() { } /** - * One-time connection and Persistent Relay, as two choices. The one-time panel + * The persistent choice's name: Hosted's own Relay is the Hosted Relay, the + * product's name for it; a self-host build's is whatever its operator runs. + */ +function persistentRelayLabel(relayMode: BurrowConsoleStatus['relayMode']): string { + return relayMode === 'self-host' ? 'Persistent Relay' : 'Hosted Relay'; +} + +/** + * One-time connection and the persistent Relay, as two choices. The one-time panel * is the same whether or not this machine enrolled (`OneTimeConnection.tsx`). * **Enrolled, the Relay's view is always shown**; un-enrolled is * {@link UnenrolledRelay}. @@ -511,7 +519,7 @@ function RelayChoices({ status }: { status: BurrowConsoleStatus }) {
{status.enrolled ? ( <> -
Persistent Relay
+
{persistentRelayLabel(status.relayMode)}
{/* Keyed by which enrollment this is: a swap to another Burrow — the console hook can do one under an open dialog — must not leave a setup code, or an error, belonging to the one we just left. */} @@ -536,7 +544,7 @@ function RelayChoices({ status }: { status: BurrowConsoleStatus }) { } /** - * Persistent Relay on a machine that has not enrolled: a disclosure that + * The persistent Relay on a machine that has not enrolled: a disclosure that * **stays folded until clicked**, installer's offer or not, holding what the * build's mode allows (`docs/specs/relay.md` → "Remote control, in the Settings * dialog"). **Folding hides the enroll view, never unmounts it**, for the same @@ -594,7 +602,7 @@ function UnenrolledRelay({ className={modalActionButton({ tone: unfolded ? 'secondary' : 'primary' })} onClick={() => setUnfolded((was) => !was)} > - Persistent Relay + {persistentRelayLabel(status.relayMode)}
{hint}
{/* The same framed panel as "Set up a phone", the other disclosure here. */} diff --git a/lib/src/stories/RemoteControlSection.stories.tsx b/lib/src/stories/RemoteControlSection.stories.tsx index 6d7586af0..0a200e3dd 100644 --- a/lib/src/stories/RemoteControlSection.stories.tsx +++ b/lib/src/stories/RemoteControlSection.stories.tsx @@ -83,27 +83,28 @@ function setupPanel(text: string | RegExp) { }; } -/** Unfold Persistent Relay the way a user does; un-enrolled with no offer, it starts folded. */ +/** Unfold the persistent choice the way a user does: Hosted Relay in a stock + * build, Persistent Relay in a self-host one; un-enrolled with no offer, it starts folded. */ async function openPersistent(canvasElement: HTMLElement) { await userEvent.click( - await within(canvasElement).findByRole('button', { name: 'Persistent Relay' }), + await within(canvasElement).findByRole('button', { name: /^(Persistent|Hosted) Relay$/ }), ); } /** * What a stock build that has never enrolled opens on: a one-time connection and - * a folded Persistent Relay. + * a folded Hosted Relay. */ export const Choices: Story = { parameters: { primedBurrow: { status: UNENROLLED_STATUS }, docs: { story: { height: '310px' } }, }, - play: settled('Persistent Relay'), + play: settled('Hosted Relay'), }; /** - * A stock build, Persistent Relay unfolded: its one Relay is Hosted's, which + * A stock build, Hosted Relay unfolded: its one Relay is Hosted's, which * it enrolls with by a code approved at the account (`docs/specs/hosted.md` → * "Burrow enrollment") — the name to keep, and the button that gets one. */