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/hosted.md b/docs/specs/hosted.md index 5d05777ad..6a47d1f86 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 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. 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/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/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/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/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/App.test.tsx b/hosted/src/App.test.tsx index 1d549e1dd..380962f66 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,91 @@ describe("Natural Voice confirmation", () => { expect(button("Retry connection").disabled).toBe(false); }); }); + +describe("an account with no plan", () => { + const noPlanShown = () => container.textContent!.includes("has no Hosted plan yet"); + 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); + 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.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 () => { + 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("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.")); + 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..5a4b40db8 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. @@ -160,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( @@ -246,7 +252,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 +327,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { setHostedVoice(null); setComputers(null); setPasskeys(null); + setRefusedPlan(false); setEnterCode(false); setCode(""); setNotice({ @@ -385,6 +393,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 +407,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) { 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 ( @@ -74,6 +90,8 @@ export interface HostedPageProps { computers: Computer[] | null; passkeys: Passkey[] | null; enrolling: Enrollment | null; + /** This account has no plan, so it cannot approve a computer. */ + noPlan: boolean; loading: boolean; busy: string; error: PageError | null; @@ -106,7 +124,7 @@ export interface HostedPageProps { export function HostedPage({ session, enabled, accounts, hostedVoice, turnOffDialog, computers, passkeys, - enrolling, loading, busy, error, notice, email, code, enterCode, + enrolling, noPlan, loading, busy, error, notice, email, code, enterCode, comingSoon, fresh, themeControl = false, act, refresh, approve, signOut, clearEnrollment, turnOffVoice, removeOne, removeKey, submit, setEmail, setCode, setEnterCode, changeEmail, sendCode, noteContinued, social, @@ -145,9 +163,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." @@ -187,29 +209,49 @@ export function HostedPage({ {enrolling && (session || !enrolling.code) ? (
{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 ? ( + <> + + {enrolling.purpose !== "voice" && ( +

+

+ )} +

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

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

+ 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 +405,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 → + Hosted Relay → Enroll remote control.

)} {computers.map((computer) => ( diff --git a/hosted/src/NoPlanOffer.tsx b/hosted/src/NoPlanOffer.tsx new file mode 100644 index 000000000..b2c06d183 --- /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 = "Natural Voice powered by ElevenLabs for spoken alarms"; +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"; + +/** + * 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. +

+
+ ); +} 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/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. --> 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. */ 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, 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.