Skip to content
Draft
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 docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ The Settings dialog's Notifications topic is this spec's; Network's content is `
- A baseboard alarm button toggles only its own setting, as an override for that Workspace; components without a Workspace scope edit application defaults. **Must open a separate Workspace alert dialog from either button's right-click or focused `Shift+F10`/`ContextMenu`**, only with a Workspace scope, preserving per-field inheritance and reset-all.
- The watched-command list removes rules and cannot add one — creating a rule stays the terminal context of a Pane running the command. It is the only place a rule set on a since-closed Pane can be removed.
- **The push group's device line must name every device a push would reach**, and otherwise say why there is none — in a Hosted build, signing in, or the missing plan with the plans linked.
- **Turning a baseboard alarm button on may add one live line to its preview, at most once a day per machine across both buttons**; never on turning off, for a member, or until the network policy answers anything but `nothing`. Signed out of Hosted, it points at signing in; signed in without a plan, at the plans with an attribution `ref`. Speech offers only where the adapter has `managedVoice`. Push with no Burrow enrolled in a build without Hosted mode points at Settings → Network. **Never name Hosted in a build without Hosted mode** (`docs/specs/relay.md` → "Relay origin").
- **Turning a baseboard alarm button on may add one live line to its preview, at most once a day per machine across both buttons**; never on turning off or for a member, and under every network policy, `nothing` included, since the line makes no request. Signed out of Hosted, it points at signing in; signed in without a plan, at the plans with an attribution `ref`. Speech offers only where the adapter has `managedVoice`. Push with no Burrow enrolled in a build without Hosted mode points at Settings → Network. **Never name Hosted in a build without Hosted mode** (`docs/specs/relay.md` → "Relay origin").
- **The Managed voice group is shown wherever the adapter has `managedVoice`**, which a self-host build's omits; without it the speech row names the browser or system voice and links nothing. **Must state what is sent to Hosted, and that ElevenLabs' copy has no guaranteed deletion time, before the sign-in that enables it.** Signing in is the Hosted enrollment Remote control runs (`HostedEnrollView`); under Nowhere it explains and links Network instead, changing no policy. Signed in, it offers the curated voices and Sign out (the enrollment's Disconnect), or, where the relay socket or speak says the plan lapsed, the plans. **No control takes a token.**
- Each sink carries a test control, usable while the sink is off: the test sound goes through the shared speech queue and selected voice, reporting admission or an unavailable backend, never Session delivery state; the test push takes the real Burrow→ACL→Relay path and distinguishes no targets, zero, partial, and full delivery, hidden without a Burrow service.

Expand Down
11 changes: 5 additions & 6 deletions docs/specs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,17 @@ Source of truth: `entitledSql` and `entitled` in `hosted/server/entitlement.ts`;

## Managed voice

A signed-in desktop exchanges its voice token for ElevenLabs speech in a voice of the curated set (`MANAGED_VOICES` in `remote-lib-common/src/remote/managed-voice.ts`). Signing in is the device-code enrollment ("Burrow enrollment"), whose redemption mints the token; the account Worker's token routes mint one by hand. The voice Worker serves speak.
A signed-in desktop exchanges its voice token for ElevenLabs speech in a voice of the curated set (`MANAGED_VOICES` in `remote-lib-common/src/remote/managed-voice.ts`). Signing in is the device-code enrollment ("Burrow enrollment"), whose redemption is the only way a token is minted; the account Worker's token routes list and revoke them. The voice Worker serves speak.

| Route | Credential | Success |
|---|---|---|
| `GET /api/voice/tokens` | login cookie, same-origin fetch | 200 `{ tokens }` |
| `POST /api/voice/tokens` | login cookie, exact `Origin` | 201 `{ id, token, createdAt }` |
| `DELETE /api/voice/tokens/:id` | login cookie, exact `Origin` | 204; 404 for another account's or an unknown ID |
| `POST /api/voice/speak` | `Authorization: Bearer dmv_…`, JSON `{ text, voiceId }` | 200 `audio/mpeg`, `Cache-Control: no-store` |

Errors are JSON `{ message }`. Cookie routes answer 401 without a login and 403 for an account not entitled ("Entitlement").

**Must store only a token's SHA-256.** A token is `dmv_` plus base64url of 32 random bytes, returned only by the mint response: the account's `POST`, or the poll that redeems a sign-in. Revocation is permanent.
**Must store only a token's SHA-256.** A token is `dmv_` plus base64url of 32 random bytes, returned only by the poll that redeems a sign-in. Revocation is permanent.

**Must mint a sign-in's token in the statement that redeems it**, owned by the approver and naming the Burrow it enrolled (`hosted/server/dormouse-migrations/007_voice_token_burrow.sql`), so removing that computer from the account deletes its token, and the Burrow cap bounds them. The relay's role may insert those columns and nothing else of voice.

Expand All @@ -112,7 +111,7 @@ Source of truth: `hosted/server/voice.ts`; `hosted/server/voice-app.ts`; `mintVo

## Relay

The relay Worker serves the self-host Relay's HTTP API to many accounts: the paths, shapes, statuses, and error strings of `docs/specs/relay.md` -> "HTTP API", "Setup tokens and the pairing QR", and "WebAuthn without a WebAuthn library", so a Burrow and Pocket cannot tell the two apart; both run the checks and bounds in `remote-lib-common/src/remote/relay-common.ts`. Assertions demand presence, not verification. Security checks: `docs/specs/security-hosted.md` -> "Relay boundary". Only the differences:
The relay Worker serves the self-host Relay's HTTP API to many accounts: the paths, shapes, statuses, and error strings of `docs/specs/relay.md` -> "HTTP API", "Setup tokens and the pairing QR", and "WebAuthn without a WebAuthn library", so a Burrow and Pocket cannot tell the two apart; both run the checks and bounds in `remote-lib-common/src/remote/relay-common.ts`, and the enrollment cap in `remote-lib-common/src/remote/enrolled-computers.ts`. Assertions demand presence, not verification. Security checks: `docs/specs/security-hosted.md` -> "Relay boundary". Only the differences:

| Route | On Hosted |
|---|---|
Expand Down Expand Up @@ -288,7 +287,7 @@ Source of truth: `METRIC_LABELS` in `hosted/server/metric-labels.ts`; `recordMet

## Development and release

**Must run local development with `dor tool hosted` inside Dormouse.** Its single loopback `http://localhost:<port>` origin serves Vite and Node auth on a disposable development database, with the voice token, Relay account, and billing routes (on StripeDev) but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler.
**Must run local development with `dor tool hosted` inside Dormouse.** Its single loopback `http://localhost:<port>` origin serves Vite and Node auth on a disposable development database, with the voice token, Relay account, and billing routes (on StripeDev) but never speak, which every Hosted build reaches only at `https://voice.dormouse.sh`. Host, Origin, and Fetch Metadata checks guard the local captured-email inbox; no production entry imports an inbox or test-control handler. **It also starts the relay Worker's dev loop attached to it** (`docs/specs/one-time.md` -> "Dev loop"): that development database through a local Hyperdrive and this origin as `ACCOUNT_ORIGIN`, so a dev Hosted Burrow build signs in by device code. `dor tool one-time` alone has no database, so its Relay routes answer 503.

**Must verify the three production Worker bundles and run the consumer's integration suite before release.** Only test entries inject deterministic Better Auth. Simulated callbacks do not certify provider registrations; production acceptance requires real browser login with each enabled provider and email delivery.

Expand Down Expand Up @@ -326,5 +325,5 @@ Source of truth: `.github/workflows/hosted-production.yml`; `hosted/scripts/prod

1. Deploy the configured providers and pass real production acceptance. pgstencil includes the Microsoft fix; personal and work/school callbacks need acceptance.
2. Add per-browser login listing/revocation, sign-out-everywhere, and account recovery before broad paid use. Revisit the fixed 24-hour login lifetime for daily voice use.
3. Managed voice for every member: per-account quotas, usage accounting, spending bounds beyond the fixed daily cap, retiring the account page's hand-minted tokens, and explicit text/redaction disclosure.
3. Managed voice for every member: per-account quotas, usage accounting, spending bounds beyond the fixed daily cap, and explicit text/redaction disclosure.
4. Open sign-in: delete `hosted/server/prelaunch.ts` and the frontend's pre-launch notice.
2 changes: 1 addition & 1 deletion docs/specs/one-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ Source of truth: `OneTimeApp` in `lib/src/remote/one-time-app/OneTimeApp.tsx`; `

## Dev loop

**`dor tool one-time` runs the rendezvous and page on loopback, without Postgres**, at `PORT` or a fixed default, since a Burrow build bakes the origin in. Its config carries no route or production secret. It prints the dev Burrow build variables (`DORMOUSE_RELAY_ORIGIN`, `DORMOUSE_RELAY_IS_HOSTED=1`; `docs/specs/relay.md` -> "Relay origin").
**`dor tool one-time` runs the rendezvous and page on loopback, without Postgres**, at `PORT` or a fixed default, since a Burrow build bakes the origin in. Its config carries no route or production secret. It prints the dev Burrow build variables (`DORMOUSE_RELAY_ORIGIN`, `DORMOUSE_RELAY_IS_HOSTED=1`; `docs/specs/relay.md` -> "Relay origin"). **Started by the Hosted dev loop, with `HOSTED_DEV_ACCOUNT_ORIGIN` and `HOSTED_DEV_DATABASE_URL` together, it adds that `ACCOUNT_ORIGIN` and a local Hyperdrive over that database**, never production's, and announces no page, the account origin being the loop's (`docs/specs/hosted.md` -> "Development and release").

Source of truth: `hosted/scripts/dev-one-time.mjs`.

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

**`/hosted` is canonical; `/pricing` 301-redirects to it.** The tool is free and open source, and Hosted is the optional service with a price, so pricing is a section of the Hosted page, never a page of its own.

**Settings is the front door.** Its sign-in, in Notifications' managed voice and Network's Remote control, starts membership on the desktop; the playground tutorial lands on `/hosted#voice`, and the plan cards sit within one screen of that anchor. An account with no plan is linked to `#pricing` from Settings and from the baseboard alarm buttons' offers (`docs/specs/alert.md` -> "Settings dialog"). `#pricing` (the plan cards, which each JSON-LD `Offer` url names), `#remote-control`, and `#voice` keep resolving as section ids.
**Settings is the front door.** Its sign-in, in Notifications' managed voice and Network's Remote control, starts membership on the desktop; the Pocket playground's tutorial lands on `/hosted#remote-control`, below the plan cards. An account with no plan is linked to `#pricing` from Settings, from the baseboard alarm buttons' offers (`docs/specs/alert.md` -> "Settings dialog"), and from the account page, in place of approving a computer. `#pricing` (the plan cards, which each JSON-LD `Offer` url names), `#remote-control`, and `#voice` keep resolving as section ids.

**Prices, inclusions, and the FAQ are prerendered text**, and the page emits `Product` / `Offer` JSON-LD carrying one `Offer` per paid plan at its current price, so an assistant fetching the page can quote it. **Offers are `PreOrder` until `CHECKOUT_OPEN`, then `InStock`.**

Expand Down
4 changes: 3 additions & 1 deletion hosted/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ dor tool hosted

Outside Dormouse, use `pnpm dev:hosted` and open the `http://localhost:<port>` URL it prints. Request a code for a test address and read it at `/api/dev/emails` on that same origin. No real mail is sent, and the development database is isolated by the worktree path; `docs/specs/hosted.md` -> "Development and release" owns what the local entry serves and what production omits. The port is OS-assigned unless you set `PORT`. Do not share this local inbox publicly. The local origin serves the voice token routes but not speak: a Hosted build speaks only at `https://voice.dormouse.sh` (`docs/specs/hosted.md` -> "Development and release").

The same command starts the relay Worker on this database at `http://localhost:8787` (or `RELAY_PORT`), approving codes at the account origin, so a dev desktop build signs in by device code end to end: `DORMOUSE_RELAY_IS_HOSTED=1 DORMOUSE_RELAY_ORIGIN=http://localhost:8787 pnpm innerdogfood`, choose Local networks or Anywhere in Settings → Network, press Sign in to Dormouse Hosted, and approve the code at the account origin. Approving needs a plan; buy one first at `/checkout?plan=monthly` on StripeDev. Don't also run `dor tool one-time` on the same port.

```sh
pnpm test:hosted
pnpm build:hosted
Expand Down Expand Up @@ -244,7 +246,7 @@ Refund requests arrive at [email protected]. Refund from the Stripe dashboard
3. For each enabled provider, test first login, consent cancellation, repeat login, and logout. Include GitHub private email and Apple Hide My Email; Microsoft needs personal and work/school accounts after the shared fix.
4. Start with email, attempt the same-address provider from another browser, and confirm no automatic linking. Connect it from the signed-in account, then confirm both methods return the same account ID. Connect Apple with its different relay address. Confirm an identity belonging to another account does not transfer, and that a connection started before logout fails on return.
5. Log in on two devices and confirm both remain signed in. Log out on one; the other must remain signed in. Provider-only accounts display no email and repeat login preserves their account ID.
6. Sign in as the admin address, create a voice token, and speak one short phrase with it from Dormouse desktop; revoke it and confirm the next speak fails. Confirm another account sees no Voice tokens section.
6. Sign Dormouse desktop in to the admin account from Settings and speak one short phrase; revoke that computer's token on the account page and confirm the next speak fails. Confirm another account sees no Voice tokens section.
7. Confirm the history sweep's Cron Trigger is registered on the voice Worker alone: the deploy log lists `schedule: */5 * * * *`, the dashboard shows it under Workers & Pages -> `dormouse-voice` -> Settings -> Trigger Events, and `dormouse-hosted` lists none. After the speak in step 6, the ElevenLabs console's speech history should be empty within a few minutes. The Worker's Cron Events list each run; a failed run means the key cannot list or delete history. Recheck them for failed runs after any key change.
8. Confirm `/api/dev/emails`, `/dev/emails`, and `/__test/time` are absent, and check the live responses against the origin, caching, and cookie rules in `docs/specs/security-hosted.md` -> "Origin boundary".
9. Confirm the release smoke's one-time half passed against `relay.dormouse.sh`: `/connect/` answers the page under its own policy with its script beside it, and the rendezvous mints a room, is refused with a browser `Origin`, joins from the relay origin, crosses a frame each way, and is refused a second phone. Load `https://relay.dormouse.sh/connect/` in a browser and confirm no injected script or third-party request, and that `https://relay.dormouse.sh/` answers Pocket's shell under Pocket's policy.
Expand Down
41 changes: 37 additions & 4 deletions hosted/scripts/dev-one-time.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,57 @@ export function devOrigin(port) {
/** The loop's `RELAY_ENROLL_SECRET`: user codes need one, and nothing here is secret. */
export const DEV_ENROLL_SECRET = "dormouse-relay-local-development-only";

/**
* The Hosted dev loop this run is part of, or null on its own: `pnpm
* dev:hosted` starts this script with the account origin that approves codes
* and its development database, so device-code sign-in redeems locally. Both
* or neither.
*/
export function hostedAttachment(env) {
const accountOrigin = env.HOSTED_DEV_ACCOUNT_ORIGIN || undefined;
const databaseUrl = env.HOSTED_DEV_DATABASE_URL || undefined;
if (!accountOrigin && !databaseUrl) return null;
if (!accountOrigin || !databaseUrl)
throw new Error("HOSTED_DEV_ACCOUNT_ORIGIN and HOSTED_DEV_DATABASE_URL are set together.");
return { accountOrigin, databaseUrl };
}

/**
* The Wrangler config the loop runs, written beside the staged page.
* Allowlisted from `hosted/wrangler.relay.jsonc` like the preview's: the relay
* entry, the rendezvous's Durable Object, migration, and rate limits, and the
* assets binding over the staging folder — never a route or a production
* secret, so it cannot answer for production. Its enrollment secret is
* {@link DEV_ENROLL_SECRET}, fixed and public.
* {@link DEV_ENROLL_SECRET}, fixed and public. Attached to the Hosted dev loop
* ({@link hostedAttachment}), it also names that account origin and reaches
* its database through a local Hyperdrive, never production's.
*/
export function devConfig(base, port) {
export function devConfig(base, port, attached = null) {
return {
name: `${base.name}-dev`,
main: "../../server/relay-worker.ts",
compatibility_date: base.compatibility_date,
compatibility_flags: base.compatibility_flags,
assets: { ...base.assets, directory: "./assets" },
vars: { APP_ORIGIN: devOrigin(port), RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET },
vars: {
APP_ORIGIN: devOrigin(port),
RELAY_ENROLL_SECRET: DEV_ENROLL_SECRET,
...(attached && { ACCOUNT_ORIGIN: attached.accountOrigin }),
},
...(attached && {
hyperdrive: [
{ binding: "HYPERDRIVE", id: LOCAL_HYPERDRIVE_ID, localConnectionString: attached.databaseUrl },
],
}),
durable_objects: base.durable_objects,
migrations: base.migrations,
ratelimits: base.ratelimits,
};
}

/** A Hyperdrive id `wrangler dev --local` requires and never looks up. */
export const LOCAL_HYPERDRIVE_ID = "local-development";

/** One GET through the Worker, as a browser on the loopback origin sends it. */
function status(port, path) {
return new Promise((resolveStatus) => {
Expand Down Expand Up @@ -111,13 +140,14 @@ async function waitFor(what, ready, timeoutMs) {
async function main() {
const port = devPort(process.env);
const origin = devOrigin(port);
const attached = hostedAttachment(process.env);
const base = parseConfig(readFileSync(resolve(hosted, "wrangler.relay.jsonc"), "utf8"));
const pageDir = resolve(devDir, "assets", PAGE_PATH.slice(1));
// A page left by an earlier run would satisfy the first wait below.
rmSync(devDir, { recursive: true, force: true });
mkdirSync(pageDir, { recursive: true });
const configPath = resolve(devDir, "wrangler.json");
writeFileSync(configPath, JSON.stringify(devConfig(base, port), null, 2) + "\n");
writeFileSync(configPath, JSON.stringify(devConfig(base, port, attached), null, 2) + "\n");

// Each package's own bin shim, which execs its tool, so a signal reaches it.
const run = (name, args, cwd) => {
Expand Down Expand Up @@ -172,9 +202,12 @@ async function main() {
// A local origin counts as Hosted only in a dev build
// (docs/specs/relay.md -> "Relay origin").
" DORMOUSE_RELAY_IS_HOSTED=1",
...(attached ? [`Its device-code sign-in approves at ${attached.accountOrigin}.`] : []),
"",
].join("\n"),
);
// Under `pnpm dev:hosted` the account origin is the page the loop serves.
if (attached) return;
// OSC 367 (docs/specs/dor-tool.md -> OSC 367): frame this port's page, not
// Wrangler's inspector, which the same process tree also binds.
process.stdout.write(
Expand Down
Loading
Loading