Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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").

Expand Down
1 change: 1 addition & 0 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/specs/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/website-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/stories/hosted-remote-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Canvas of={RemoteControl.HostedPersistentRelay} />

Expand Down
2 changes: 1 addition & 1 deletion hosted/server/policy-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
89 changes: 89 additions & 0 deletions hosted/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("./api")>(),
Expand Down Expand Up @@ -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<HTMLAnchorElement>('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<typeof import("./api")>("./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);
});
});
16 changes: 14 additions & 2 deletions hosted/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getPasskeys,
getProviders,
getSession,
NoPlanError,
post,
removeComputer,
removePasskey,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -320,6 +327,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
setHostedVoice(null);
setComputers(null);
setPasskeys(null);
setRefusedPlan(false);
setEnterCode(false);
setCode("");
setNotice({
Expand Down Expand Up @@ -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("");
Expand All @@ -395,7 +407,7 @@ export function App({ enrollment }: { enrollment: Enrollment | null }) {
return <HostedPage
session={session} enabled={enabled} accounts={accounts} hostedVoice={hostedVoice}
turnOffDialog={turnOffDialog} computers={computers} passkeys={passkeys}
enrolling={enrolling} loading={loading} busy={busy} error={error} notice={notice}
enrolling={enrolling} noPlan={noPlan} loading={loading} busy={busy} error={error} notice={notice}
email={email} code={code} enterCode={enterCode}
comingSoon={comingSoon} fresh={fresh} themeControl act={act} refresh={refresh} approve={approve}
signOut={signOut} clearEnrollment={clearEnrollment} turnOffVoice={turnOffVoice}
Expand Down
Loading
Loading