diff --git a/docs/specs/mobile-terminal-ui.md b/docs/specs/mobile-terminal-ui.md index f1dcb7811..55e6cfc2f 100644 --- a/docs/specs/mobile-terminal-ui.md +++ b/docs/specs/mobile-terminal-ui.md @@ -71,7 +71,7 @@ Touch mode is global, so **each mounted pane's mouse override is a pure function of that mode and the pane's *own* mouse-reporting state** (`selection` + reporting ≠ `none` → `permanent`, else `off`), recomputed for **every** pane, not just the active one — a pane switched away from must not keep a stale -override. The consumer owns this wiring. +override. `lib` exports the function; the consumer owns the loop. Select mode **must route touch and pen drags through the shared terminal mouse-selection router**, never a mobile-only one, so every selection and copy @@ -99,8 +99,9 @@ the pointer, and **must never reach xterm or the pane** for focus, selection, or pane interaction. **Non-primary mouse buttons are ignored**, so their browser or host behavior continues. -Source of truth: `TOUCH_MODES` in `lib/src/components/MobileTerminalUi.tsx`; -per-pane override wiring in `lib/src/remote/pocket-app/PocketWall.tsx` and +Source of truth: `TOUCH_MODES` and `paneMouseOverride` in +`lib/src/components/MobileTerminalUi.tsx`; per-pane wiring in +`lib/src/remote/pocket-app/PocketWall.tsx` and `website/src/components/PocketTerminalExperience.tsx`. ## Gesture mode @@ -234,7 +235,7 @@ same rule as the touch selector. | Draft | Draft reserve copy, filling the reserve. | Default input mode is **Type**. Recent and Draft are placeholder-only today and -say so in the reserve — the real features are staged (see [Future](#future)). +say so in the reserve ([Future](#future)). **Must focus the hidden input synchronously inside the Type selector's tap/click handler** (rationale). A follow-up effect retries via rAF and staggered timers diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md index 7a9a66732..42474d8c7 100644 --- a/docs/specs/pocket-app.md +++ b/docs/specs/pocket-app.md @@ -62,8 +62,9 @@ navigates, and hands the text to `parsePairingInvitationUrl` included — is not a setup code for this Relay. `pairingInvitationExpired` re-runs that same parser at the epoch, so the grammar has no second copy. * **The camera tracks stop on every way out** — accepted, cancelled, errored, - unmounted, and on a start that finished after the screen was gone. Only a - refused permission gets its own line; it leaves paste working. + unmounted, and on a start that finished after the screen was gone. **A refused + permission and an unusable camera each get their own line**, and no other + failure does; both leave paste working. * **The invitation lives in memory-only ceremony state**, cleared on every terminal outcome. @@ -99,7 +100,8 @@ v1 grants no phone-side kill or layout authority (rationale). **An inline image crosses the relay as the ordered `terminal.data` messages its PTY chunks produce**, reassembled by Pocket's own xterm — panes are built through -the same `createXtermHost`, so ImageAddon is loaded. **Nothing coalesces them and +the same `createXtermHost`, so ImageAddon is loaded wherever the terminal's +`inlineImages` setting is on, as it is by default. **Nothing coalesces them and no size gates them** (rationale); each is bounded only by what the Burrow feeds its parser ([remote-api.md](./remote-api.md) → Terminal surfaces). @@ -216,7 +218,7 @@ with **no `type: 'module'`**, pinned by `service-worker.test.ts`. **A worker tha is not one classic self-contained script fails the build**: `lib/scripts/assert-pocket-worker.mjs` runs last in `build:pocket` and owns the exact refusals (rationale). **Root `pnpm build` runs `build:pocket`**, so CI -checks a real bundler output (rationale; `assert-pocket-worker.test.ts`). +checks a real bundler output (rationale). `dev:pocket` bundles the same config in memory per `/sw.js` request (rationale). - **The worker caches nothing and registers no `fetch` handler** (rationale). It @@ -254,9 +256,11 @@ returns to sign-in** (`PasskeyUnavailableError`); the verified response restores it on any profile. **Pocket states the order on the screen that leads with the scan**, above the -action rather than after it: install to the Home Screen **first**, then scan, -approve the pairing on the machine, and enable push from within it — everything -partition-bound is minted from there, the passkey and each per-Burrow static. +action rather than after it: install to the Home Screen **first**, then scan and +approve the pairing on the machine — everything partition-bound is minted from +there, the passkey and each per-Burrow static. **Push is not named there**, +since whether it works at all depends on the Relay's push config; the Burrows +view's notice and push rows gate on that. **One phone can hold two Client identities, so Pocket names the mode in the label it suggests at pairing** — `Dormouse Pocket (Home Screen)` versus @@ -279,7 +283,7 @@ order, and every unavailable result is named in the UI:** | Result | Condition | UI consequence | |---|---|---| -| `needs-install` | `navigator.standalone` exists but the app is not installed; checked before capability probes, since iOS tabs omit those APIs | Explain Home Screen install above the scan action and again on the Burrows view; with no prompt API it stays advice — a tab must still scan | +| `needs-install` | `navigator.standalone` exists but the app is not installed; checked before capability probes, since iOS tabs omit those APIs | Explain Home Screen install above the scan action, and again on the Burrows view unless push is `disabled` there; with no prompt API it stays advice — a tab must still scan | | `unsupported` | Service workers, `Notification`, or `PushManager` unavailable after the install gate | Explain that this browser cannot receive push | | `no-worker` | The tracked registration failed or resolved empty, commonly on an insecure origin | Explain the worker failure | | `denied` | Notification permission is denied | Direct the user to browser settings | @@ -303,9 +307,9 @@ fetch offers Retry, which only caches the key, and the next tap reveals Enable. **Permission is requested on that separate, fresh tap** — a network round trip can consume an iOS gesture's transient activation. (rationale) -Browser availability and Burrow registration are separate states: a -`PushSubscription` belongs to the service-worker scope; the Relay stores one row -per `(burrowId, deliveryId)`. +Browser availability and Burrow registration are separate states: the +`PushSubscription` belongs to the service-worker scope, the Relay's rows to +`(burrowId, deliveryId)` ([relay.md](./relay.md) → State files). **Push is asked for once per device, on one card, on the Burrows view** — the prompt and the subscription it mints are scope-wide; the per-Burrow rows are @@ -337,8 +341,8 @@ settles at empty on its own behalf**: the card re-offers its idempotent Enable. **A Relay row is necessary but not sufficient for Push notifications on.** Pocket also checks that permission is still granted, that the scope holds a `PushSubscription` minted for the Relay's current VAPID key, and that it points -at the registered address; any of the four failing re-exposes Enable. **The -Relay likewise omits rows under an old VAPID key** (rationale). +at the registered address; any of the four failing re-exposes Enable, and the +Relay omits such rows too ([relay.md](./relay.md) → Web Push). **Pocket records a SHA-256 digest of the address each time the Relay accepts a registration** (`dormouse-pocket:push-endpoint`, beside the `:passkey:` cache) @@ -390,29 +394,26 @@ Source of truth: `isInstalledWebApp` / `requiresInstallForPush` / ## What Pocket stores **Must have a successful current-page storage probe before a scan starts -registration, sign-in, token retirement, or pairing.** Share in-flight work and -cache successful selection only in memory for that page; never retain failures. -Invalidate selection after production store or key-generation failure. -Probe native storage first, then encrypted -storage only if native fails, using fresh disposable keys in Pocket's record -shape. Reopen and verify identical key agreement; reject missing or extractable -runtime keys. Both formats failing shows a storage -compatibility error without resetting pairing data. Attempt probe database -deletion on exit. (rationale) +registration, sign-in, token retirement, or pairing** — one probe per page, +shared in flight, cached only on success and only in memory, and invalidated by +any later production store or key-generation failure. **Must probe native +storage first and encrypted storage only if native fails**, on fresh disposable +keys in Pocket's own record shape, selecting a format only once it survives +reopen and identical key agreement. **Both formats failing shows a storage +compatibility error without resetting pairing data**, pointing at +`/diagnostics/index.html`. (rationale) **Must identify the failed probe stage and an allowlisted exception name; never display browser exception messages or key material.** -**Must keep the separate-key experiment out of pairing preflight.** Direct -compatibility failures to `/diagnostics/index.html`. +**Must keep the separate-key experiment out of pairing preflight.** **Must use metadata-only summaries for listing, push registration/queries, removal, and re-pair identity checks.** `getSummary` and `listSummaries` omit key -material without decryption/import. Corrupt keys cannot block these operations; -re-pairing requires fresh approval and preserves the Burrow pin. Connection -and push decryption use full records. +material without decryption or import, so a corrupt key blocks none of them; +connection and push decryption use full records. **Must report connection-record read failures with fixed retry/scan recovery text, never browser exception details or authorization changes.** A fresh scan -retains the pin and requires approval; a read failure grants nothing. +keeps the Burrow pin and requires fresh approval; a read failure grants nothing. **Must use the selected format for new keys and decode both formats in the shared page/worker store.** The encrypted format stores AES-GCM ciphertext, @@ -452,35 +453,6 @@ in `lib/src/remote/client/pocket-client.ts`. ## Serving the built bundle -**Must serve the opt-in capability harness at `/diagnostics/index.html`, built -from `lib/pocket/diagnostics/` as a second Pocket HTML entry.** Test fresh keys and isolated temporary -storage, report stage failures and cleanup failures, and never read pairing -data, request passkeys or media permissions, or upload results. API presence -is observational; crypto storage success requires reopening and using the key. -**Must use the production key codec for encrypted round-trip and restart tests, -including authenticated context.** Diagnostics never open production databases. -Keep primitive checks independently generated and database-isolated. -**Must keep diagnostics platform-neutral and state which browser/app context -was tested.** API presence alone never certifies Android, iOS, or desktop support. - -**Must retain a restart checkpoint only on explicit preparation, in a -diagnostic-only database, until explicit cleanup.** Verification requires a new -page instance and derives the saved expected result using the recovered key; -never claim page reload proves process termination. The diagnostic manifest has -its own identity and start URL. Reports omit key material. Pinned by -`lib/src/remote/client/capability-harness.test.ts`. -**Must identify harness v3 production-format reports and reject legacy restart -checkpoints with explicit cleanup/reprepare instructions**, never silently -reclassify experimental evidence. Preserve the v1 production envelope/context. - -Source of truth: `runCapabilities` in -`lib/pocket/diagnostics/capabilities.js`; UI: -`lib/pocket/diagnostics/page.js`; restart: `verifyRestart` in -`lib/pocket/diagnostics/restart.js`; codec: `generatePocketKeyPair` / -`loadPocketPrivateKey` in `lib/src/remote/client/pocket-private-key.ts`. -Both built HTML shells are checked by `assertPocketShell` in -`lib/scripts/assert-pocket-worker.mjs`. - Content types need no special-casing: `serveStatic` already answers `application/manifest+json` for `.webmanifest` and `text/javascript` for `sw.js`. @@ -498,7 +470,36 @@ previous build's hashed assets (rationale). Two rules make it hold: response's cache policy describes the response, and the shell is never a useful answer to a subresource miss. (rationale) -Source of truth: `registerPocketServing` in `relay/src/app.ts`. +Source of truth: `registerPocketServing` in `relay/src/app.ts`. Both built HTML +shells are checked by `assertPocketShell` in +`lib/scripts/assert-pocket-worker.mjs`. + +### The capability harness + +**Must serve the opt-in capability harness at `/diagnostics/index.html`**, built +from `lib/pocket/diagnostics/` as a second Pocket HTML entry with its own +manifest identity and start URL. Four rules make its evidence worth anything: + +- **Never read pairing data, open a production database, request passkeys or + media permissions, or upload results**; reports omit key material. +- **Must use the production key codec, authenticated context included**, for the + encrypted round-trip and restart tests. API presence is observational and + certifies no platform, so a report states which browser/app context it + measured; a crypto-storage pass requires reopening and using the key. +- **Must retain a restart checkpoint only on explicit preparation**, in a + diagnostic-only database, until explicit cleanup; verification needs a new page + instance and derives the saved expected result with the recovered key. +- **Never claim a page reload proves process termination.** + +**Must identify harness v3 production-format reports and reject legacy restart +checkpoints with explicit cleanup/reprepare instructions**, never silently +reclassifying experimental evidence; the v1 production envelope and context are +preserved. Pinned by `lib/src/remote/client/capability-harness.test.ts`. + +Source of truth: `runCapabilities` in `lib/pocket/diagnostics/capabilities.js`; +UI: `lib/pocket/diagnostics/page.js`; restart: `verifyRestart` in +`lib/pocket/diagnostics/restart.js`; codec: `generatePocketKeyPair` / +`loadPocketPrivateKey` in `lib/src/remote/client/pocket-private-key.ts`. ## A backgrounded phone loses its Burrow session @@ -643,5 +644,3 @@ Source of truth: `pocketContentSecurityPolicy` in `relay/src/app.ts`, 3. **Theme picker in Pocket** — the app restores the persisted theme but exposes no picker; add the shared `ThemePicker` (and its theme-debugger entry) once its dropdown is phone-friendly. -4. **Onboarding friction** — Pocket carries the phone-side items of the - **selfhost-onboarding** scope ([relay.md](./relay.md) `## Future`). diff --git a/docs/specs/pocket-app.rationale.md b/docs/specs/pocket-app.rationale.md index 994878811..8b7fc9bdf 100644 --- a/docs/specs/pocket-app.rationale.md +++ b/docs/specs/pocket-app.rationale.md @@ -72,8 +72,6 @@ mode. [WebKit's iOS Web Push guidance](https://webkit.org/blog/13878/web-push-fo **Why a failed or in-flight read never settles at empty.** An empty result and "not yet known" would render the same card. Re-offering the idempotent Enable costs a redundant registration at worst, while a stale **Push notifications on.** claim hides the repair entirely. -**Why the Relay omits rows under an old VAPID key.** After a key rotation those rows can never be delivered to. Serving them would let a device that repaired one Burrow see another Burrow's dead endpoint as current, and stop offering the Enable that would fix it. - **Why the push endpoint is fingerprinted.** A push service may rotate an address on its own with the VAPID key unchanged: the subscription stays valid and correctly keyed while every stored Relay row points somewhere unreachable — a state no other check can see. One scope holds one subscription, so a move invalidates every Burrow row for that device at once, and one recorded digest covers them all. **Why a matching subscription is reused rather than replaced.** Calling `subscribe()` again with a matching `applicationServerKey` mints a new endpoint and invalidates the one already stored for every other Burrow — turning a single Burrow's registration into a silent outage for all of them. diff --git a/docs/specs/relay.md b/docs/specs/relay.md index 99426b4db..f21613f2e 100644 --- a/docs/specs/relay.md +++ b/docs/specs/relay.md @@ -37,10 +37,13 @@ primitive lives in `remote-lib-common`, the terminal UI in `lib`/`standalone`. prune** — `ChallengeIssuer.issue` drops expired entries on every call, as do the presence-nonce and setup-token stores (rationale). * **A cap that one caller can spend on another's behalf is not a cap.** Every - bounded transient store here is keyed by whoever grew it: setup tokens per + *keyed* transient store here is keyed by whoever grew it: setup tokens per minting Burrow (`MAX_TOKENS_PER_BURROW`), presence nonces per session (`MAX_PENDING_REAUTH_NONCES_PER_SESSION`, `MAX_REAUTH_NONCE_SESSIONS` LRU - buckets bounding the total) (rationale). + buckets bounding the total). **The two challenge issuers are the accepted + exception** — one flat `MAX_PENDING_CHALLENGES` map apiece, whose oldest entry + any unauthenticated caller can evict at the cost of one ceremony's retry + (WebAuthn below; rationale). ## Configuration @@ -56,9 +59,16 @@ Production configuration (`pnpm --filter relay start`, containers, and installer | `DORMOUSE_BIND_HOST` | Interface to listen on; unset binds every interface (below). | | `DORMOUSE_VAPID_PUBLIC_KEY` / `DORMOUSE_VAPID_PRIVATE_KEY` | Web Push signing keypair; set both or neither. At startup the Relay decodes both, derives the P-256 public point from the private key, and exits on a missing, malformed, or mismatched pair. Unset, it mints a pair on first boot into `vapid.json`. | | `DORMOUSE_VAPID_SUBJECT` | `mailto:`/`https:` contact for push-service operators (RFC 8292), defaulted from `DORMOUSE_ORIGIN` and validated at startup — Web Push below. | -| `DORMOUSE_RUNTIME_FILE` | Absolute path the Relay records `{pid, releaseId, port, origin, startedAt}` into once it has **bound**, mode `0600`. Unset — dev, containers, every test — writes nothing. A relative value is a `ConfigError`, and the path lives outside `DORMOUSE_STATE_DIR` (rationale). | +| `DORMOUSE_RUNTIME_FILE` | Absolute path the Relay records `{pid, releaseId, port, origin, startedAt}` into once it has **bound**, mode `0600`. Unset — dev, containers, every test — writes nothing. A relative value is a `ConfigError`; the installers keep it in `run/`, outside `DORMOUSE_STATE_DIR` (`SELF_HOST.md`), which nothing in the Relay enforces (rationale). | | `DORMOUSE_RELEASE_ID` | The release directory's name, supplied by the installer's `run-relay` wrapper, recorded in the runtime file. `null` when the Relay was not started by an installer. | -| `DORMOUSE_ENROLL_TOKEN_FILE` | Absolute installer offer path — `{origin, token, mintedAt}`, the token 64 hex characters, shape in `remote-lib-common/src/remote/enroll-offer.ts` — which `POST /api/burrow/enroll` accepts in place of the setup password; unset, one-click enrollment is off. A relative value is a `ConfigError` (rationale). **The offer lasts until the first Burrow enrollment or 24 hours, whichever comes first**, `burrows.json` being the durable marker (rationale); the Burrow-store mutex serializes password/token requests. **Redemption atomically renames the file before minting**, so exactly one concurrent redemption wins, and a mismatched claim is restored by no-clobber hard link so a newer installer generation wins. The installer rotates offers only before `burrows.json` exists. | +| `DORMOUSE_ENROLL_TOKEN_FILE` | Absolute path to the installer's enrollment offer — `{origin, token, mintedAt}`, shape in `remote-lib-common/src/remote/enroll-offer.ts` — which `POST /api/burrow/enroll` accepts in place of the setup password; unset, one-click enrollment is off. A relative value is a `ConfigError` (rationale). | + +**The enrollment offer lasts until the first Burrow enrollment or 24 hours, +whichever comes first**, `burrows.json` being the durable marker (rationale). +**Redemption renames the file before minting**, so exactly one concurrent +redemption wins and a claim holding a newer offer is put back untouched. +Source of truth: `relay/src/enroll-token.ts`; test: +`relay/test/enroll-token.test.mjs`. **Must generate the setup password inside the Relay on first boot, never accept it as configuration, and persist it as `setup-password.json`.** Use 32 @@ -85,11 +95,12 @@ not admission*: the HTTP API table owns the public routes and credential gates. **`DORMOUSE_ORIGIN` is normalized to a bare origin exactly once**, in `readConfig` by the shared `normalizeOrigin` in `remote-lib-common`; a value that -is not a URL with a host is a `ConfigError` naming the variable (rationale). +is not an `http`/`https` URL with a host is a `ConfigError` naming the variable +(rationale). WebAuthn clientData checks, passkey assertion verification, the Burrow enrollment policy and the pairing URL a Burrow composes all compare against that string -rather than re-parsing it; `createApp` parses it only to take `rpId` from the -hostname. +rather than re-parsing it; `createApp` re-checks that same shape and takes +`rpId` from the hostname. **`startRelay` in `relay/src/start.ts` validates the VAPID pair and subject before building the app** — the only disk half of an otherwise pure env→config mapping. @@ -129,13 +140,12 @@ override rule; `standalone/scripts/build-sidecar-proxy.mjs` and `bakedConnectSrc()` in `lib/src/host/remote/connect-src.ts` is the single reader — **reading it as a `declare const`, never an import**, so the value is a literal nothing at runtime can move. -Two build-time guards, both because their failure mode is silent (rationale): -`assertConnectSrcBaked` greps the bundle for the define, and -`resolveRemoteConnectSrc` rejects an override the matcher could never read — a -trailing slash, a path, a bare host, a scheme outside `http`/`https`/`ws`/`wss`, -a port outside 1–65535. The grammar is one regex duplicated into the `.mjs`, -which cannot import TypeScript; `connect-src.test.ts` pins both patterns, and -both copies of the default, as identical. +**Both failure modes are silent, so the bundle is grepped for the define and +the override grammar is validated, at build time** (rationale): +`assertConnectSrcBaked` and `resolveRemoteConnectSrc`. The grammar is one regex +duplicated into the `.mjs`, which cannot import TypeScript; +`lib/src/host/remote/connect-src.test.ts` pins both patterns, and both copies of +the default, as identical. **Enforcement is `originAllowedByConnectSrc` in `lib/src/host/remote/service.ts`:** @@ -149,8 +159,7 @@ both copies of the default, as identical. Matching is narrower than a browser's: `https`/`wss` are one scheme class and `http`/`ws` the other, hostname matches exactly or by a leading `*.` wildcard covering any depth of sub-domain but never the bare domain, ports must match -unless the source says `*` (numeric ports canonicalized as `URL` does, so a -leading zero is not a silent miss), and anything unparseable fails closed. +unless the source says `*`, and anything unparseable fails closed. **Enrollment and Burrow-authenticated push fetches must use `redirect: 'error'`** — a Node process does not re-check a redirect target, so following one could carry the setup password, Burrow bearer token, or notification metadata outside the baked @@ -231,8 +240,8 @@ stale row rather than leave one per rotation: them (rationale). Rows already carrying the *presented* endpoint are the same scope and stay, which is what makes a second Burrow's registration additive. * **The response reports the state that mutation left behind** — every Burrow the - presented endpoint is still registered with — so a committed POST whose - response was lost is repaired by its own idempotent retry. + presented endpoint is still registered with under the current VAPID key — so a + committed POST whose response was lost is repaired by its own idempotent retry. * **Removing a Burrow row is observed lazily**, nothing cascading on write: `listForBurrow` answers nothing for a `burrowId` that is gone, so its rows are unreachable the moment the edit lands and leave disk on the next 404/410 prune @@ -294,7 +303,7 @@ The Relay owns the fixed `/api/hello` health response, pinned by | -------------------------------- | -------------- | ------------------------------------------------- | | `GET /api/hello` | — | Fixed `{ message: "Hello, world!" }` health response. **Carries no release identity** — it is unauthenticated and reachable through the HTTPS proxy; the runtime file carries it ("Installing it") | | `POST /api/setup/begin` | setup token | Issues a registration challenge, gated exactly as `finish` is so neither is softer. Answers the account's credential ids for a retry's `excludeCredentials`, so no passkey that already signs in is duplicated — an orphan the Relay never registered is absent, and is still replaced | -| `POST /api/setup/finish` | setup token | Registers the passkey in `account.json`; the token is spent at the gate and put back if registration then fails. `label` is **reduced, not refused** — the same `boundedPushText` a pairing label goes through, to `MAX_PASSKEY_LABEL_LENGTH` code points with control and bidi characters stripped | +| `POST /api/setup/finish` | setup token | Registers the passkey in `account.json`; the token is spent at the gate and put back if registration then fails. `label` is **reduced, not refused** — the same `boundedPushText` a pairing label goes through, to `MAX_PASSKEY_LABEL_LENGTH` code points, control characters becoming spaces that then collapse and the bidi and zero-width set dropped outright | | `POST /api/setup/retire` | session token | Spends a live setup token without registering anything (rationale). 204, or 401 `SETUP_TOKEN_INVALID_ERROR` | | `POST /api/signin/begin` | — | Issues a sign-in challenge | | `POST /api/signin/finish` | — | Verifies the assertion and issues a 12-hour in-memory session token | @@ -359,8 +368,8 @@ because only a Burrow sends one; **a rejected setup token answers the distinct ### Setup tokens and the pairing QR An enrolled Burrow mints a setup token over its own authenticated channel; the -response carries the token alone — the Burrow knows its enrolled origin and -composes the QR itself. **Scanning is the only way a passkey is registered** — +response carries `{ token, expiresAt }` and no origin or URL — the Burrow knows +its enrolled origin and composes the QR itself. **Scanning is the only way a passkey is registered** — `/api/setup/*` takes no other credential. **The QR grammar is this spec's.** Exactly @@ -484,10 +493,13 @@ Relay's Web Push dependency. Burrow and webview halves: **Must count sender throws and rejections as `failed`, preserving sibling deliveries and subscriptions**, pinned by `relay/test/push.test.mjs`. Both are separate from the 300-second provider TTL — an alarm an hour late is noise. -- **Push is disabled, not half-working**, when no VAPID key **or no VAPID - subject** is configured: the config route reports `null` and subscribe/send - answer 503; a phone registered against a key the Relay has no contact to sign - with would be subscribed to a push it can never receive. +- **Push is disabled, not half-working**, and **only a missing VAPID subject + turns it off**: `startRelay` always resolves a keypair, minting one into + `vapid.json` when env supplies none, so the contact is the whole switch. With + none the config route reports `null` and subscribe/send answer 503 — a phone + registered against a key the Relay has no contact to sign with would be + subscribed to a push it can never receive. Only an injected `createApp` config + reaches the key-less path. - **A VAPID subject naming a loopback host is a startup error, not a default** (rationale). The default is `DORMOUSE_ORIGIN` when that origin is https and not loopback; otherwise there is none, and a loopback dev server turns push off @@ -647,9 +659,11 @@ framed as application messages on the Noise session (below). four signals** ([remote-api.md](./remote-api.md) → Direct path), which the Relay routes without reading, like every other ciphertext. - **Prologues are `lengthPrefixedConcat`** of `dormouse/e2e/v1`, the ceremony - kind, the `burrowId`, and — for a connection — the connection id; for a pairing, - every field of its invitation in QR order ("Setup tokens and the pairing QR" - above), so a transcript is useless against another Burrow, id, or ceremony. + kind, the `burrowId`, and — for a connection — the connection id; for a + pairing, the invitation's own fields in QR order after that — `v`, `inviteId`, + `expiry`, `setupToken`, `ephPub` ("Setup tokens and the pairing QR" above) — + the `burrowId` ahead of `v` here, behind it in the QR. A transcript is + therefore useless against another Burrow, id, or ceremony. Source of truth: `remote-lib-common/src/security/noise-transport.ts`, pinned by `remote-lib-common/test/noise-transport.test.mjs` and driven through the real @@ -804,8 +818,7 @@ the build cannot do. `no-burrow`**, a superset covering both a Burrow service that has not enrolled *and* a build with no Burrow service at all ([alert.md](./alert.md) -> Push notifications). Only the first has a section beneath it, so only the first says -"below". `describePushTargets` takes the seam as an argument; the `PushNoBurrow` / -`PushNotEnrolled` story pair holds the two apart. +"below"; the `PushNoBurrow` / `PushNotEnrolled` story pair holds the two apart. Un-enrolled it is a three-field form (Relay, setup password, Burrow name — prefilled with the `suggestedLabel` `status` carries) calling the service's @@ -820,7 +833,7 @@ exists to honor: three-field form folded behind "Enroll with a different Relay…" — **folded with `hidden`, never unmounted**, so typed input survives the disclosure and an offer appearing underneath it. **Reading the file is bounded to the un-enrolled - state**; enrolled answers `offer: null` without touching disk (rationale). + state** (rationale). - **The offer's token never enters a webview.** `status` carries only the origin; `enrollOffer` re-reads the file in the Burrow service, so an old card cannot reuse a spent offer (`docs/specs/security-remote.md` -> "Credentials at rest"). @@ -828,54 +841,46 @@ exists to honor: file that no longer names it — an installer rerun rewrites the offer with an origin nobody reviewed. `enrollOffer` takes `{ origin, label }`: the origin reviewed, never the one enrolled against, which stays the file's. -- **The card outlives its offer**, so a refusal landing after the enroll unlinked - the file is not silence over a spent token. +- **The card outlives its offer**, so a late refusal is not silence over a spent + token. - **Only one enrollment may run.** One synchronous gate covers both forms and pre-render double clicks. - **The password is passed through, never held**, cleared on success; `enroll` answers `{ burrowId, relayUrl }`, so `burrowToken` never re-enters the webview. - **Refusals are shown, not swallowed**, the offer card included: the allowlist - error (above) is what the form renders, so the failure reads as "this build - will not talk to that Relay" rather than as a wrong password. + error (above) is what the form renders, never a wrong-password message. - **Enrolled, "Set up a phone" opens an inline QR panel**, so a phone is set up by pointing a camera at the laptop rather than typing an origin and a 64-hex password. It mints on open and never before, re-mints shortly before `expiresAt` while the panel stays open, and always offers New code and Done, the only exit from a dead code (`RemoteControlSection.test.tsx`). Rules it exists to honor: - - **The panel owns its busy and error**, not the section's shared pair, since - its mint also fires on a timer. - - **Must clamp refresh delay to `[30 s, DEFAULT_PAIRING_TTL_MS - 20 s]`**: the - floor stops a fast-clock mint loop, the ceiling replaces a slow-clock code - before its real Relay expiry. + - **The panel owns its busy and error**, not the section's shared pair. + - **Must clamp refresh delay to `[30 s, DEFAULT_PAIRING_TTL_MS - 20 s]`.** - **The code being replaced stays on screen** until its replacement lands; only a first mint blanks. - **An invitation state change flips only the panel showing that `inviteId`**, - so a second window offering a different code stays live, and **the panel - stays subscribed past the QR**: `reserved` spends the code, `consumed` says - the request it produced has been answered. + and **the panel stays subscribed past the QR**: `reserved` spends the code, + `consumed` says the request it produced has been answered. - **The panel reports which decision ended the code**, in fixed copy per outcome (rationale), riding that `consumed` event; a retirement nobody - decided carries none. **One region reports it**: the panel where it - supersedes that sentence, the section otherwise. **Only a user action clears - it**, never the timed re-mint. + decided carries none. **One region reports it** — the panel where it + supersedes that sentence, the section otherwise — and **only a user action + clears it**. - **The view is keyed by enrollment identity and the QR sits behind its own - error boundary**: a Relay swap drops the stale code, and a failed chunk - fetch or refused encode costs a retry button, not the app-wide ErrorBoundary. + error boundary**, so neither a Relay swap nor a refused encode reaches the + app-wide ErrorBoundary. - **Disconnect asks first**: clearing the enrollment drops every paired phone until each pairs again. -- **Status is re-read, not patched, and the connection is polled.** The - service's `status` event carries only `{ enrolled }`, so every event triggers a - full `status` command, and the dialog re-reads on open since another window may - have enrolled meanwhile. The *connection* moves with no event at all, so the - store polls every 2 s **while something is subscribed**, never as a standing - timer in every window, comparing field-wise before publishing (rationale; same - rule as `setPushDevices` in `lib/src/lib/push-devices.ts`). +- **Status is re-read, not patched**: the service's `status` event carries only + `{ enrolled }`, so every event triggers a full `status` command, and the dialog + re-reads on open since another window may have enrolled meanwhile. **The + connection is polled every 2 s while something is subscribed**, never as a + standing timer in every window, comparing field-wise before publishing + (rationale; same rule as `setPushDevices` in `lib/src/lib/push-devices.ts`). - **Reads are serialized, and coalescing stops at anything that changes the - answer.** Ticks arriving during a slow read queue behind it, so a 15-second - Burrow-service timeout becomes the visible error rather than being superseded by - newer polls; `enroll`, `reconnect`, `clearEnrollment` and losing the last - subscriber each *drop* the read in flight (rationale). + answer** — `enroll`, `reconnect`, `clearEnrollment` and losing the last + subscriber each drop the read in flight (rationale). Source of truth: `useSetupQr` and `ScannableCode` in `lib/src/components/RemoteControlSection.tsx` over `lib/src/components/QrCode.tsx` diff --git a/docs/specs/relay.rationale.md b/docs/specs/relay.rationale.md index f6cb65527..354434889 100644 --- a/docs/specs/relay.rationale.md +++ b/docs/specs/relay.rationale.md @@ -16,7 +16,7 @@ **Why a relative path is a `ConfigError`.** `DORMOUSE_RUNTIME_FILE` and `DORMOUSE_ENROLL_TOKEN_FILE` come from the installer's `run-relay` wrapper, which a service manager launches with a working directory that is not the installer's — a relative value lands where neither side can predict. The same drift is why `DORMOUSE_POCKET_DIR` resolves from the compiled Relay's own location: a service manager could otherwise change what is served. -**Why the runtime file sits outside the state dir.** It is runtime truth about one process — pid, port, release — not durable state a backup should capture and a restore replay. +**Why the installers keep the runtime file outside the state dir.** It is runtime truth about one process — pid, port, release — not durable state a backup should capture and a restore replay. **Why a blank `PORT` is not zero.** `Number('')` is 0, which asks the OS for an ephemeral port and moves the Relay out from under whatever proxy is pointed at it — the same reason an explicit `PORT=0` is refused. diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 57ca3f7ba..a8e0e95bc 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -94,11 +94,9 @@ signal always fits one control body. **No ICE servers**, and **never a public STUN or TURN default**: `iceServers: []` at both ends, host candidates only. (rationale) -**The two shipped stacks are proven against each other by hand.** No CI job has -a browser, so `scripts/direct-interop/run.mjs` negotiates a real browser against -the real addon over the shipped `DirectPeer` — measuring the browser's offer -against `MAX_DIRECT_SDP_LENGTH`, which is a property of the host's interfaces -rather than of the code (rationale). +**The two shipped stacks are proven against each other by hand**, by +`scripts/direct-interop/run.mjs` over the shipped `DirectPeer`, which also +measures a real browser's offer against `MAX_DIRECT_SDP_LENGTH` (rationale). **Every byte on the channel is a Noise transport message of the promoted session**: one message per channel frame, raw bytes, the same two `CipherState`s @@ -114,13 +112,11 @@ session, and an answerer that refuses before it has answered declines rather than leaving the offerer to wait out its setup budget. A limit the implementation does not report is not treated as small. -**Two limits of those checks are known and accepted.** The reliability flags -reach only as far as the implementation reports them, and `node-datachannel`'s -polyfill rebuilds an incoming channel with its own defaults — so on the -standalone Burrow only the label comparison is load-bearing (rationale). And the -message limit is the *remote's* advertised one, so it is per direction: where -the two ends disagree, a peer that has already switched loses the session rather -than staying relayed. +**Two limits of those checks are known and accepted**: on the standalone Burrow +the reliability flags reach nothing, so only the label comparison is +load-bearing; and the message limit is the *remote's* advertised one, so where +the two ends disagree a peer that has already switched loses the session rather +than staying relayed. (rationale) **A sender bounds its own queue rather than the implementation's.** Past `DIRECT_BUFFER_HIGH` of buffered channel data the ciphertext queues, draining at @@ -128,11 +124,9 @@ than staying relayed. overtakes a frame encrypted before it. **A frame is written once or not at all** — the implementation's send either consumes a message or throws, and a retry would put counted ciphertext on the wire twice. Overflowing -`MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` disposes the session, -as the receiver's hold does. **Each failure is reported in its own words**: this -end's queue overrunning and the channel refusing a write are opposite diagnoses, -and the reason is all an operator reading a burrow-loss log has to tell them -apart. +`MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` — the one pair both +directions use — disposes the session, as the receiver's hold does. **Each failure is reported in its own words** — a +queue overrun and a refused write are opposite diagnoses in a burrow-loss log. **The switch preserves order per direction:** @@ -211,6 +205,12 @@ Reserved: a `capabilities` field on the client hello (what the client can render `directory.watch` subscribes to a live, lightweight listing of every pane — enough to render the picker and know which pane wants attention, without attaching. `DirectoryEntry` / `DirectorySnapshot` carry the terminal-only payload: identity, derived title, focus, semantic state, PTY liveness, and the `ringing` / `hasTODO` badges. Nothing else — thumbnails are staged. +Reserved: **`paneRef` is set to the same value as `surfaceId`** and no Client +reads it — it becomes the Pane handle when `window.watch` lands ([Future](#future), +The Window), so a Burrow keeps setting it. **`focused` and `exitCode` likewise +have no Client reader today**, produced for picker affordances the phone does +not render yet. + **Snapshot-only, never deltas**: on any change the Burrow coalesces (150ms window, `DIRECTORY_DEBOUNCE_MS`) and resends the whole listing. (rationale) **One snapshot per collect** — the provider answers for every surface the Burrow can reach, so no subset is known sooner. **A collect is dropped unless it is still the newest and its subscription neither replaced nor torn down**, a per-collect generation of the same shape as the per-attach one keeping a stale answer — an empty timed-out one included — from blanking the picker (rationale). **A collection that rejects emits nothing** and leaves the last good snapshot standing, contained inside the session; the next invalidation or `directory.watch` retries it. diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 2c7f0d15a..ce816ad66 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -349,10 +349,9 @@ omits `client-gone`, invents client IDs, or reorders frames. | `ESTABLISHED_E2E_IDLE_TIMEOUT_MS` | 120 000 | same | | `E2E_INIT_BURST` / `E2E_INIT_REFILL_INTERVAL_MS` | 8 / 1 000 | same | | `DIRECT_SETUP_TIMEOUT_MS` / `DIRECT_ANSWER_TIMEOUT_MS` / `DIRECT_GATHER_TIMEOUT_MS` | 15 000 / 10 000 / 3 000 | `remote-lib-common/src/security/direct-path.ts` | -| `DIRECT_HANDOFF_TIMEOUT_MS` / `DIRECT_DISCONNECTED_GRACE_MS` | 5 000 / 5 000 | same | +| `DIRECT_HANDOFF_TIMEOUT_MS` / `DIRECT_DISCONNECTED_GRACE_MS` | `= DIRECT_SETUP_TIMEOUT_MS` (15 000) / 5 000 | same | | `MAX_DIRECT_SDP_LENGTH` | 2 000 characters | same | -| `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first (rationale) | same | -| `MAX_DIRECT_OUTBOUND_FRAMES` / `MAX_DIRECT_OUTBOUND_BYTES` | the same pair, for what a sender holds | same | +| `MAX_DIRECT_PENDING_FRAMES` / `MAX_DIRECT_PENDING_BYTES` | 8 192 frames / 4 MiB, bytes binding first; one pair for a receiver's hold and a sender's queue alike (rationale) | same | | `DIRECT_BUFFER_HIGH` / `DIRECT_BUFFER_LOW` | 256 KiB / 64 KiB | same | - **Must bound waiting relay frames before enqueueing**, by count and cumulative @@ -422,22 +421,15 @@ admits Burrow enrollment with ([relay.md](./relay.md#http-api)). Pinned by **The direct path adds no layer to this model.** A WebRTC data channel replaces the Relay as the carrier of an already-authorized session; every rule above holds unchanged, because nothing about *what* is carried changes. -[remote-api.md](./remote-api.md) -> "Direct path" owns the design and is not -restated here: that the channel carries transport messages of the session -promoted at [Connection](#connection) on that `Split`'s own two `CipherState`s, -that every signal rides inside the ciphertext, that nothing is offered before -promotion, and that one peer connection per session is closed by every path -that ends one, are its rules. What this model adds is what is *underneath* them. +[remote-api.md](./remote-api.md) -> "Direct path" owns the design — the session +promoted at [Connection](#connection) and its `CipherState`s, the signals that +ride inside it, the empty ICE-server list, and the bound on either end's queue. +What this model adds is what is *underneath* them. - **DTLS beneath is transport hygiene this model does not rely on.** It protects nothing the Noise session does not already protect, and **the fingerprints in an SDP are authentic for exactly one reason — that SDP arrived inside the session**. A DTLS peer is never an authenticated one. -- **Never an ICE server.** Both ends pass an empty list. (rationale) -- **What the channel may buffer is this side's bound, not the - implementation's**, in both directions ([remote-api.md](./remote-api.md) -> - "Direct path"): the same pair of numbers holds a sender's queue and a - receiver's, and overrunning either disposes the session. **The listener is UDP on every interface a candidate names, for the life of an attempt.** The standalone Burrow's addon binds one socket on the unspecified @@ -609,10 +601,11 @@ Onboarding changes with security surface are staged in the ### Device verification -Two properties of the shipped Pocket client are observable only on a real iOS -device, and both are load-bearing: the selected Client-static storage format -surviving an app and phone restart, and `getUserMedia` working inside a Home Screen web app (without -it the install has only the paste field). +`getUserMedia` inside a Home Screen web app is observable only on a real iOS +device, and without it the install has only the paste field. The Client-static +storage format surviving an app and phone restart is no longer open: the v3 +Home Screen report is in [Client statics](#client-statics)'s rationale and in +[pocket-app.rationale.md](./pocket-app.rationale.md). ### Revocation propagation diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index fbdf98de3..b0544331f 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -236,7 +236,7 @@ layer; neither is restated below. - **FAIL IF** any signaling leaves the ciphertext. The four signals are `control` messages on the established session, so no relay route, frame type, or Relay-side guard may carry, name, or validate an SDP or a candidate: a negative search over `relay/src/` for `sdp`, the four signal names, and `RTCPeerConnection` must find nothing. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** any ICE server reaches shipped source — a `stun:`, `stuns:`, `turn:`, or `turns:` URL, or a non-empty `iceServers` array, anywhere under `remote-lib-common/src/`, `lib/src/`, or `relay/src/`. Both factories pass `iceServers: []`; a public default hands a third party the user's address. `scripts/e2e-lint.mjs` holds it textually. - **FAIL IF** a peer connection can outlive its session. `DirectEndpoint.dispose` closes it and must run on every path that ends one: in `lib/src/remote/burrow/burrow-runtime.ts` `#disposeEstablished` — which `#disposeClient` reaches from `client-gone`, socket loss and `stop()` — and the session `#promoteConnection` replaces; in `lib/src/remote/client/pocket-client.ts` `#disposeCeremony`, on every teardown, an intentional `close()` and a dropped relay socket included. -- **FAIL IF** the direct path stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by `MAX_DIRECT_OUTBOUND_FRAMES` **and** `MAX_DIRECT_OUTBOUND_BYTES` — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. +- **FAIL IF** the direct path stops bounding what it holds, or stops disposing on a violation. Held frames are capped by `MAX_DIRECT_PENDING_FRAMES` **and** `MAX_DIRECT_PENDING_BYTES`, and a sender's queue by that same pair — neither direction may hand the implementation unbounded data instead, and overflow disposes the session rather than dropping a frame; a relay `transport` frame arriving after inbound has switched disposes it before any decrypt; the channel closing or erroring after either direction has switched disposes it at both ends. `DirectCutover` in `remote-lib-common/src/security/direct-path.ts` decides all three, and through `onSwitchDecrypted` that a switch onto a channel this end abandoned ends the session; `DirectEndpoint`, which both ends run, must act on every outcome it returns, and must be both ends' only entry for a relay frame: `onRelayFrame` decodes the `ct` there, so an undecodable one ends the session rather than escaping a socket handler. Pinned by `remote-lib-common/test/direct-path.test.mjs`, `lib/src/remote/direct/direct-endpoint.test.ts`, and the direct cases in `lib/src/remote/burrow/burrow-bounds.test.ts` and `lib/src/remote/client/pocket-client.test.ts`. - **FAIL IF** the standalone Burrow's native peer addon is loaded at sidecar boot rather than at the first offer, or its absence changes anything but a decline. `createNativeDirectPeerFactory` in `lib/src/host/remote/` must reach `node-datachannel` — declared in `standalone/sidecar/package.json` — only through a bare `require` performed inside an authorized session's first offer, and a load failure must answer `direct-decline` and leave that session relayed rather than fail the Burrow's start. - **FAIL IF** a switched end waits on its peer without a deadline, or a channel this protocol did not ask for is adopted. `DirectPeer` in `lib/src/remote/direct/direct-peer.ts` must refuse a channel that is not `DIRECT_CHANNEL_LABEL`, one reported unordered or partially reliable, and one whose association reports a per-message limit below `NOISE_MAX_MESSAGE_LENGTH` — all before it reports the open, so each abandons the attempt while the relay still carries the session. **The reliability half is defence in depth against a paired Client, not a boundary control**, and reaches only as far as the implementation reports those flags: on the standalone Burrow it does not, which `lib/src/host/remote/native-direct-peer.test.ts` pins so a version that changes it is noticed (`docs/specs/remote-api.md` -> Transport -> "Direct path"). `DirectEndpoint` must arm `DIRECT_HANDOFF_TIMEOUT_MS` on its own switch, since from there it sends only on the channel. Pinned by `lib/src/remote/direct/direct-peer.test.ts` and `lib/src/remote/direct/direct-endpoint.test.ts`. - **FAIL IF** a direct path survives `client-gone`, `burrow-gone`, or a lost relay socket: the Relay stays the lifecycle authority on both paths. Every Burrow bound is path-agnostic, and the idle deadline still moves only on a decrypted Client→Burrow transport message, whichever path carried it (`docs/specs/remote-security-model.md` -> "Burrow bounds"). diff --git a/lib/pocket/diagnostics/capabilities.js b/lib/pocket/diagnostics/capabilities.js index 903671242..ffb2e7e9d 100644 --- a/lib/pocket/diagnostics/capabilities.js +++ b/lib/pocket/diagnostics/capabilities.js @@ -1,4 +1,9 @@ -// Diagnostic databases are independent of Pocket's authorization database. +// Diagnostic databases are independent of Pocket's authorization database: each +// run opens its own temporary database under PREFIX and deletes it afterwards, +// reporting both stage failures and cleanup failures, and every primitive check +// generates its own key material rather than reusing another check's. Nothing +// here reads pairing data, asks for a passkey or a media permission, or uploads +// anything (`docs/specs/pocket-app.md` -> "The capability harness"). import { toBase64Url } from 'remote-lib-common'; import { generatePocketKeyPair, loadPocketPrivateKey, storePocketPrivateKey, diff --git a/lib/src/components/MobileTerminalUi.test.tsx b/lib/src/components/MobileTerminalUi.test.tsx index 1307623e3..19ff6e6ef 100644 --- a/lib/src/components/MobileTerminalUi.test.tsx +++ b/lib/src/components/MobileTerminalUi.test.tsx @@ -4,7 +4,7 @@ import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MobileTerminalUi, type MobileTerminalTouchMode, type MobileTerminalUiProps } from './MobileTerminalUi'; +import { MobileTerminalUi, paneMouseOverride, type MobileTerminalTouchMode, type MobileTerminalUiProps } from './MobileTerminalUi'; import { setNativeFieldValue } from '../lib/dom'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -357,3 +357,22 @@ describe('MobileTerminalUi touch modes', () => { expect(received).toEqual([]); }); }); + +describe('paneMouseOverride', () => { + it('overrides a reporting pane only in Select mode', () => { + expect(paneMouseOverride('selection', 'vt200')).toBe('permanent'); + expect(paneMouseOverride('selection', 'any')).toBe('permanent'); + }); + + it('leaves a pane that reports nothing alone, Select mode included', () => { + expect(paneMouseOverride('selection', 'none')).toBe('off'); + }); + + it('never overrides outside Select mode', () => { + for (const mode of ['gestures', 'cursor'] as const) { + for (const reporting of ['none', 'x10', 'vt200', 'drag', 'any'] as const) { + expect(paneMouseOverride(mode, reporting)).toBe('off'); + } + } + }); +}); diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index d64ba59af..9764a6fe0 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -41,6 +41,7 @@ import { useDynamicPalette } from '../lib/themes/use-dynamic-palette'; import { isEditableTarget } from '../lib/dom'; import { TouchUiContext } from './touch-ui-context'; import type { SessionStatus } from '../lib/terminal-registry'; +import type { MouseTrackingMode, OverrideState } from '../lib/mouse-selection'; export type MobileTerminalKeyboardMode = 'sessions' | 'recent' | 'type' | 'draft'; export type MobileTerminalTouchMode = 'gestures' | 'selection' | 'cursor'; @@ -84,6 +85,24 @@ const KEYBOARD_MODES: Array<{ id: MobileTerminalKeyboardMode; label: string; Ico { id: 'draft', label: 'Draft', Icon: ArticleNyTimesIcon }, ]; +/** + * The mouse override one pane gets, from the global touch mode and that pane's + * *own* mouse reporting. + * + * A pure function rather than each consumer's own conditional: touch mode is + * one UI state for the whole wall, so every mounted pane has to be reconfigured + * on a change — a pane switched away from that kept `permanent` would swallow + * the inner program's mouse input for as long as it stayed mounted. Both + * compositions call it in a loop over their panes + * (`docs/specs/mobile-terminal-ui.md` -> "Touch mode selector"). + */ +export function paneMouseOverride( + touchMode: MobileTerminalTouchMode, + reporting: MouseTrackingMode, +): OverrideState { + return touchMode === 'selection' && reporting !== 'none' ? 'permanent' : 'off'; +} + const TOUCH_MODES: Array<{ id: MobileTerminalTouchMode; label: string; @@ -98,9 +117,6 @@ const TOUCH_MODES: Array<{ export interface MobileTerminalUiProps { terminal: ReactNode; - activeSection?: MobileTerminalKeyboardMode; - defaultSection?: MobileTerminalKeyboardMode; - onSectionChange?: (section: MobileTerminalKeyboardMode) => void; activeKeyboardMode?: MobileTerminalKeyboardMode; defaultKeyboardMode?: MobileTerminalKeyboardMode; onKeyboardModeChange?: (mode: MobileTerminalKeyboardMode) => void; @@ -454,11 +470,8 @@ class RetrySchedule { export function MobileTerminalUi({ terminal, - activeSection, - defaultSection = 'type', - onSectionChange, activeKeyboardMode, - defaultKeyboardMode, + defaultKeyboardMode = 'type', onKeyboardModeChange, activeTouchMode, defaultTouchMode = 'gestures', @@ -477,10 +490,9 @@ export function MobileTerminalUi({ style, }: MobileTerminalUiProps) { useDynamicPalette(); - const resolvedDefaultKeyboardMode = defaultKeyboardMode ?? defaultSection; - const [internalKeyboardMode, setInternalKeyboardMode] = useState(resolvedDefaultKeyboardMode); + const [internalKeyboardMode, setInternalKeyboardMode] = useState(defaultKeyboardMode); const [internalTouchMode, setInternalTouchMode] = useState(defaultTouchMode); - const keyboardMode = activeKeyboardMode ?? activeSection ?? internalKeyboardMode; + const keyboardMode = activeKeyboardMode ?? internalKeyboardMode; const touchMode = activeTouchMode ?? internalTouchMode; const terminalHostRef = useRef(null); const inputRef = useRef(null); @@ -562,17 +574,14 @@ export function MobileTerminalUi({ }, [blurRetries, configurePaneTextInputs, focusRetries]); const setKeyboardMode = useCallback((nextMode: MobileTerminalKeyboardMode) => { - if (activeKeyboardMode === undefined && activeSection === undefined) { - setInternalKeyboardMode(nextMode); - } + if (activeKeyboardMode === undefined) setInternalKeyboardMode(nextMode); onKeyboardModeChange?.(nextMode); - onSectionChange?.(nextMode); if (nextMode === 'type') { focusInput(); } else { blurInput(); } - }, [activeKeyboardMode, activeSection, blurInput, focusInput, onKeyboardModeChange, onSectionChange]); + }, [activeKeyboardMode, blurInput, focusInput, onKeyboardModeChange]); const setTouchMode = useCallback((nextMode: MobileTerminalTouchMode) => { if (nextMode === 'cursor' && !cursorTouchAvailable) return; diff --git a/lib/src/remote/client/pocket-db.ts b/lib/src/remote/client/pocket-db.ts index 60a3a5f5c..aaaedd7af 100644 --- a/lib/src/remote/client/pocket-db.ts +++ b/lib/src/remote/client/pocket-db.ts @@ -136,6 +136,12 @@ export async function probePocketKeyStorage(mode: PocketKeyStorageMode = 'native } } +/** + * The one probe this page runs, shared by every caller that arrives while it is + * in flight. Memory only and per page: a success is evidence about this runtime + * now, not something to carry across an app restart, and a failure is never + * retained at all — the next attempt re-probes. + */ let storageProbe: Promise | undefined; /** A storage/key failure invalidates compatibility evidence for this page. */ @@ -161,6 +167,14 @@ export async function requirePocketKeyStorage(): Promise { if (storageProbe !== pending) throw new Error(POCKET_KEY_STORAGE_ERROR); } +/** + * Native first, encrypted only where native fails: the encrypted format exists + * for WebKit's broken X25519 key storage, not as a preference. Each attempt + * probes on fresh disposable keys in Pocket's own record shape, so a format is + * selected only once it has survived a reopen and identical key agreement; both + * failing leaves the caller with a compatibility error and the pairing records + * untouched. + */ async function selectKeyStorage(): Promise { try { await probePocketKeyStorage('native'); @@ -211,8 +225,9 @@ export interface KnownBurrowV1 { readonly accountId: string; /** * What to call this machine. The Burrow's own label, as it arrived inside the - * encrypted pairing outcome — never the Relay's copy, which a Client is not - * told and which stops existing in stage 4c. + * encrypted pairing outcome. The Relay keeps no label for a Burrow at all — + * `burrows.json` rows are `{burrowId, burrowToken, enrolledAt}` + * (`docs/specs/relay.md` -> "State files"). */ readonly label: string; /** The pinned Burrow Noise static, base64url. A change is a terminal error. */ diff --git a/lib/src/remote/direct/direct-peer.test.ts b/lib/src/remote/direct/direct-peer.test.ts index 220421c7a..75229d8eb 100644 --- a/lib/src/remote/direct/direct-peer.test.ts +++ b/lib/src/remote/direct/direct-peer.test.ts @@ -12,8 +12,8 @@ import { DIRECT_DISCONNECTED_GRACE_MS, DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, - MAX_DIRECT_OUTBOUND_BYTES, - MAX_DIRECT_OUTBOUND_FRAMES, + MAX_DIRECT_PENDING_BYTES, + MAX_DIRECT_PENDING_FRAMES, NOISE_MAX_MESSAGE_LENGTH, } from 'remote-lib-common'; @@ -261,7 +261,7 @@ describe('DirectPeer', () => { const frame = new Uint8Array(NOISE_MAX_MESSAGE_LENGTH); let held = 0; - while (client.closes.length === 0 && held <= MAX_DIRECT_OUTBOUND_FRAMES) { + while (client.closes.length === 0 && held <= MAX_DIRECT_PENDING_FRAMES) { clientPeer.send(frame); held += 1; } @@ -270,8 +270,8 @@ describe('DirectPeer', () => { // log has only the reason to tell those two apart. expect(client.closes).toEqual(['the direct path outran what a sender can hold in order']); // Bytes bind first: the frame cap is far above what this many reaches. - expect((held - 1) * frame.length).toBeLessThanOrEqual(MAX_DIRECT_OUTBOUND_BYTES); - expect(held * frame.length).toBeGreaterThan(MAX_DIRECT_OUTBOUND_BYTES); + expect((held - 1) * frame.length).toBeLessThanOrEqual(MAX_DIRECT_PENDING_BYTES); + expect(held * frame.length).toBeGreaterThan(MAX_DIRECT_PENDING_BYTES); }); it('reports the channel gone when a queued frame will not go out', async () => { diff --git a/lib/src/remote/direct/direct-peer.ts b/lib/src/remote/direct/direct-peer.ts index 420dff704..530ec7d0a 100644 --- a/lib/src/remote/direct/direct-peer.ts +++ b/lib/src/remote/direct/direct-peer.ts @@ -24,8 +24,8 @@ import { DIRECT_GATHER_TIMEOUT_MS, DIRECT_SETUP_TIMEOUT_MS, DirectFrameQueue, - MAX_DIRECT_OUTBOUND_BYTES, - MAX_DIRECT_OUTBOUND_FRAMES, + MAX_DIRECT_PENDING_BYTES, + MAX_DIRECT_PENDING_FRAMES, NOISE_MAX_MESSAGE_LENGTH, isDirectSdp, } from 'remote-lib-common'; @@ -143,7 +143,7 @@ export class DirectPeer { readonly #setTimer: RemoteTimer; #channel: DirectChannelLike | null = null; /** Ciphertext waiting on the channel to drain; see {@link send}. */ - readonly #outbound = new DirectFrameQueue(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_OUTBOUND_BYTES); + readonly #outbound = new DirectFrameQueue(MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_PENDING_BYTES); #cancelSetup: (() => void) | null = null; /** Cancels the grace a `disconnected` connection is given, if one is running. */ #cancelDisconnected: (() => void) | null = null; diff --git a/lib/src/remote/pocket-app/PocketWall.tsx b/lib/src/remote/pocket-app/PocketWall.tsx index 9b7b08f73..f1247c0d6 100644 --- a/lib/src/remote/pocket-app/PocketWall.tsx +++ b/lib/src/remote/pocket-app/PocketWall.tsx @@ -10,6 +10,7 @@ import { import type { DirectoryEntry } from 'remote-lib-common'; import { MobileTerminalUi, + paneMouseOverride, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode, } from '../../components/MobileTerminalUi'; @@ -87,13 +88,13 @@ export function PocketWall({ adapter, onError }: { const cursorTouchAvailable = activeMouseState?.mouseReporting !== undefined && activeMouseState.mouseReporting !== 'none'; - // Touch mode × each pane's own reporting decides its mouse override — configure - // every pane so one switched away from isn't left in a stale override. + // Every pane, not just the active one: `paneMouseOverride` is a function of + // touch mode and that pane's own reporting, so one switched away from would + // otherwise keep a stale override. useEffect(() => { for (const entry of entries) { const reporting = mouseStates.get(entry.surfaceId)?.mouseReporting ?? 'none'; - const override = touchMode === 'selection' && reporting !== 'none' ? 'permanent' : 'off'; - setMouseOverride(entry.surfaceId, override); + setMouseOverride(entry.surfaceId, paneMouseOverride(touchMode, reporting)); } }, [entries, mouseStates, touchMode]); diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index bc6fb00d3..42f4ab5fb 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -143,7 +143,7 @@ function PocketWallFrame(args: MobileTerminalUiProps) { if (!adapterRef.current) adapterRef.current = initPlatform('fake'); const [activePaneId, setActivePaneId] = useState(POCKET_WALL_PANE); const [keyboardMode, setKeyboardMode] = useState( - args.activeKeyboardMode ?? args.activeSection ?? args.defaultKeyboardMode ?? args.defaultSection ?? 'type', + args.activeKeyboardMode ?? args.defaultKeyboardMode ?? 'type', ); const sessionItems = useMobileWallSessionItems(POCKET_WALL_SESSIONS, activePaneId); @@ -164,7 +164,6 @@ function PocketWallFrame(args: MobileTerminalUiProps) { onKeyboardModeChange={(mode) => { setKeyboardMode(mode); args.onKeyboardModeChange?.(mode); - args.onSectionChange?.(mode); }} sessions={sessionItems} onSessionSelect={setActivePaneId} @@ -281,28 +280,28 @@ function GestureSnapshotFrame({ export const TypePane: Story = { args: { - defaultSection: 'type', + defaultKeyboardMode: 'type', }, render: (args) => , }; export const RecentTodoPane: Story = { args: { - defaultSection: 'recent', + defaultKeyboardMode: 'recent', }, render: (args) => , }; export const DraftTodoPane: Story = { args: { - defaultSection: 'draft', + defaultKeyboardMode: 'draft', }, render: (args) => , }; export const NonInteractivePhoneMockup: Story = { args: { - defaultSection: 'type', + defaultKeyboardMode: 'type', interactive: false, }, render: (args) => , @@ -310,7 +309,7 @@ export const NonInteractivePhoneMockup: Story = { export const CursorTouchAvailable: Story = { args: { - defaultSection: 'type', + defaultKeyboardMode: 'type', cursorTouchAvailable: true, }, render: (args) => , @@ -318,7 +317,7 @@ export const CursorTouchAvailable: Story = { export const PocketWall: Story = { args: { - defaultSection: 'type', + defaultKeyboardMode: 'type', }, parameters: { layout: 'fullscreen', diff --git a/relay/test/e2e-ceremony.test.mjs b/relay/test/e2e-ceremony.test.mjs index aa76549fe..70b6f5975 100644 --- a/relay/test/e2e-ceremony.test.mjs +++ b/relay/test/e2e-ceremony.test.mjs @@ -1,7 +1,7 @@ /** * Both end-to-end ceremonies driven through the real Relay - * (`docs/specs/remote-security-model.md` -> `## Future` -> **Scope: - * e2e-client-burrow**, stage 4): a phone scans a Burrow's QR, registers a passkey + * (`docs/specs/remote-security-model.md` -> Pairing and Connection): a phone + * scans a Burrow's QR, registers a passkey * off the setup token that QR carries, signs in, pairs over IK against the * invitation key, and then connects against the Burrow static it pinned. * diff --git a/remote-lib-common/src/security/direct-path.ts b/remote-lib-common/src/security/direct-path.ts index 1c514ac0c..e0bbbd82c 100644 --- a/remote-lib-common/src/security/direct-path.ts +++ b/remote-lib-common/src/security/direct-path.ts @@ -63,16 +63,22 @@ export const DIRECT_GATHER_TIMEOUT_MS = 3_000; export const MAX_DIRECT_SDP_LENGTH = 2000; /** - * How many bytes of held channel frames a receiver holds while awaiting the - * peer's switch. **The operative bound of the two**: what the window has to - * cover is one relay one-way hop of a terminal stream, and bytes are what the - * machine actually holds (`docs/specs/remote-security-model.md` -> "Burrow - * bounds"). + * How many bytes of channel frames either direction may hold — a receiver while + * it awaits the peer's switch, a sender while the channel drains. **The + * operative bound of the two**: what the window has to cover is one relay + * one-way hop of a terminal stream, and bytes are what the machine actually + * holds (`docs/specs/remote-security-model.md` -> "Burrow bounds"). + * + * **One pair of numbers for both directions**, since each covers a burst of the + * same terminal stream: sizing them apart would mean one had a reason the other + * did not. A sender that overruns them is one whose peer is not draining fast + * enough to stay in order, which is a dead session rather than a dropped + * frame — the same answer the receiver gives. */ export const MAX_DIRECT_PENDING_BYTES = 4 * 1024 * 1024; /** - * How many channel frames a receiver holds, whatever their size. Set above + * How many channel frames either direction holds, whatever their size. Set above * where the ~1 KiB frames a PTY produces can reach it, so it stops only a peer * sending thousands of tiny ones; {@link MAX_DIRECT_PENDING_BYTES} is what * bounds real traffic. The relationship is pinned by @@ -108,26 +114,13 @@ export const DIRECT_HANDOFF_TIMEOUT_MS = DIRECT_SETUP_TIMEOUT_MS; */ export const DIRECT_DISCONNECTED_GRACE_MS = 5_000; -/** - * How much a sender holds while the channel drains, in bytes and in frames. - * - * The same pair of numbers as the receiver's hold, for the same reasons: what a - * queue has to cover is a burst of a terminal stream, bytes are what the machine - * actually holds, and the frame count sits above where the ~1 KiB frames a PTY - * produces can reach it. A sender that overruns them is one whose peer is not - * draining fast enough to stay in order, which is a dead session rather than a - * dropped frame — the same answer the receiver gives. - */ -export const MAX_DIRECT_OUTBOUND_BYTES = MAX_DIRECT_PENDING_BYTES; -export const MAX_DIRECT_OUTBOUND_FRAMES = MAX_DIRECT_PENDING_FRAMES; - /** * How much the channel implementation may have buffered before a sender stops * handing it more and queues instead, and the level it must drain back to * before sending resumes. * * Two levels rather than one, so a busy stream is not woken on every frame. - * {@link MAX_DIRECT_OUTBOUND_BYTES} is what bounds the wait; these only decide + * {@link MAX_DIRECT_PENDING_BYTES} is what bounds the wait; these only decide * where the ciphertext sits while the association catches up. */ export const DIRECT_BUFFER_HIGH = 256 * 1024; diff --git a/remote-lib-common/test/direct-path.test.mjs b/remote-lib-common/test/direct-path.test.mjs index 9b1f73a78..65620cf75 100644 --- a/remote-lib-common/test/direct-path.test.mjs +++ b/remote-lib-common/test/direct-path.test.mjs @@ -22,8 +22,6 @@ import { DIRECT_SETUP_TIMEOUT_MS, DirectCutover, DirectFrameQueue, - MAX_DIRECT_OUTBOUND_BYTES, - MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_PENDING_BYTES, MAX_DIRECT_PENDING_FRAMES, MAX_DIRECT_SDP_LENGTH, @@ -139,17 +137,7 @@ test('the send water marks bracket the queue they feed', () => { assert.equal(DIRECT_BUFFER_HIGH, 256 * 1024); assert.equal(DIRECT_BUFFER_LOW, 64 * 1024); assert.ok(DIRECT_BUFFER_LOW < DIRECT_BUFFER_HIGH); - assert.ok(DIRECT_BUFFER_HIGH < MAX_DIRECT_OUTBOUND_BYTES); -}); - -/** - * A sender holds what a receiver holds. The window each covers is a burst of - * the same terminal stream, so sizing them apart would mean one of the two - * numbers had a reason the other did not. - */ -test('both directions are bounded the same way', () => { - assert.equal(MAX_DIRECT_OUTBOUND_BYTES, MAX_DIRECT_PENDING_BYTES); - assert.equal(MAX_DIRECT_OUTBOUND_FRAMES, MAX_DIRECT_PENDING_FRAMES); + assert.ok(DIRECT_BUFFER_HIGH < MAX_DIRECT_PENDING_BYTES); }); // --- The queue both directions use ------------------------------------------ diff --git a/scripts/csp-defaults.mjs b/scripts/csp-defaults.mjs index a6063a7f2..1d567571a 100644 --- a/scripts/csp-defaults.mjs +++ b/scripts/csp-defaults.mjs @@ -41,9 +41,10 @@ function isSupportedSource(source) { * * An override the runtime matcher cannot parse fails the build. Silently it * matches nothing — `originAllowedByConnectSrc` fails closed on a source it - * cannot read — so a trailing slash or a missing scheme produces a binary that - * builds green and then refuses to enroll against the very server it was built - * for, with an error naming the list it was already given. + * cannot read — so a trailing slash, a path, a bare host, a scheme outside + * http/https/ws/wss, or a port outside 1–65535 produces a binary that builds + * green and then refuses to enroll against the very server it was built for, + * with an error naming the list it was already given. */ export function resolveRemoteConnectSrc(env = process.env, label = 'build') { const override = env.DORMOUSE_REMOTE_CONNECT_SRC?.trim(); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 0958e0da7..54ddcd7f9 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,9 +15,9 @@ "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3850, "docs/specs/pocket-app.md": 4900, - "docs/specs/relay.md": 10200, + "docs/specs/relay.md": 10100, "docs/specs/remote-api.md": 4700, - "docs/specs/remote-security-model.md": 4800, + "docs/specs/remote-security-model.md": 4750, "docs/specs/security-audit.md": 1900, "docs/specs/security-ci.md": 2700, "docs/specs/security-hosted.md": 450, diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index 283bb88f9..533a7cb8f 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { MobileTerminalUi, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode } from "dormouse-lib/components/MobileTerminalUi"; +import { MobileTerminalUi, paneMouseOverride, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode } from "dormouse-lib/components/MobileTerminalUi"; import { MobileWall, useMobileWallSessionItems, type MobileWallSession } from "dormouse-lib/components/MobileWall"; import { getMouseSelectionSnapshot, @@ -228,16 +228,13 @@ export function PocketTerminalExperience({ }; }, [getPocketTouchMode, handleNotifyPocket, handleOpenGithub, subscribeToPocketTouchMode, tryAutoStart]); - // Touch mode is a single global UI state, so each pane's mouse override is a - // pure function of (touch mode) × (that pane's own reporting) — not of which - // pane happens to be active. Configuring every pane prevents a pane the user - // switched away from being left stuck in a stale override (e.g. a - // mouse-reporting pane left "permanent" after leaving Select mode). + // Every pane, not just the active one: `paneMouseOverride` is a function of + // touch mode and that pane's own reporting, so a pane the user switched away + // from would otherwise be left stuck in a stale override. useEffect(() => { for (const session of POCKET_SESSIONS) { const reporting = mouseStates.get(session.id)?.mouseReporting ?? "none"; - const override = touchMode === "selection" && reporting !== "none" ? "permanent" : "off"; - setMouseOverride(session.id, override); + setMouseOverride(session.id, paneMouseOverride(touchMode, reporting)); } }, [mouseStates, touchMode]);