diff --git a/.gitignore b/.gitignore index 1ed41e5f..2dabb1f2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ dist/ dor/src/generated-version.ts lib/dist/ +lib/dist-pocket/ *.tsbuildinfo # Vite cache diff --git a/AGENTS.md b/AGENTS.md index 4e4206af..33f66bc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,8 @@ pnpm build # build lib, vscode extension, and website - **standalone/** — Tauri desktop app with Node.js sidecar for native PTY via node-pty - **vscode-ext/** — VS Code extension wrapping the lib in a webview with native PTY backend - **website/** — Marketing website bundling part of the lib as an interactive demo +- **server/** — Hono coordinating server for remote control: selfhost accounts + passkey auth, WebSocket relay between Pocket clients and Hosts, serves the built Pocket app +- **server-lib-common/** — Runtime-agnostic security primitives + wire contract shared by `server`, the Host module in `lib`, and the Pocket app ## Project Structure @@ -25,6 +27,9 @@ pnpm build # build lib, vscode extension, and website - `standalone/src-tauri/` — Rust backend that bridges webview ↔ Node.js sidecar - `vscode-ext/` — VS Code extension (esbuild, node-pty via forked child process) - `website/` — Marketing site (Vite, uses FakePtyAdapter for demo) +- `server/` — Selfhost server (Hono; accounts in local JSON files, no database) +- `server-lib-common/` — Shared security + wire contract (bare ES2022 — no DOM or Node types) +- `lib/src/remote/` — Remote control: `host/` (laptop side), `client/` (phone-side protocol + `RemotePtyAdapter`), `pocket-app/` (Pocket shell), `ws.ts` (shared socket surface) ## Specs @@ -42,6 +47,10 @@ The primary job of a spec is to be an accurate reference for the current state o - **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane layout, interactive `tut` TUI runner with three sections (keyboard navigation, alerts/TODOs, copy/paste), per-item detection wired to `WallEvent` / activity store / mouse-selection store, single-key `dormouse-tut-v3` localStorage scheme, theme picker, and FakePtyAdapter extensions (`sendOutput`, `pumpActivity`, `setInputHandler`). Read this when touching: `website/src/pages/PlaygroundDesktop.tsx`, `website/src/pages/PocketPlayground.tsx`, `website/src/components/PocketTerminalExperience.tsx`, `website/src/lib/playground-routing.ts`, the `website/src/pages/Playground.tsx` and `website/src/pages/Pocket.tsx` redirect dispatchers, `website/src/lib/tut-runner.ts`, `website/src/lib/tut-detector.ts`, `website/src/lib/tutorial-state.ts`, `website/src/lib/tut-items.ts`, `website/src/lib/tutorial-shell.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), the `WallEvent` union, or the `onApiReady`/`onEvent`/`initialPaneIds` props on Wall. - **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy, theme data model, conversion pipeline, bundled themes, localStorage store, shared ThemePicker component, standalone AppBar picker, runtime OpenVSX installer. Read this when touching: `lib/src/lib/themes/`, `lib/src/components/ThemePicker.tsx`, `lib/src/theme.css`, `lib/scripts/bundle-themes.mjs`, `standalone/src/AppBar.tsx` (theme picker), `standalone/src/main.tsx` (theme restore), or `website/src/components/SiteHeader.tsx` (themeAware mode). - **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned text selection, copy (Raw / Rewrapped), bracketed paste, smart URL/path extension, mouse-reporting override UI (icon + banner), and the state matrix for which layer owns mouse events. Read this when touching: `lib/src/lib/mouse-selection.ts`, `lib/src/lib/mouse-mode-observer.ts`, `lib/src/lib/clipboard.ts`, `lib/src/lib/rewrap.ts`, `lib/src/lib/selection-text.ts`, `lib/src/lib/smart-token.ts`, `lib/src/components/SelectionOverlay.tsx`, `lib/src/components/SelectionPopup.tsx`, the mouse icon / override banner / Cmd+C-V handling in `lib/src/components/Wall.tsx`, or the parser hooks + mouse listeners in `lib/src/lib/terminal-registry.ts`. +- **`docs/specs/remote-security-model.md`** — The trust model for remote control, built on two independent primitives: passkeys prove fresh user presence (user credentials, never device identities — they sync), and per-browser non-extractable device keys prove long-lived Client identity. The Host's local ACL authorizes the *pair* of a passkey credential and a device key; the pairing ceremony (local approval on the Host) is the only path into it, and the Host — never the Server — makes the final access decision. Covers the connection requirements (all layers must agree), storage durability (iOS PWA guidance), and device-key-loss recovery. Read this first for anything remote; the other three remote specs build on it. Read this when touching: `server-lib-common/src/security/` (deviceKey, challenge, passkey, acl, pairing, connection), `server/src/handshake.ts`, `lib/src/remote/host/{acl,pairing-approval,remote-host}.ts`, or `lib/src/remote/client/{device-key,webauthn}.ts`. +- **`docs/specs/remote-api.md`** — The protocol a Client speaks after `authorizeConnection`: one protocol at two consumption depths (phone = directory picker + one attached surface; VR = full wall, future). Defines the v1 scope (terminal-only decisions live in server.md), the directory (snapshot-only, `ringing`/`hasTODO`), attach-is-the-resize (the SIGWINCH repaint is the screen transfer — no snapshot format), last-attach-wins size authority with the "tethering to \" display, and the staged future work (in-flight replay, semantic command scrollback, thumbnails, grants, VR wall, WebRTC). Read this when touching: `server-lib-common/src/remote/wire.ts` (the fixed wire contract), `lib/src/remote/host/remote-api.ts`, `lib/src/remote/client/{pocket-client,remote-adapter}.ts`, or `server/test/harness/fake-host.mjs`. +- **`docs/specs/server.md`** — The selfhost coordinating server: env config (`DORMOUSE_SETUP_PASSWORD`, `DORMOUSE_ORIGIN`, …), two-JSON-file state (no database), "WebAuthn without a WebAuthn library" (registration via `response.getPublicKey()`, assertions via the same `verifyPasskeyAssertion` the Host uses), the HTTP API, the relay frame flow (pairing and connect sequence diagrams — one host challenge feeds both the passkey assertion and the device signature, so connecting costs one biometric prompt), the Host and Pocket side responsibilities, and "Running the POC" instructions. Read this when touching: `server/src/` (app, relay, handshake, state), `lib/src/remote/host/enrollment.ts`, or the `dev:pocket-server` flow. +- **`docs/specs/pocket-app.md`** — Pocket app architecture: the remote session is a `PlatformAdapter` (`RemotePtyAdapter` maps the PTY core onto remote-api one-to-one), so Pocket is auth screens + `MobileTerminalUi`/`MobileWall` — the same composition the website playground runs on `FakePtyAdapter`. Covers the `lib/src/remote/` module layout and the same-origin deployment rule (WebAuthn origin binding + Chrome PNA: selfhost server serves the bundle now; CloudFlare static + routed `/api`+`/ws` for SaaS later). Read this when touching: `lib/src/remote/client/remote-adapter.ts`, `lib/src/remote/pocket-app/`, `lib/vite.pocket.config.ts`, or the Pocket static serving in `server/src/app.ts`. When updating code covered by a spec, update the spec to match. When the two specs overlap (e.g. pane header elements appear in both), layout.md documents placement and sizing while alert.md documents behavior and visual states. diff --git a/docs/specs/pocket-app.md b/docs/specs/pocket-app.md new file mode 100644 index 00000000..2b2483fe --- /dev/null +++ b/docs/specs/pocket-app.md @@ -0,0 +1,96 @@ +# Pocket App Architecture + +How the phone client (Dormouse Pocket) is structured and deployed. Companion +to [remote-api.md](./remote-api.md) (the protocol) and +[server.md](./server.md) (the selfhost server). + +# The seam: the remote session is a platform adapter + +`lib` renders every Dormouse surface through a `PlatformAdapter` +(`lib/src/lib/platform/types.ts`). The adapter's PTY core — `writePty`, +`resizePty`, `onPtyData`, `onPtyExit`, plus the `requestInit`/`onPtyList`/ +`onPtyReplay` resume path built for VS Code webview reloads — maps one-to-one +onto the remote-api v1 terminal protocol: + +| PlatformAdapter | remote-api | +| ------------------------ | --------------------------------------- | +| `onPtyList` | `directory.snapshot` | +| attach semantics | `surface.attach` (attach-is-the-resize) | +| `onPtyData` | `terminal.data` | +| `writePty` | `terminal.write` | +| `resizePty` | `terminal.resize` | +| `onPtyExit` | `terminal.closed` | + +So the Pocket app is NOT a bespoke terminal UI. It is: + +> auth screens + `MobileTerminalUi`/`MobileWall` + **`RemotePtyAdapter`** + +— the exact composition the website playground already proves out with +`FakePtyAdapter` (`website/src/components/PocketTerminalExperience.tsx`). +One mobile terminal experience in `lib`, three consumers: the website +playground (fake adapter), the real Pocket (remote adapter), and whatever +comes later. Everything not in the PTY core no-ops or is absent — the +interface is designed for capability degradation. +Pocket hides `MobileWall`'s local Kill affordance: remote panes are +Host-owned, and v1 grants no phone-side kill/layout authority. Closing a local +xterm view without a Host-side close would leave the Host attachment live and +the phone view inconsistent. + +Adapter-specific extras (the same pattern as `FakePtyAdapter`'s scenario +controls): the concrete `RemotePtyAdapter` exposes `setActivePane(id)` — the +v1 protocol allows one attachment per session, so pane switching is +detach → attach, and the attach repaint (resize) redraws the screen. Badges +for non-attached panes come from `directory.watch` without attaching. + +Pocket's local "paired" host marker is optimistic cache, not authority. When a +connect denial reports an ACL miss (`passkey-not-paired`, +`device-not-paired`, or `pairing-mismatch`), Pocket clears that marker and +shows Pair again so expected Host ACL resets, revocations, or browser +device-key loss recover through the normal pairing ceremony. + +# Module layout + +``` +lib/src/remote/ + client/ the phone side + pocket-client.ts UI-free protocol client (auth, pair, connect, msg) + device-key.ts non-extractable device key in IndexedDB + webauthn.ts navigator.credentials wrappers + remote-adapter.ts RemotePtyAdapter (PlatformAdapter over pocket-client) + host/ the laptop side (enrollment, approval modal, ACL, bridge) + pocket-app/ the app shell: auth views + the mobile wall composition +``` + +The server (`server/`) stays the only dynamic code: accounts, relay, and +static serving of the built Pocket bundle. + +# Deployment: same-origin, always + +WebAuthn binds passkeys to the serving origin, and Chrome's Private Network +Access rules are progressively blocking public-site → private-network fetches. +Both point the same way: **the Pocket app is always served same-origin with +its API.** One lib-owned bundle, two deployments: + +* **Selfhost (now):** the Node server serves the bundle (`lib/dist-pocket`). + Selfhost auth never depends on dormouse.dev existing. +* **SaaS (later):** CloudFlare serves the static site and routes `/api/*` and + `/ws/*` to the dynamic backend (CloudFlare proxies WebSockets). The same + bundle mounts at the site origin; rpId is the site's. The dynamic surface + is two path prefixes — everything else stays static. + +The website keeps its playground and marketing pages fully static in both +worlds and shares all terminal UI through `lib`; it never duplicates Pocket +code. + +# Phases + +1. **RemotePtyAdapter + real Pocket UI** — move the protocol modules to + `remote/client/`, add the adapter, rebuild the Pocket entry as + auth views + `MobileTerminalUi`/`MobileWall`, delete the bespoke terminal + UI from the POC. Protocol and server untouched. The fake host harness + grows a synthetic directory + echo terminal so the adapter is testable + end-to-end without a real host. +2. **Dedupe the composition** — extract the thin wiring shared by the + website's `PocketTerminalExperience` and the Pocket shell so the two + cannot drift. +3. **CloudFlare routing** — deferred until SaaS; nothing in 1–2 needs rework. diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md new file mode 100644 index 00000000..7fc62434 --- /dev/null +++ b/docs/specs/remote-api.md @@ -0,0 +1,473 @@ +# Remote Surface API + +> See `docs/specs/glossary.md` for the canonical Pane / Surface / Session model; this spec uses that vocabulary and adds only remote-specific terms (Viewer, and the wire-level `DirectoryEntry` projection of a pane). + +This spec sketches the API a Client uses to view and control a Host's surfaces +after a session has been authorized by the +[remote security model](./remote-security-model.md). Nothing here weakens that +model: every message below travels inside one authorized session, and the Host +can terminate the session (and every stream in it) at any time. + +Two consumers, one protocol: + +* **Phone (Dormouse Pocket)** — the user sees a directory of the Host's active + panes (terminal and browser), picks one, and views/controls just that one. +* **VR headset** — the client runs the entire Dormouse UI remotely: the full + wall layout, every surface live at once, each rendered as its own panel in + space. + +The phone is not a different API — it is a shallow consumer of the same one. + +| Capability | Phone | VR | +| --------------------- | ---------------- | ---------------- | +| `directory.watch` | yes (the picker) | optional | +| `surface.attach` | one at a time | many at once | +| `wall.watch` (layout) | no | yes | +| Layout mutations | no | yes | +| Input | to attached pane | to any surface | + +Design principle: **replicate state, don't stream a desktop.** Terminals are +sent as PTY data and rendered client-side (the Client already ships the +terminal renderer); browser surfaces are sent as per-surface screencasts. This +is what makes VR viable — each surface arrives as its own independently +placeable, independently sized stream — and it makes the phone cheap: one +attached surface costs one stream. + +--- + +# v1 scope + +v1 is the smallest protocol that lets a phone **sign in, pick a pane, see it +live, and type into it** — the phone column of the table above, and only it: + +* Hello (version + capabilities) +* `directory.watch`, snapshot-only (no deltas, no thumbnails) +* `surface.attach` / `surface.detach`, one attachment per session +* Terminal: attach-is-the-resize, live data + semantic events, + `terminal.write`/`terminal.resize`, last-attach-wins tethering +* Browser: screencast frames at Host-chosen fixed quality, pointer + key input +* One implicit grant: every paired session has full input (selfhost is + single-user), no layout operations + +Everything else is future work, staged in likely order of arrival: + +1. **In-flight command replay** — first follow-up after v1 (see Terminal) +2. **Semantic command scrollback** — v2 (see Terminal) +3. **Directory thumbnails** +4. **Graded grants + layout mutations** (observe-only viewers, remote + split/kill with Host-side confirm) +5. **The wall: VR** (`wall.watch`, multi-attach, wall lease) +6. **WebRTC transport + app-layer encryption** +7. **Audio** + +Each future item is additive — a new method, event, or optional field — so +nothing in the v1 protocol changes shape when it lands. + +--- + +# Terminology + +`docs/specs/glossary.md` is canonical for **Pane** and **Surface**; the wire +shapes reuse the existing surface model (`dor/src/protocol.ts`, +`dor/src/commands/types.ts`). Remote-specific usage: + +* **Surface** — identified on the wire by `surfaceId`; carries `ref`, + `paneRef`, `title`, `focused`, `indexInPane`, `selectedInPane`. +* **Pane** — the phone's picker lists panes; attaching to a pane means + attaching to its selected surface. +* **Wall** — the full layout tree (workspaces → windows → panes) plus geometry. + Only VR consumes this. +* **Viewer** — one connected Client session. Multiple viewers may coexist; the + Host UI shows who is connected. + +--- + +# Transport + +## Channels + +v1 transport is **WebSocket relay only**: one WebSocket per session, relayed +through the Server, bound to the `sessionId` issued by `authorizeConnection`. +Control messages and media frames share the socket: + +* **Control** — requests, responses, and event subscriptions. Terminal data + rides here too (it is small and ordering matters). +* **Media** — browser screencast frames. A dropped frame must be skipped, not + queued behind: the Host keeps at most the newest frame per attachment and + sends it only when the socket drains, so a slow link degrades to a lower + frame rate instead of growing a buffer. + +Future upgrades, none of which change the API surface: WebRTC rendezvous for +latency (the Server signals but, per the security model, is never trusted with +authorization — pin the DTLS fingerprint inside the device-key-signed connect +payload), and app-layer encryption so the relaying Server sees only +ciphertext. + +## Server deployment modes + +The Server always ships in two modes; the remote API and the security model +are identical in both — the modes differ only in how accounts come to exist. + +* **Selfhost** — an env-var sets a setup password; presenting it allows the + system's only user to create their account and register the first passkey. + Sign-in from then on is passkey-only. No database: accounts, passkey + credentials, and revocation state live in local files. +* **SaaS multitenant** — anyone can create an account with email + passkey. + +v1 ships selfhost only. Selfhost is not a stepping stone: it remains a +supported mode alongside SaaS permanently. + +## Envelope + +Same shape as the dor control protocol — requests correlated by `requestId`, +events correlated by `subId`: + +```ts +interface RemoteRequest { requestId: string; method: string; params?: object } +interface RemoteResponse { requestId: string; ok: boolean; result?: object; error?: string } +interface RemoteEvent { subId: string; event: string; data: object } +``` + +## Hello + +First exchange on the control channel; establishes version and capabilities so +the protocol can grow without breaking older Pockets. + +```ts +// client → host +interface ClientHello { + protocolVersion: 1; + viewer: 'phone' | 'vr' | 'desktop'; + /** What the client can render / wants to do. v1 phones send + * { screencast: ['jpeg'], input: true, wall: false }. */ + capabilities: { + screencast: ReadonlyArray<'jpeg' | 'webp'>; + input: boolean; + wall: boolean; + }; +} + +// host → client +interface HostHello { + protocolVersion: 1; + hostId: string; + /** v1: always { input: true, layout: false } — selfhost is single-user, so + * every paired session is the owner. Graded grants are future work. */ + grants: { input: boolean; layout: boolean }; +} +``` + +--- + +# Directory (the phone's picker) + +`directory.watch` subscribes to a live, lightweight listing of every pane — +enough to render the picker and know which pane wants attention, without +attaching to anything. + +```ts +interface DirectoryEntry { + paneRef: string; + surfaceId: string; // the selected surface in the pane + type: 'terminal' | 'agent-browser'; // iframe surfaces are not listed (unsupported) + title: string; // derived title, same one the wall header shows + focused: boolean; // focused on the host + // Terminal-only, from the existing semantic-event model (terminal-state.ts): + activity?: 'unknown' | 'prompt' | 'editing' | 'running' | 'finished'; + exitCode?: number; + alive: boolean; // the PTY process is still alive (see below) + cwd?: string; + // Browser-only: + url?: string; + /** The pane's alert is ringing on the host (alert-manager). */ + ringing: boolean; + /** The pane has an outstanding TODO waiting for the user. */ + hasTODO: boolean; +} + +type DirectoryEvent = + | { event: 'directory.snapshot'; data: { entries: DirectoryEntry[] } }; +``` + +Snapshot-only, deliberately: a directory is dozens of entries at most, so on +any change the Host coalesces and resends the whole thing. Delta events are a +future optimization there is no current reason to pay for. + +Thumbnails are future work; in v1 the picker renders from titles, activity, +and the `ringing`/`hasTODO` badges. + +`alive` reflects real PTY-process liveness: it is `true` while the pane's +process is running and `false` once that process has exited. Dormouse keeps an +exited pane open in the Host registry (rendering "[Process exited with code N]") +until the user closes it, so such a surface is still *listed* but reports +`alive: false` — the phone's picker uses this to stop offering a dead pane as +attachable (attaching would transfer nothing). + +This is distinct from `exitCode`, which is the last finished command's +shell-integration semantic status, not PTY lifetime. A pane can report +`alive: true` with an `exitCode` set (a command finished but the shell lives on), +and a pane reporting `alive: false` may carry no `exitCode` at all. + +--- + +# Attaching to a surface + +`surface.attach { surfaceId, ... }` opens the surface's stream (terminals add +their dimensions — see below); `surface.detach { surfaceId }` closes it. v1 +allows one attachment per session (the phone's model); lifting that cap for VR +is future work. Attachment is view-state only with one deliberate exception: +attaching to a terminal takes size authority. + +## Terminal surfaces + +Replicated, not screencast: the client renders its own xterm from the same +data the host UI consumes. + +### Attach is the resize (v1) + +The remote is virtually always a different size than the Host, and a resize is +exactly what makes a terminal paint itself — so attach carries the client's +dimensions and there is no snapshot transfer: + +1. Client attaches with `{ cols, rows }`. +2. Host resizes the PTY (last-attach-wins; see Size authority). `SIGWINCH` + makes full-screen TUIs repaint completely and shells redraw their prompt + line, filling the client's screen from the live stream alone. +3. If the requested size happens to equal the current size, the Host forces + the repaint anyway: `SIGWINCH` alone first (most TUIs refetch size and + repaint), then a quick rows±1 bounce if no output follows. + +Normal-screen history does not regenerate on resize; it is deliberately absent +from v1 (see Future work below). + +```ts +// client → host +{ method: 'surface.attach', params: { surfaceId: string, cols: number, rows: number } } + +// host → client, the attach result +interface TerminalAttachResult { + cols: number; rows: number; // the size the PTY now has + // Reserved: `inflight` (in-flight replay) and `blocks` (semantic + // scrollback) land here additively — see Future work. +} + +// then a stream of: +type TerminalEvent = + | { event: 'terminal.data'; data: { bytes: string /* base64 */ } } + | { event: 'terminal.resize'; data: { cols: number; rows: number } } // another display took authority + | { event: 'terminal.semantic'; data: TerminalSemanticEvent } // cwd/activity/title, as today + | { event: 'terminal.closed'; data: { exitCode?: number } }; + +// client → host (requires the input grant) +type TerminalInput = + | { method: 'terminal.write'; params: { surfaceId: string; bytes: string } } + | { method: 'terminal.resize'; params: { surfaceId: string; cols: number; rows: number } }; +``` + +`terminal.write` and `terminal.resize` are valid only for the session's current +attachment. A stale request for a detached surface, or a request for a +background surface listed in the directory but not attached by this session, is +rejected and must not reach the PTY or change its size. +The attachment is bound to the terminal selected at `surface.attach` time: +after a Host-side pane swap moves that terminal to another pane, the remote +stream, `terminal.write`, and `terminal.resize` keep targeting the same PTY +rather than re-resolving the old `surfaceId` through the current registry slot. +When that PTY exits, the Host emits `terminal.closed` and then drops the +attachment, so a later `terminal.write`/`terminal.resize` for the surface is +rejected ("surface is not attached") instead of acting on the disposed terminal. + +### Size authority: last-attach-wins + +A terminal has one size, and the most recent size writer owns it: attaching +with dimensions and `terminal.resize` both take authority, and the Host user +interacting with the pane locally reclaims it. Every other display of that +pane — the Host's wall pane, other attached viewers — greys out and shows only +**"tethering to \"** (the ACL record's label, e.g. `iPhone Safari`) +instead of fighting over `SIGWINCH`. Interacting with a tethered pane is how a +display takes it back. + +### Future work: in-flight replay, then semantic scrollback + +**In-flight replay (first follow-up after v1).** The most common reason to +open a pane on the phone is a command that is still running — "is my build +done?" — and a resize repaint shows nothing for a command quietly writing a +log. (Dormouse's primary workload, agent TUIs, do repaint on resize — which is +what makes this deferrable at all.) The Host retains the output of the current +command from its `commandStart` boundary (OSC 133/633, with the existing +keystroke-heuristic fallback), tail-capped to a fixed byte budget, dropped at +the next prompt; attach replays it via the reserved `inflight` field: + +```ts +inflight?: { + commandLine: string | null; + startedAt: number; + bytes: string; // base64, tail-capped + truncated: boolean; +} +``` + +**Semantic command scrollback (v2).** History arrives as structure the Host +already extracts, not as emulator state: OSC 133/633 segmentation gives +per-command boundaries, alt-screen spans are already tracked and stripped, and +the in-flight buffer is the same capture mechanism retained for K commands +instead of one: + +```ts +interface CommandBlock { + commandLine: string | null; + cwd: string | null; + exitCode: number | null; // null while still running + startedAt: number; + finishedAt: number | null; + bytes: string; // output, tail-capped, alt-screen spans stripped + truncated: boolean; +} +``` + +Attach then also delivers recent blocks, and the client renders them at its +own width — collapsible cards on the phone, panels in VR — rather than +replaying a fixed-width terminal. Additive by construction: a `blocks` field +on `TerminalAttachResult` plus a `terminal.block` event. + +## Browser surfaces (`agent-browser`) + +The existing screencast path, made remote: + +```ts +type BrowserEvent = + | { event: 'browser.frame'; data: { format: 'jpeg' | 'webp'; width: number; height: number; bytes: string } } + | { event: 'browser.tab'; data: AgentBrowserTab } // title/url/active changes + | { event: 'browser.closed'; data: {} }; + +// client → host (requires the input grant); coordinates in frame space, +// the host maps them through the screencast scale into CDP input. +type BrowserInput = + | { method: 'browser.pointer'; params: { surfaceId: string; kind: 'tap' | 'down' | 'move' | 'up' | 'scroll'; x: number; y: number; dx?: number; dy?: number } } + | { method: 'browser.key'; params: { surfaceId: string; text?: string; key?: string; modifiers?: number } }; +``` + +In v1 the Host picks fixed, phone-appropriate screencast parameters (JPEG, +capped dimension and frame rate). Per-attachment quality negotiation +(`browser.quality`) and remote navigation (`browser.navigate`) are future +work — a phone can drive the page's own UI in the meantime. + +## Iframe surfaces + +Not supported. Iframe surfaces are omitted from the directory and refuse +attachment; wall snapshots still list them (the layout must be truthful) and +VR renders an inert placeholder. Nothing else in the protocol assumes they +exist, so support can be added cleanly later — it is not on the critical path. + +--- + +# Input authority + +v1 is deliberately flat: selfhost is single-user, so every paired session is +the owner and gets full input (`grants: { input: true, layout: false }`). No +session gets layout operations. The Host UI still shows connected viewers and +can kill any session live; in-flight input is dropped the moment it does. + +Future work — graded grants, layered so "the Host is the final authority" +holds at every step: + +1. **Pairing-time**: the ACL record's approval carries a standing grant + (observe-only vs interactive) chosen in the Host's approval UI. +2. **Session-time**: `HostHello.grants` reports what the session actually got. +3. **Layout**: destructive operations (`surface.kill`) require the `layout` + grant and are confirmed on the Host the same way local kills are + (KillConfirm), unless the Host user opts a session into unattended control. + +--- + +# The wall (VR) — future work + +Nothing in this section is v1. VR does not stream the desktop; it *is* the +desktop: the headset runs the same web UI (`lib`) against remote data sources +instead of local ones. + +## Layout replication + +`wall.watch` subscribes to the layout tree plus geometry: + +```ts +interface WallSnapshot { + workspaces: Array<{ + ref: string; name: string; + windows: Array<{ + ref: string; + panes: Array<{ + paneRef: string; + /** Normalized rect within the window, for initial spatial placement. */ + rect: { x: number; y: number; w: number; h: number }; + surfaces: Surface[]; // the existing Surface shape + }>; + }>; + }>; + focusedSurfaceId: string | null; +} + +type WallEvent = + | { event: 'wall.snapshot'; data: WallSnapshot } + | { event: 'wall.changed'; data: WallSnapshot }; // coalesced; layouts are small +``` + +The rects seed VR placement; after that the headset owns spatial arrangement +locally (a VR user re-hanging panels in space is presentation, not layout, and +does not round-trip to the Host). + +## Layout mutations + +The existing `surface.*` control vocabulary, carried over the session +(requires the `layout` grant): + +``` +surface.split surface.ensure surface.send +surface.kill surface.read surface.focus +``` + +These are the same methods the dor CLI speaks today; the remote API reuses +their request/response shapes so the Host dispatches both through one handler. + +## Wall lease + +A VR session may request `wall.lease`, declaring itself the primary display. +Sizing needs no lease — last-attach-wins already hands VR the panes it +displays — so the lease is presentational: the Host UI tethers wholesale +("tethering to \") instead of pane by pane, and panes created on the +Host while the lease is held open tethered to the leaseholder. One lease at a +time; the Host user can always reclaim it locally. Phones never need it. + +--- + +# Multi-viewer semantics + +Concurrent sessions need no special machinery in v1: attach state is +per-session, streams fan out per attachment, and terminal size is +last-attach-wins with the tether display resolving contention. Every viewer is +visible on the Host (label from the ACL record, e.g. `iPhone Safari`), with +per-viewer disconnect. Interleaved typing from two granted sessions is no +worse than two keyboards on one machine; the wall lease (future) is the only +exclusive resource. + +--- + +# QoS notes (phone-first) + +* Terminal output is already coalesced host-side; the remote stream reuses + that batching and adds a per-session byte budget with tail-drop + resync + (an implicit re-attach: repaint via resize) rather than unbounded buffering + on a bad link. +* Screencast frames are droppable by design; only the newest frame matters. +* The directory is metadata-only. +* Detach on backgrounding: when the phone app/PWA loses visibility, the client + detaches streams but keeps the control channel; reattach is one message. + +--- + +# Open questions + +* **Browser media**: screencast frames over the WebSocket are v1; when WebRTC + arrives, a video track would be smoother for VR. Possibly phone=frames, + VR=track, negotiated in the hello. +* **Audio**: browser surfaces can produce audio; VR will want it (spatial, + per-panel). Out of scope for v1. diff --git a/docs/specs/server.md b/docs/specs/server.md new file mode 100644 index 00000000..4e441f56 --- /dev/null +++ b/docs/specs/server.md @@ -0,0 +1,299 @@ +# Server (selfhost POC) + +The coordinating Server from the +[remote security model](./remote-security-model.md), in its selfhost mode, cut +down to the smallest thing that completes this loop: + +> Run the server with a setup password. Visit it, present the password, create +> a passkey. Pair your phone with your laptop's Dormouse Terminal. Move a +> running terminal session from the laptop to the phone. + +One Node process (Hono, as the `server` package already is). No database. No +browser-surface support — **terminal-only**. The heavy lifting is already +done: every security primitive lives in `server-lib-common`, and the terminal +UI lives in `lib`/`standalone`. + +## POC guardrails + +* One account (`accountId: "owner"`), created once with the setup password. +* Terminal surfaces only; the remote-api v1 subset minus browser surfaces. +* Revocation is editing a JSON file by hand; no management UI. +* A dropped WebSocket is handled by reloading the page / reconnecting the + host. No resume protocol. +* Everything transient (challenges, sessions, relay state) is in memory; a + server restart just means everyone reconnects. + +--- + +# Configuration + +| Env var | Meaning | +| ------------------------- | ---------------------------------------------------------- | +| `DORMOUSE_SETUP_PASSWORD` | Required. Gates account creation and host enrollment. | +| `DORMOUSE_ORIGIN` | External origin, e.g. `https://dormouse.tailnet.ts.net`. Source of the WebAuthn `rpId`/`origin` and the Host's `ConnectionPolicy`. Defaults to `http://localhost:` for dev. | +| `DORMOUSE_STATE_DIR` | Where the JSON state files live. Default `./data`. | +| `PORT` | Default 3000. | + +WebAuthn requires a secure context: `localhost` works for development; for a +real phone, put the server behind TLS (`tailscale serve` is the intended +selfhost path, any reverse proxy works). The server itself always speaks +plain HTTP. +`DORMOUSE_ORIGIN` is parsed once and normalized with `URL.origin`; WebAuthn +clientData checks, passkey assertion verification, and the Host enrollment +policy all use that normalized origin. + +## Host webview CSP (self-host builds) + +The standalone Host is a Tauri app, and its webview `connect-src` bounds where +the Host can reach a relay server. The shipped binary is scoped to the SaaS +origin only (`https://*.dormouse.sh wss://*.dormouse.sh`, plus localhost for +dev), so a compromised webview cannot exfiltrate to an arbitrary host. A +self-host server on a different origin is therefore reached only by a custom +build: set `DORMOUSE_REMOTE_CONNECT_SRC` when building +(`pnpm --filter dormouse-standalone tauri build`) to the CSP sources for your +server, e.g. `https://dormouse.example.com wss://dormouse.example.com` (or a +tailnet wildcard `https://*.ts.net wss://*.ts.net`). It replaces the default +SaaS sources; localhost and the rest of the policy are untouched. The default +is deliberately not internet-wide — widening it is an explicit, per-build +opt-in. + +# State files + +``` +$DORMOUSE_STATE_DIR/ + account.json { accountId: "owner", + passkeys: [{ credentialId, publicKey /* SPKI b64u */, + label, createdAt }] } + hosts.json [{ hostId, hostToken, label, enrolledAt }] +``` + +That is the entire persistent state. The Host's ACL is not here — it lives on +the Host (platform `saveState`), which is the whole point of the security +model. + +--- + +# WebAuthn without a WebAuthn library + +Two facts keep the server dependency-free: + +* **Registration**: browsers expose the new credential's public key directly — + `response.getPublicKey()` returns SPKI DER. The Pocket page sends + `{ credentialId, publicKey, clientDataJSON }`; the server checks + `clientDataJSON` (`type === 'webauthn.create'`, its challenge, its origin) + and stores the key. No CBOR, no attestation parsing (we request + `attestation: 'none'` anyway). +* **Assertions**: `verifyPasskeyAssertion` in `server-lib-common` already + verifies full assertions against an SPKI key — the same function the Host + uses, so Server and Host literally cannot disagree on what a valid assertion + is. + +Server-issued challenges (registration, sign-in) reuse `HostChallengeIssuer` +— it is a generic single-use/TTL challenge store despite the name. +Before a challenge is consumed, the server canonicalizes the browser's +`clientDataJSON.challenge` by decoded base64url bytes, so padded browser +serializations redeem the issued challenge without weakening single-use replay +protection. + +This also makes the server fully testable without a browser: the +`SimAuthenticator` harness in `server-lib-common` produces real assertions, +so `node --test` can drive setup → pairing → connect end to end via +`app.request()` and a pair of in-process WebSockets. + +--- + +# HTTP API + +| Route | Auth | Does | +| -------------------------------- | -------------- | ------------------------------------------------- | +| `GET /*` | — | Serves the Pocket web app (static build of `lib`'s pocket entry) | +| `POST /api/setup/begin` | setup password | `{ challenge }` for registration; rejects if the account already has a passkey (add more by re-presenting the password) | +| `POST /api/setup/finish` | setup password | `{ credentialId, publicKey, clientDataJSON }` → creates/updates `account.json` | +| `POST /api/signin/begin` | — | `{ challenge }` for sign-in | +| `POST /api/signin/finish` | — | full assertion → verified → `{ sessionToken }` (random, in-memory, hours-scale TTL) | +| `POST /api/host/enroll` | setup password | `{ label }` → `{ hostId, hostToken, origin, rpId }`; appends to `hosts.json` | +| `GET /api/hosts` | session token | Enrolled hosts + whether each is currently connected | +| `GET /ws/host` | host token | The Host's relay socket | +| `GET /ws/client` | session token | A Client's relay socket | + +The setup password is compared in constant time with a small fixed delay on +failure; that is the extent of POC hardening. + +--- + +# Relay + +The server routes JSON envelopes between client sockets and host sockets +(`@hono/node-ws`). Before a session is authorized it only forwards an +allowlist of handshake types; after authorization it is a dumb pipe. + +The relay keeps one current Host binding per Client socket. Host-originated +handshake replies and `msg` frames are routed only when the frame comes from +that current Host; late replies from a previous Host are ignored and cannot +re-establish an old session. +When a Client socket binds to a different Host, the relay sends `client-gone` +to the previous live Host before replacing the binding, so Host-side pairing UI, +remote-api sessions, and watchers are disposed immediately. +Client-originated `pair` and `connect2` frames are also rechecked after their +async validation work: if the Client disconnected, rebound, or the Host socket +was replaced while validation was pending, the stale result is dropped. +For `connect2`, the server remembers the last Host challenge it relayed to a +Client with a relay-local expiry derived from the server's observation time +(`DEFAULT_CHALLENGE_TTL_MS`). The Host's `expiresAt` is still forwarded to the +Client, but the server never compares its own clock to that Host wall-clock +timestamp. + +## Pairing (phone ↔ laptop, first time) + +``` +phone server host (laptop) + |-- signin (passkey) -------->| | + | generate device key | | + |-- pair-request ------------>|-- pair-request ------------->| approval modal + | | | user clicks Approve + |<-- pair-result -------------|<-- pair-result --------------| ACL record saved +``` + +The `pair-request` carries the `PairingRequest` shape from `server-lib-common` +(`accountId`, `passkeyCredentialId`, `passkeyPublicKeyHash`, +`devicePublicKey`, `requestedLabel`). The server checks the session's +credential matches and rejects malformed requests before relaying; the Host runs +`PairingCeremony` and only local approval writes the ACL. A malformed +`PairingRequest` is answered locally with `pair-result approved:false` and is +never shown in the Host approval UI. + +## Connect (every session) + +``` +phone server host + |-- connect-request {hostId}->|-- challenge-request -------->| + |<-- challenge ---------------|<-- challenge (HostChallengeIssuer) + | ONE biometric prompt: | | + | WebAuthn get({challenge}) | | + | + device-key signature | | + |-- ConnectionRequest ------->| server verifies the | + | | assertion itself, then | + | |-- ConnectionRequest -------->| authorizeConnection() + |<-- session-established -----|<-- decision -----------------| (final authority) + |============ opaque remote-api relay from here ============>| +``` + +One host challenge feeds both signatures, so the user gets one Face ID prompt +per connection. The server verifies the assertion against the stored passkey +(its half of "fresh user presence is validated by the Server and the Host") +and drops the request on failure; the Host's `authorizeConnection` remains the +final authority regardless of what the server claims to have checked. + +## After authorization: remote-api v1, terminal-only + +Exactly the v1 scope of [remote-api.md](./remote-api.md) minus browser +surfaces: `hello`, `directory.watch` (snapshot-only), one `surface.attach` +(attach-is-the-resize), `terminal.data`/`semantic`/`resize`/`closed` out, +`terminal.write`/`terminal.resize` in. + +--- + +# Host side (`lib` + `standalone`) + +A `remote-host` module in `lib`, active in standalone: + +* **Enrollment** (settings UI, once): server URL + setup password → + `POST /api/host/enroll` → persist `{ serverUrl, hostId, hostToken, origin, + rpId }` via the platform adapter; open and maintain `GET /ws/host`. +* **Security**: `HostAcl` (persisted with `saveState`/`getState` as + `records()`/`fromRecords`), `HostChallengeIssuer`, `PairingCeremony`, and + `authorizeConnection` — all straight from `server-lib-common`, running in + the webview. +* **Pairing approval modal**: shows the requested label + account; Approve / + Deny. (Same modal pattern as KillConfirm.) If the Host user approves after + the pairing ticket expires, the Host sends `pair-result approved:false` with + an error and dismisses the modal; the ACL is untouched. +* **Terminal bridge**: `directory.watch` snapshots come from the existing + terminal registry/state store (title, activity, cwd, exitCode, ringing, + hasTODO — all already tracked); `surface.attach` resizes the PTY through + the existing resize path and subscribes to its data stream; + `terminal.write` feeds the existing input path. +* **Tethering**: while a remote session holds size authority, the local pane + greys out to "tethering to \"; local interaction reclaims it. + +# Pocket side (phone) + +Served by the server, built from `lib`: + +* Sign-in with passkey; session token in memory. +* Device key: `generateDeviceKeyPair()` persisted as non-extractable + CryptoKeys in IndexedDB (the tiny IndexedDB wrapper lives in `lib` — it is + DOM-dependent, so not in `server-lib-common`). +* First run against a host: pairing flow, then connect. After that: connect + straight away. +* Picker renders `directory.snapshot`; tapping a pane attaches with the + phone's cols/rows and reuses the existing mobile terminal UI (xterm). + +--- + +# Build order — five slices, each testable + +1. **Accounts & passkeys.** Setup/sign-in endpoints + `account.json` + + static-serving stub. Tests: `node --test` with `SimAuthenticator` against + `app.request()` — register, sign in, wrong password, replayed challenge, + wrong origin. Manual: create a real passkey at `localhost:3000`. +2. **Relay & host enrollment.** `hosts.json`, host/client sockets, envelope + routing, presence in `GET /api/hosts`. Tests: two in-process WebSockets + echo through the relay; token/session rejection. +3. **Security handshake over the relay.** A headless fake host (a Node script + wiring `server-lib-common` exactly as the harness's `SimHost` does) + + `SimAuthenticator` client: full pairing ceremony and connect through the + real server, plus the deny cases (unpaired device, revoked record, replayed + challenge). Still no browser anywhere. +4. **Standalone host module.** Enrollment settings, approval modal, ACL + persistence, host socket. Dogfood: pair a second browser profile (or the + phone) against your real standalone app and watch the modal + ACL. +5. **Terminal bridge + Pocket terminal view.** Directory, attach-is-the-resize, + write/resize, tether display, mobile terminal UI hookup. Dogfood — the + actual goal: pick up a running session from your laptop on your phone. + +Slices 1–3 are pure Node with full automated coverage; browsers only enter at +slice 4. After slice 5 the POC is in daily-use territory, and everything +after that (browser surfaces, in-flight replay, thumbnails, WebRTC) is +already staged in remote-api.md as additive follow-ups. + +--- + +# Running the POC + +All five slices are implemented. To test end to end: + +**1. Server + Pocket** (one terminal): + +```sh +DORMOUSE_SETUP_PASSWORD=hunter2 pnpm dev:pocket-server +``` + +Builds the Pocket app (`lib/dist-pocket`) and the server, then serves both on +`:3000`. Other env vars per Configuration above; for a real phone set +`DORMOUSE_ORIGIN` to your TLS origin (e.g. via `tailscale serve`) — WebAuthn +needs a secure context, and only `localhost` is exempt. + +**2. Host** (the laptop being controlled): `pnpm dev:standalone`, then enroll +once from the devtools console of the standalone webview: + +```js +await window.dormouseRemoteHost.enroll('http://localhost:3000', 'hunter2', 'My Laptop') +``` + +Enrollment persists in localStorage; on later launches the host connects by +itself. (`status()` / `clearEnrollment()` on the same object.) For a headless +stand-in host instead: +`DORMOUSE_SETUP_PASSWORD=hunter2 node server/scripts/fake-host.mjs http://localhost:3000` +(auto-approves pairing; answers `hello` only — no real terminals). + +**3. Phone** (or any other browser profile): open the server origin → +First-time setup (password + label) creates the passkey and signs you in → +Hosts → **Pair** → approve in the modal on the laptop → **Connect** (one +biometric prompt) → pick a pane → type. + +POC limitations to know about: pair/connect only works from the browser that +registered the passkey (the passkey public key is stored client-side at +registration); clearing site data destroys the device key → re-pair, per the +security model; a dropped WebSocket sends you back to the Hosts view — +reconnect by tapping Connect again. diff --git a/lib/.storybook/main.ts b/lib/.storybook/main.ts index ca06c880..c460fe9e 100644 --- a/lib/.storybook/main.ts +++ b/lib/.storybook/main.ts @@ -25,6 +25,11 @@ const config: StorybookConfig = { // any Wall-importing story fails with "Failed to resolve import 'dor/…'". // Safe next to `dormouse-lib`: a string alias only matches `dor` or `dor/…`. dor: path.resolve(here, '..', '..', 'dor', 'src'), + // Same reason: `Wall` → `RemotePairingModalHost` pulls in the remote host + // modules, which import `server-lib-common`. Its package `exports` point + // at a `dist` the Storybook/Chromatic job never builds, so alias the bare + // specifier to source too. + 'server-lib-common': path.resolve(here, '..', '..', 'server-lib-common', 'src'), }; return config; }, diff --git a/lib/package.json b/lib/package.json index 5c99ef51..079990d7 100644 --- a/lib/package.json +++ b/lib/package.json @@ -6,7 +6,10 @@ "type": "module", "scripts": { "dev": "vite", + "dev:pocket": "vite --config vite.pocket.config.ts", + "prebuild": "pnpm --filter server-lib-common build", "build": "tsc -b && vite build", + "build:pocket": "vite build --config vite.pocket.config.ts", "preview": "vite preview", "pretest": "pnpm --filter dor-lib-common build && pnpm --filter server-lib-common build", "test": "vitest run", diff --git a/lib/pocket/index.html b/lib/pocket/index.html new file mode 100644 index 00000000..b4ff8071 --- /dev/null +++ b/lib/pocket/index.html @@ -0,0 +1,17 @@ + + + + + + + + Dormouse Pocket + + +
+ + + diff --git a/lib/src/App.tsx b/lib/src/App.tsx index 8437216d..be12b41c 100644 --- a/lib/src/App.tsx +++ b/lib/src/App.tsx @@ -27,15 +27,17 @@ export default function App({ restoredLayout, initialDoors, baseboardNotice, + enableRemoteHost, }: { initialPaneIds?: string[]; restoredLayout?: unknown; initialDoors?: PersistedDoor[]; baseboardNotice?: ReactNode; + enableRemoteHost?: boolean; }) { return ( - + ); diff --git a/lib/src/components/MobileWall.test.tsx b/lib/src/components/MobileWall.test.tsx new file mode 100644 index 00000000..a5a92ce8 --- /dev/null +++ b/lib/src/components/MobileWall.test.tsx @@ -0,0 +1,82 @@ +/** + * @vitest-environment jsdom + */ +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MobileWall } from './MobileWall'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const registry = vi.hoisted(() => ({ + activitySnapshot: new Map(), + clearSessionTodo: vi.fn(), + dismissOrToggleAlert: vi.fn(), + disposeSession: vi.fn(), + getActivitySnapshot: vi.fn(), + getOrCreateTerminal: vi.fn(), + terminalPaneStateSnapshot: new Map(), + getTerminalPaneStateSnapshot: vi.fn(), + setTerminalUserTitle: vi.fn(), + subscribeToActivity: vi.fn(() => () => {}), + subscribeToTerminalPaneState: vi.fn(() => () => {}), +})); + +vi.mock('../lib/terminal-registry', () => ({ + ...registry, + DEFAULT_ACTIVITY_STATE: { status: 'WATCHING_DISABLED', todo: false }, +})); + +vi.mock('./TerminalPane', () => ({ + TerminalPane: ({ id }: { id: string }) =>
, +})); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + registry.getActivitySnapshot.mockReturnValue(registry.activitySnapshot); + registry.getTerminalPaneStateSnapshot.mockReturnValue(registry.terminalPaneStateSnapshot); + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: vi.fn(() => null), + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +function renderWall(showKillButton?: boolean) { + act(() => { + root.render( + + + , + ); + }); +} + +describe('MobileWall', () => { + it('shows the Kill control by default', () => { + renderWall(); + + expect(container.querySelector('button[aria-label="Kill"]')).not.toBeNull(); + }); + + it('can hide the local Kill control for Host-owned remote panes', () => { + renderWall(false); + + expect(container.querySelector('button[aria-label="Kill"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Minimize"]')).not.toBeNull(); + }); +}); diff --git a/lib/src/components/MobileWall.tsx b/lib/src/components/MobileWall.tsx index 459aceed..e47ade11 100644 --- a/lib/src/components/MobileWall.tsx +++ b/lib/src/components/MobileWall.tsx @@ -43,6 +43,7 @@ export interface MobileWallProps { onActiveSessionChange?: (id: string) => void; onSessionMinimize?: (id: string) => void; onSessionKill?: (id: string) => void; + showKillButton?: boolean; className?: string; } @@ -98,6 +99,7 @@ export function MobileWall({ onActiveSessionChange, onSessionMinimize, onSessionKill, + showKillButton = true, className, }: MobileWallProps) { const [internalSessions, setInternalSessions] = useState(() => controlledSessions ?? [DEFAULT_MOBILE_SESSION]); @@ -161,6 +163,7 @@ export function MobileWall({ session={activeItem} onMinimize={() => onSessionMinimize?.(activeItem.id)} onKill={() => killSession(activeItem.id)} + showKillButton={showKillButton} />
@@ -173,10 +176,12 @@ function MobileWallHeader({ session, onMinimize, onKill, + showKillButton, }: { session: MobileTerminalSessionItem; onMinimize: () => void; onKill: () => void; + showKillButton: boolean; }) { const status = session.status ?? 'WATCHING_DISABLED'; const todoPill = useTodoPillContent(session.todo === true); @@ -235,14 +240,16 @@ function MobileWallHeader({ > - - - + {showKillButton ? ( + + + + ) : null}
); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 63cfa250..5563b511 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'; +import { useRef, useState, useEffect, useCallback, useMemo, lazy, Suspense, type ReactNode } from 'react'; import { clsx } from 'clsx'; import { DockviewReact, @@ -10,6 +10,15 @@ import 'dockview-react/dist/styles/dockview.css'; import { Baseboard } from './Baseboard'; import { ExternalLinkModalHost } from './ExternalLinkModalHost'; import { AgentBrowserScreenModalHost } from './AgentBrowserScreenModalHost'; +// Remote-host code (relay/WebSocket/enrollment + the window.dormouseRemoteHost +// console hook) is loaded and mounted only when the embedding runtime opts in +// via `enableRemoteHost` — see the mount below. Lazy so it stays out of the +// website playground and vscode webview bundles, which never enable it. +const RemotePairingModalHost = lazy(() => + import('../remote/host/RemotePairingModalHost').then((m) => ({ + default: m.RemotePairingModalHost, + })), +); import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; import { markAgentBrowserSessionClosed } from './wall/agent-browser-sessions'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; @@ -446,6 +455,7 @@ export function Wall({ onEvent, baseboardNotice, showBaseboard = true, + enableRemoteHost = false, }: { initialPaneIds?: string[]; initialMode?: WallMode; @@ -455,6 +465,13 @@ export function Wall({ onEvent?: (event: WallEvent) => void; baseboardNotice?: ReactNode; showBaseboard?: boolean; + /** + * Opt in to the remote-control Host (the "Pocket" pairing seam). Only the + * standalone desktop/sidecar runtime sets this; the website playground and + * vscode webview leave it off so the remote-host stack and its + * `window.dormouseRemoteHost` console hook never load there. + */ + enableRemoteHost?: boolean; } = {}) { const apiRef = useRef(null); const [dockviewApi, setDockviewApi] = useState(null); @@ -1845,6 +1862,11 @@ export function Wall({ onKeyboardActiveChange={setDialogKeyboardActive} resolveLabel={surfaceRefForId} /> + {enableRemoteHost ? ( + + + + ) : null} diff --git a/lib/src/lib/local-json-store.test.ts b/lib/src/lib/local-json-store.test.ts new file mode 100644 index 00000000..de4f2964 --- /dev/null +++ b/lib/src/lib/local-json-store.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { loadJson, saveJson } from './local-json-store'; + +function stubLocalStorage(): Map { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + return store; +} + +interface Widget { + id: string; +} + +function isWidget(value: unknown): value is Widget { + return !!value && typeof value === 'object' && typeof (value as Widget).id === 'string'; +} + +describe('local-json-store', () => { + afterEach(() => vi.unstubAllGlobals()); + + describe('loadJson', () => { + it('round-trips a stored value', () => { + const store = stubLocalStorage(); + store.set('k', JSON.stringify({ id: 'w1' })); + expect(loadJson('k', null, isWidget)).toEqual({ id: 'w1' }); + }); + + it('returns the fallback for a missing key', () => { + stubLocalStorage(); + expect(loadJson('missing', null, isWidget)).toBeNull(); + expect(loadJson('missing', [])).toEqual([]); + }); + + it('returns the fallback for malformed JSON', () => { + const store = stubLocalStorage(); + store.set('k', 'not json'); + expect(loadJson('k', null, isWidget)).toBeNull(); + expect(loadJson('k', [], Array.isArray)).toEqual([]); + }); + + it('returns the fallback when the guard rejects the parsed value', () => { + const store = stubLocalStorage(); + store.set('k', JSON.stringify({ notId: 42 })); + expect(loadJson('k', null, isWidget)).toBeNull(); + }); + + it('returns the parsed value unvalidated when no guard is given', () => { + const store = stubLocalStorage(); + store.set('k', JSON.stringify({ id: 'w1' })); + expect(loadJson('k', null)).toEqual({ id: 'w1' }); + }); + + it('returns the fallback when localStorage is absent', () => { + vi.stubGlobal('localStorage', undefined); + expect(loadJson('k', null, isWidget)).toBeNull(); + expect(loadJson('k', [], Array.isArray)).toEqual([]); + }); + }); + + describe('saveJson', () => { + it('JSON-stringifies and writes the value', () => { + const store = stubLocalStorage(); + saveJson('k', { id: 'w1' }); + expect(store.get('k')).toBe(JSON.stringify({ id: 'w1' })); + }); + + it('does not throw when localStorage is absent', () => { + vi.stubGlobal('localStorage', undefined); + expect(() => saveJson('k', { id: 'w1' })).not.toThrow(); + }); + + it('swallows a write failure (e.g. quota exceeded)', () => { + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => { + throw new DOMException('quota', 'QuotaExceededError'); + }, + removeItem: () => {}, + }); + expect(() => saveJson('k', { id: 'w1' })).not.toThrow(); + }); + }); +}); diff --git a/lib/src/lib/local-json-store.ts b/lib/src/lib/local-json-store.ts new file mode 100644 index 00000000..5311ff8e --- /dev/null +++ b/lib/src/lib/local-json-store.ts @@ -0,0 +1,49 @@ +/** + * The load/save dance for a single JSON blob kept in `localStorage`. Several + * host-side stores (the ACL, the enrollment credentials) persist one value + * under one key with identical failure semantics: + * + * - absent `localStorage` (SSR / no-storage host / test context) must not + * throw — reads yield the fallback, writes are silently dropped; + * - a missing key, malformed JSON, or a value that fails validation all + * collapse to the caller's fallback rather than propagating; + * - a failed write (no storage, quota exceeded) is swallowed so the + * in-memory value keeps working for the session. + * + * Each caller supplies its own key, fallback, and (optionally) a type guard, so + * the fallback and validation stay caller-specific while the boilerplate lives + * here once. + */ + +/** + * Read and JSON-parse the value at `key`, returning `fallback` if storage is + * unavailable, the key is missing, the JSON is malformed, or `validate` (when + * given) rejects the parsed value. + */ +export function loadJson( + key: string, + fallback: F, + validate?: (value: unknown) => value is V, +): V | F { + try { + const raw = globalThis.localStorage?.getItem(key); + if (!raw) return fallback; + const parsed: unknown = JSON.parse(raw); + if (validate && !validate(parsed)) return fallback; + return parsed as V; + } catch { + return fallback; + } +} + +/** + * JSON-stringify `value` and write it to `key`, swallowing any failure (absent + * storage, quota exceeded) so callers keep their in-memory value. + */ +export function saveJson(key: string, value: unknown): void { + try { + globalThis.localStorage?.setItem(key, JSON.stringify(value)); + } catch { + // No localStorage / quota exceeded: the in-memory value still works. + } +} diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 01f13c18..e4f2dd5e 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -167,6 +167,10 @@ function wirePtyEvents(id: string, terminal: Terminal): () => void { const handleExit = (detail: { id: string; exitCode: number }) => { if (detail.id !== id) return; terminal.write(`\r\n[Process exited with code ${detail.exitCode}]\r\n`); + // The PTY process is dead but the pane lingers in the registry; mark it so + // the directory reports this surface as `alive: false` to the phone. + const entry = registry.get(id); + if (entry) entry.exited = true; // The process is gone, so any command we seeded for this pane is no longer // live; clear it so `dor ensure` stops matching a dead surface. finishLaunchedCommandByPtyId(id, detail.exitCode); diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 20f1093c..5e771e3c 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -23,6 +23,12 @@ export interface TerminalEntry { attentionDismissedRing: boolean; isReplaying: boolean; untouched: boolean; + /** + * The PTY process has exited (onPtyExit fired) but the pane lingers in the + * registry showing "[Process exited…]". The directory reports this surface as + * `alive: false` so the phone's picker stops offering it as attachable. + */ + exited?: boolean; } export interface TerminalOverlayDims { diff --git a/lib/src/remote/client/device-key.ts b/lib/src/remote/client/device-key.ts new file mode 100644 index 00000000..5fd8b708 --- /dev/null +++ b/lib/src/remote/client/device-key.ts @@ -0,0 +1,107 @@ +/** + * The Pocket device key: a non-extractable ECDSA P-256 keypair that is this + * browser's long-lived Client identity (docs/specs/remote-security-model.md). + * The `CryptoKey` objects are persisted directly in IndexedDB — never exported + * — so the private key material never leaves the runtime, exactly as + * `generateDeviceKeyPair`'s contract intends. + * + * The store is injected into {@link getOrCreateDeviceKey} so its logic is + * unit-testable without IndexedDB (the browser default is + * {@link indexedDbDeviceKeyStore}). + */ + +import { generateDeviceKeyPair, type DeviceKeyPair } from 'server-lib-common'; + +/** Where a {@link DeviceKeyPair} is persisted; faked in tests. */ +export interface DeviceKeyStore { + get(): Promise; + put(key: DeviceKeyPair): Promise; +} + +const DB_NAME = 'dormouse-pocket'; +const STORE_NAME = 'device-key'; +const RECORD_KEY = 'default'; + +/** + * Return this device's keypair, generating and persisting one on first run. + * The private key is non-extractable; only its base64url public point + * (`devicePublicKey`) ever crosses the wire. + */ +export async function getOrCreateDeviceKey( + store: DeviceKeyStore = indexedDbDeviceKeyStore(), +): Promise { + const existing = await store.get(); + if (existing) return existing; + const created = await generateDeviceKeyPair(); + await store.put(created); + return created; +} + +/** A tiny one-object-store IndexedDB wrapper holding the `CryptoKey` objects. */ +export function indexedDbDeviceKeyStore(): DeviceKeyStore { + return { + async get() { + const db = await openDb(); + try { + const value = await promisifyRequest( + db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(RECORD_KEY), + ); + if (!value) return null; + return { + publicKey: value.publicKey, + privateKey: value.privateKey, + devicePublicKey: value.devicePublicKey, + }; + } finally { + db.close(); + } + }, + async put(key) { + const db = await openDb(); + try { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const record: StoredDeviceKey = { + publicKey: key.publicKey as CryptoKey, + privateKey: key.privateKey as CryptoKey, + devicePublicKey: key.devicePublicKey, + }; + tx.objectStore(STORE_NAME).put(record, RECORD_KEY); + await promisifyTransaction(tx); + } finally { + db.close(); + } + }, + }; +} + +interface StoredDeviceKey { + readonly publicKey: CryptoKey; + readonly privateKey: CryptoKey; + readonly devicePublicKey: string; +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE_NAME); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('failed to open IndexedDB')); + }); +} + +function promisifyRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); +} + +function promisifyTransaction(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')); + }); +} diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts new file mode 100644 index 00000000..6b929556 --- /dev/null +++ b/lib/src/remote/client/pocket-client.test.ts @@ -0,0 +1,527 @@ +/** + * Pocket protocol-client coverage with faked fetch / WebAuthn / WebSocket, plus + * the injected-store device-key logic. Everything crypto-touching + * (`generateDeviceKeyPair`, `signDeviceChallenge`, `hashPasskeyPublicKey`) runs + * for real against Node's WebCrypto; only the browser/network edges are faked. + * + * (fake-indexeddb is not a dependency, so the IndexedDB round-trip itself is not + * exercised here — `getOrCreateDeviceKey` is tested through an injected store.) + */ + +import { describe, expect, it } from 'vitest'; +import { + SELFHOST_ACCOUNT_ID, + generateDeviceKeyPair, + hashPasskeyPublicKey, + toBase64Url, + type DeviceKeyPair, + type PasskeyAssertion, +} from 'server-lib-common'; + +import { + hasRecoverablePairingFailure, + PocketClient, + type PocketSocket, + type PocketStorage, + type PocketClientDeps, +} from './pocket-client'; +import { getOrCreateDeviceKey, type DeviceKeyStore } from './device-key'; +import type { PasskeyRegistration, WebAuthnClient } from './webauthn'; + +// --- Fakes ----------------------------------------------------------------- + +const CREDENTIAL_ID = 'cred-123'; +const PASSKEY_PUBLIC_KEY = 'pk-spki-b64u'; +const RP_ID = 'localhost'; + +/** A base64url string usable as a real challenge (device signing decodes it). */ +function b64uChallenge(seed: number): string { + return toBase64Url(Uint8Array.from({ length: 32 }, (_, i) => (seed + i) & 0xff)); +} + +const assertion: PasskeyAssertion = { + credentialId: CREDENTIAL_ID, + clientDataJSON: 'client-data', + authenticatorData: 'auth-data', + signature: 'sig', +}; + +const fakeWebAuthn: WebAuthnClient = { + async registerPasskey(): Promise { + return { + credentialId: CREDENTIAL_ID, + publicKey: PASSKEY_PUBLIC_KEY, + clientDataJSON: 'create-client-data', + }; + }, + async getAssertion(): Promise { + return assertion; + }, +}; + +interface FetchCall { + url: string; + method: string; + headers: Record; + body: unknown; +} + +/** A router-style fake `fetch` that records every call. */ +function makeFetch(routes: Record { status?: number; json: unknown }>) { + const calls: FetchCall[] = []; + const fetch = (async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'POST'; + const headers = (init?.headers ?? {}) as Record; + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ url, method, headers, body }); + const path = new URL(url, 'http://test').pathname; + const handler = routes[path]; + if (!handler) throw new Error(`unexpected fetch: ${path}`); + const { status = 200, json } = handler(body); + return { + ok: status >= 200 && status < 300, + status, + json: async () => json, + } as Response; + }) as unknown as typeof fetch; + return { fetch, calls }; +} + +function memoryStorage(): PocketStorage { + const passkeys = new Map(); + const paired = new Set(); + return { + getPasskeyPublicKey: (id) => passkeys.get(id) ?? null, + setPasskeyPublicKey: (id, pk) => void passkeys.set(id, pk), + knownCredentialIds: () => [...passkeys.keys()], + isPaired: (hostId) => paired.has(hostId), + markPaired: (hostId) => void paired.add(hostId), + unmarkPaired: (hostId) => void paired.delete(hostId), + }; +} + +/** + * A {@link WebAuthnClient} that records the `allowCredentials` each + * `getAssertion` is scoped to, so tests can assert connect narrows selection. + */ +function recordingWebAuthn(): { + webauthn: WebAuthnClient; + assertionAllowLists: Array; +} { + const assertionAllowLists: Array = []; + return { + assertionAllowLists, + webauthn: { + registerPasskey: fakeWebAuthn.registerPasskey, + async getAssertion(_challenge, _rpId, allowCredentials): Promise { + assertionAllowLists.push(allowCredentials); + return assertion; + }, + }, + }; +} + +class FakeSocket implements PocketSocket { + readyState = 0; + readonly sent: Array> = []; + readonly #handlers = new Map void>>(); + + addEventListener(type: string, handler: (ev: unknown) => void): void { + const list = this.#handlers.get(type) ?? []; + list.push(handler); + this.#handlers.set(type, list); + } + + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + + close(): void { + this.readyState = 3; + this.#emit('close', { code: 1000 }); + } + + /** Simulate the server/network dropping the connection (no client `close()`). */ + drop(): void { + this.readyState = 3; + this.#emit('close', { code: 1006 }); + } + + fireOpen(): void { + this.readyState = 1; + this.#emit('open', {}); + } + + /** Simulate the server sending a frame to this client. */ + server(frame: unknown): void { + this.#emit('message', { data: JSON.stringify(frame) }); + } + + #emit(type: string, ev: unknown): void { + for (const handler of this.#handlers.get(type) ?? []) handler(ev); + } +} + +/** Poll `sent` for the first frame matching `predicate`. */ +async function nextSent( + socket: FakeSocket, + predicate: (frame: Record) => boolean, +): Promise> { + for (let i = 0; i < 200; i++) { + const found = socket.sent.find(predicate); + if (found) return found; + await new Promise((r) => setTimeout(r, 2)); + } + throw new Error('expected frame was never sent'); +} + +interface Harness { + client: PocketClient; + socket: FakeSocket; + calls: FetchCall[]; + device: () => Promise; +} + +function makeClient( + routes: Record { status?: number; json: unknown }>, + overrides: Partial = {}, +): Harness { + const socket = new FakeSocket(); + const { fetch, calls } = makeFetch(routes); + let devicePair: DeviceKeyPair | undefined; + const device = async () => (devicePair ??= await generateDeviceKeyPair()); + const client = new PocketClient({ + wsBase: 'ws://test', + fetch, + webauthn: fakeWebAuthn, + createWebSocket: () => socket, + deviceKey: device, + storage: memoryStorage(), + ...overrides, + }); + return { client, socket, calls, device }; +} + +const AUTH_ROUTES = { + '/api/setup/begin': () => ({ + json: { challenge: b64uChallenge(1), rpId: RP_ID, accountId: SELFHOST_ACCOUNT_ID }, + }), + '/api/setup/finish': () => ({ + json: { accountId: SELFHOST_ACCOUNT_ID, credentialId: CREDENTIAL_ID }, + }), + '/api/signin/begin': () => ({ json: { challenge: b64uChallenge(9), rpId: RP_ID } }), + '/api/signin/finish': () => ({ + json: { sessionToken: 'tok-abc', accountId: SELFHOST_ACCOUNT_ID, expiresAt: 1 }, + }), + '/api/hosts': () => ({ json: { hosts: [{ hostId: 'h1', label: 'Laptop', online: true }] } }), +} as const; + +/** Setup + sign-in + open the relay socket, ready for pair/connect. */ +async function signedIn(overrides: Partial = {}): Promise { + const harness = makeClient({ ...AUTH_ROUTES }, overrides); + await harness.client.setup('pw', 'My Phone'); + await harness.client.signin(); + const open = harness.client.openSocket(); + harness.socket.fireOpen(); + await open; + return harness; +} + +async function pairApproved(client: PocketClient, socket: FakeSocket): Promise { + const pairing = client.pair('h1', 'iPhone'); + await nextSent(socket, (f) => f.t === 'pair'); + socket.server({ t: 'pair-result', approved: true, record: { hostId: 'h1' } }); + await pairing; +} + +// --- Tests ----------------------------------------------------------------- + +describe('setup + signin', () => { + it('registers, signs in, keeps the token, and sends it as a bearer', async () => { + const harness = makeClient({ ...AUTH_ROUTES }); + const setup = await harness.client.setup('pw', 'My Phone'); + expect(setup.credentialId).toBe(CREDENTIAL_ID); + + const signin = await harness.client.signin(); + expect(signin.sessionToken).toBe('tok-abc'); + expect(harness.client.sessionToken).toBe('tok-abc'); + + const hosts = await harness.client.listHosts(); + expect(hosts).toEqual([{ hostId: 'h1', label: 'Laptop', online: true }]); + const hostsCall = harness.calls.find((c) => c.url.endsWith('/api/hosts'))!; + expect(hostsCall.method).toBe('GET'); + expect(hostsCall.headers.authorization).toBe('Bearer tok-abc'); + }); + + it('rejects with the server error message on a failed request', async () => { + const harness = makeClient({ + '/api/setup/begin': () => ({ status: 401, json: { error: 'invalid setup password' } }), + }); + await expect(harness.client.setup('wrong', 'Phone')).rejects.toThrow('invalid setup password'); + }); +}); + +describe('pair', () => { + it('sends a well-formed pairing frame and resolves on pair-result', async () => { + const { client, socket } = await signedIn(); + const pairing = client.pair('h1', 'iPhone Safari'); + + const frame = await nextSent(socket, (f) => f.t === 'pair'); + expect(frame.hostId).toBe('h1'); + const request = frame.request as Record; + expect(request.accountId).toBe(SELFHOST_ACCOUNT_ID); + expect(request.passkeyCredentialId).toBe(CREDENTIAL_ID); + expect(request.passkeyPublicKeyHash).toBe(await hashPasskeyPublicKey(PASSKEY_PUBLIC_KEY)); + expect(request.requestedLabel).toBe('iPhone Safari'); + expect(typeof request.devicePublicKey).toBe('string'); + + const record = { hostId: 'h1', label: 'iPhone Safari' }; + socket.server({ t: 'pair-result', approved: true, record }); + const result = await pairing; + expect(result.approved).toBe(true); + expect(result.record).toEqual(record); + expect(client.isPaired('h1')).toBe(true); + }); + + it('surfaces a denial and does not mark the host paired', async () => { + const { client, socket } = await signedIn(); + const pairing = client.pair('h1', 'iPhone'); + await nextSent(socket, (f) => f.t === 'pair'); + socket.server({ t: 'pair-result', approved: false, error: 'denied by host' }); + const result = await pairing; + expect(result.approved).toBe(false); + expect(result.error).toBe('denied by host'); + expect(client.isPaired('h1')).toBe(false); + }); +}); + +describe('connect', () => { + it('classifies ACL miss failures as recoverable stale pairing', () => { + expect(hasRecoverablePairingFailure(['device-not-paired'])).toBe(true); + expect(hasRecoverablePairingFailure(['pairing-mismatch'])).toBe(true); + expect(hasRecoverablePairingFailure(['passkey-not-paired'])).toBe(true); + expect(hasRecoverablePairingFailure(['challenge-invalid'])).toBe(false); + expect(hasRecoverablePairingFailure(undefined)).toBe(false); + }); + + it('challenge → one assertion + device signature → connect2 → allowed', async () => { + const { client, socket, device } = await signedIn(); + const connecting = client.connect('h1'); + + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + + const connect2 = await nextSent(socket, (f) => f.t === 'connect2'); + const request = connect2.request as Record; + expect(request.accountId).toBe(SELFHOST_ACCOUNT_ID); + expect(request.challenge).toBe(b64uChallenge(7)); + expect(request.devicePublicKey).toBe((await device()).devicePublicKey); + expect(typeof request.deviceSignature).toBe('string'); + expect((request.passkey as Record).publicKey).toBe(PASSKEY_PUBLIC_KEY); + expect((request.passkey as { assertion: PasskeyAssertion }).assertion.credentialId).toBe( + CREDENTIAL_ID, + ); + + socket.server({ t: 'decision', allowed: true }); + const decision = await connecting; + expect(decision.allowed).toBe(true); + expect(client.connectedHostId).toBe('h1'); + }); + + it('scopes the connect assertion to the stored credential and resolves its public key', async () => { + const { webauthn, assertionAllowLists } = recordingWebAuthn(); + const { client, socket } = await signedIn({ webauthn }); + + const connecting = client.connect('h1'); + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + const connect2 = await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: true }); + + const decision = await connecting; + expect(decision.allowed).toBe(true); + // sign-in discovers (empty list); connect scopes to the credential setup stored. + expect(assertionAllowLists.at(-1)).toEqual([CREDENTIAL_ID]); + // ...so the stored public key is the one placed into the connect2 request. + const request = connect2.request as { passkey: { publicKey: string } }; + expect(request.passkey.publicKey).toBe(PASSKEY_PUBLIC_KEY); + }); + + it('rejects a second waiter for an already-pending frame type', async () => { + const { client, socket } = await signedIn(); + const first = client.connect('h1'); + // Once the first connect is awaiting its challenge, a second overlapping + // connect must not silently queue behind it. + await nextSent(socket, (f) => f.t === 'connect'); + await expect(client.connect('h1')).rejects.toThrow(/already awaiting/); + + // The first handshake still completes normally. + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: true }); + expect((await first).allowed).toBe(true); + }); + + it('resolves not-allowed with failures on a denied decision', async () => { + const { client, socket } = await signedIn(); + await pairApproved(client, socket); + expect(client.isPaired('h1')).toBe(true); + + const connecting = client.connect('h1'); + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(3), expiresAt: 9e15 }); + await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: false, failures: ['device-not-paired'] }); + const decision = await connecting; + expect(decision.allowed).toBe(false); + expect(decision.failures).toEqual(['device-not-paired']); + expect(decision.pairingStale).toBe(true); + expect(client.isPaired('h1')).toBe(false); + expect(client.connectedHostId).toBeNull(); + }); + + it('keeps the paired marker for non-pairing denials', async () => { + const { client, socket } = await signedIn(); + await pairApproved(client, socket); + + const connecting = client.connect('h1'); + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(4), expiresAt: 9e15 }); + await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: false, failures: ['challenge-invalid'] }); + + const decision = await connecting; + expect(decision.allowed).toBe(false); + expect(decision.pairingStale).toBeUndefined(); + expect(client.isPaired('h1')).toBe(true); + }); +}); + +/** Drive the full connect dance until the session is established. */ +async function connectEstablished(harness: Harness): Promise { + const { client, socket } = harness; + const connecting = client.connect('h1'); + await nextSent(socket, (f) => f.t === 'connect'); + socket.server({ t: 'challenge', hostId: 'h1', challenge: b64uChallenge(7), expiresAt: 9e15 }); + await nextSent(socket, (f) => f.t === 'connect2'); + socket.server({ t: 'decision', allowed: true }); + await connecting; +} + +describe('socket lifecycle', () => { + it('an unexpected close fires host-gone for an established session and resets the socket', async () => { + const harness = await signedIn(); + await connectEstablished(harness); + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + harness.socket.drop(); + expect(hostGone).toBe(1); + expect(harness.client.socketOpen).toBe(false); + expect(harness.client.connectedHostId).toBeNull(); + }); + + it('an intentional close() does not fire host-gone', async () => { + const harness = await signedIn(); + await connectEstablished(harness); + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + harness.client.close(); + expect(hostGone).toBe(0); + expect(harness.client.socketOpen).toBe(false); + }); + + it('a host-gone frame followed by a socket close fires host-gone exactly once', async () => { + const harness = await signedIn(); + await connectEstablished(harness); + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + harness.socket.server({ t: 'host-gone' }); + expect(hostGone).toBe(1); + expect(harness.client.connectedHostId).toBeNull(); + harness.socket.drop(); + expect(hostGone).toBe(1); + }); + + it('an unexpected close without an established session resets state silently', async () => { + const harness = await signedIn(); + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + harness.socket.drop(); + expect(hostGone).toBe(0); + expect(harness.client.socketOpen).toBe(false); + }); +}); + +describe('remote-api correlation', () => { + it('resolves a request by requestId', async () => { + const { client, socket } = await signedIn(); + const helloing = client.hello(); + const frame = await nextSent(socket, (f) => f.t === 'msg'); + const data = frame.data as { requestId: string; method: string }; + expect(data.method).toBe('hello'); + socket.server({ + t: 'msg', + data: { requestId: data.requestId, ok: true, result: { protocolVersion: 1, hostId: 'h1' } }, + }); + const result = await helloing; + expect(result.hostId).toBe('h1'); + }); + + it('rejects a request when the response is ok:false', async () => { + const { client, socket } = await signedIn(); + const req = client.request('bogus'); + const frame = await nextSent(socket, (f) => f.t === 'msg'); + const data = frame.data as { requestId: string }; + socket.server({ t: 'msg', data: { requestId: data.requestId, ok: false, error: 'nope' } }); + await expect(req).rejects.toThrow('nope'); + }); + + it('routes events by subId, and only to the matching subscription', async () => { + const { client, socket } = await signedIn(); + const snapshots: unknown[] = []; + const watching = client.watchDirectory((entries) => snapshots.push(entries)); + + const frame = await nextSent(socket, (f) => f.t === 'msg'); + const data = frame.data as { requestId: string; method: string }; + expect(data.method).toBe('directory.watch'); + // Host convention: the subId is the request's own requestId. + socket.server({ t: 'msg', data: { requestId: data.requestId, ok: true, result: { subId: data.requestId } } }); + const subId = await watching; + expect(subId).toBe(data.requestId); + + // A snapshot for our subId is delivered... + socket.server({ + t: 'msg', + data: { subId, event: 'directory.snapshot', data: { entries: [{ title: 'zsh' }] } }, + }); + // ...one for an unrelated subId is not. + socket.server({ + t: 'msg', + data: { subId: 'other', event: 'directory.snapshot', data: { entries: [{ title: 'nope' }] } }, + }); + expect(snapshots).toEqual([[{ title: 'zsh' }]]); + }); +}); + +describe('getOrCreateDeviceKey (injected store)', () => { + it('generates and persists on first call, then reuses', async () => { + let stored: DeviceKeyPair | null = null; + let puts = 0; + const store: DeviceKeyStore = { + get: async () => stored, + put: async (key) => { + stored = key; + puts++; + }, + }; + const first = await getOrCreateDeviceKey(store); + expect(puts).toBe(1); + const second = await getOrCreateDeviceKey(store); + expect(puts).toBe(1); + expect(second.devicePublicKey).toBe(first.devicePublicKey); + }); +}); diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts new file mode 100644 index 00000000..bbfc6b48 --- /dev/null +++ b/lib/src/remote/client/pocket-client.ts @@ -0,0 +1,595 @@ +/** + * The Pocket protocol client: a UI-free driver of the exact client flow the + * server's `handshake.test.mjs` exercises (register → signin → pair → connect → + * challenge → connect2 → msg), but with real `navigator.credentials` and a real + * IndexedDB device key instead of the simulated harness. + * + * Everything external is injected — `fetch`, the {@link WebAuthnClient}, the + * WebSocket factory, the device key, and localStorage-backed {@link PocketStorage} + * — so vitest can fake all of it (`pocket-client.test.ts`). + * + * Correlation follows the Host's conventions (see `remote/host/remote-api.ts`): + * a `msg` request is matched by `requestId`; events are matched by `subId`, and + * for `directory.watch` / `surface.attach` the Host reuses the request's + * `requestId` as that `subId`, so this client sends those two with + * `requestId === subId`. + */ + +import { + API_ROUTES, + REMOTE_EVENTS, + REMOTE_METHODS, + SELFHOST_ACCOUNT_ID, + WS_ROUTES, + WS_TOKEN_PARAM, + hashPasskeyPublicKey, + signDeviceChallenge, + type ClientFrame, + type ConnectionFailure, + type ConnectionRequest, + type DeviceKeyPair, + type DirectoryEntry, + type DirectorySnapshot, + type HelloResult, + type HostAclRecord, + type HostsResponse, + type PairingRequest, + type RemoteEventMsg, + type RemoteResponse, + type ServerToClientFrame, + type SetupBeginResponse, + type SetupFinishResponse, + type SigninBeginResponse, + type SigninFinishResponse, + type TerminalAttachResult, + type TerminalClosedEvent, + type TerminalDataEvent, + type TerminalResizeEvent, +} from 'server-lib-common'; +import type { WebAuthnClient } from './webauthn'; +import type { RemoteWebSocket } from '../ws'; + +/** The slice of a WebSocket the client uses; a browser `WebSocket` satisfies it. */ +export type PocketSocket = RemoteWebSocket; + +/** + * Persistent per-device state. Passkey public keys are stashed at registration + * keyed by credential id, because the wire never returns a passkey's public key + * on sign-in — so pairing/connecting can only build its request on the device + * that created the passkey (a documented POC limitation). + */ +export interface PocketStorage { + getPasskeyPublicKey(credentialId: string): string | null; + setPasskeyPublicKey(credentialId: string, publicKey: string): void; + /** Credential ids this device has stored a public key for (may be empty). */ + knownCredentialIds(): string[]; + isPaired(hostId: string): boolean; + markPaired(hostId: string): void; + unmarkPaired(hostId: string): void; +} + +export interface PocketClientDeps { + /** Prepended to API routes; `''` for same-origin (the served app). */ + readonly baseUrl?: string; + /** Base for the `/ws/client` URL, e.g. `wss://host`; derived from origin in the app. */ + readonly wsBase: string; + readonly fetch: typeof fetch; + readonly webauthn: WebAuthnClient; + readonly createWebSocket: (url: string) => PocketSocket; + /** This device's key; memoized after the first call. */ + readonly deviceKey: () => Promise; + readonly storage?: PocketStorage; +} + +/** Terminal stream callbacks for {@link PocketClient.attach}. */ +export interface TerminalHandlers { + /** Base64url PTY output bytes. */ + onData(bytes: string): void; + onResize?(cols: number, rows: number): void; + onClosed?(exitCode?: number): void; +} + +export interface ConnectDecision { + readonly allowed: boolean; + readonly failures?: readonly ConnectionFailure[]; + /** True when a denial means the local paired marker is stale and the user can re-pair. */ + readonly pairingStale?: boolean; +} + +export interface PairResult { + readonly approved: boolean; + readonly record?: HostAclRecord; + readonly error?: string; +} + +interface Waiter { + resolve(frame: ServerToClientFrame): void; + reject(error: Error): void; +} + +interface PendingRequest { + resolve(result: unknown): void; + reject(error: Error): void; +} + +export class PocketClient { + readonly #baseUrl: string; + readonly #wsBase: string; + readonly #fetch: typeof fetch; + readonly #webauthn: WebAuthnClient; + readonly #createWebSocket: (url: string) => PocketSocket; + readonly #deviceKeyFactory: () => Promise; + readonly #storage: PocketStorage; + + #ws: PocketSocket | null = null; + #sessionToken: string | null = null; + #rpId: string | null = null; + /** The credential id from the most recent sign-in (or registration). */ + #credentialId: string | null = null; + #connectedHostId: string | null = null; + #deviceKey: DeviceKeyPair | null = null; + #onHostGone: (() => void) | null = null; + + /** + * The single in-flight handshake waiter per frame type + * (`pair-result`/`challenge`/`decision`). The handshake awaits exactly one of + * each in strict sequence and the App's single-flight guard forbids overlap, + * so at most one waiter per type is ever pending — {@link #expect} throws if a + * second is registered rather than silently queueing it. + */ + readonly #waiters = new Map(); + /** In-flight remote-api requests, keyed by `requestId`. */ + readonly #pending = new Map(); + /** Live event subscriptions, keyed by `subId`. */ + readonly #events = new Map void>(); + + constructor(deps: PocketClientDeps) { + this.#baseUrl = deps.baseUrl ?? ''; + this.#wsBase = deps.wsBase; + this.#fetch = deps.fetch; + this.#webauthn = deps.webauthn; + this.#createWebSocket = deps.createWebSocket; + this.#deviceKeyFactory = deps.deviceKey; + this.#storage = deps.storage ?? localStoragePocketStorage(); + } + + get sessionToken(): string | null { + return this.#sessionToken; + } + + get connectedHostId(): string | null { + return this.#connectedHostId; + } + + isPaired(hostId: string): boolean { + return this.#storage.isPaired(hostId); + } + + /** Notified when the Host drops (a `host-gone` frame or a closed socket). */ + setOnHostGone(callback: (() => void) | null): void { + this.#onHostGone = callback; + } + + // --- Account: first-time setup + sign-in --------------------------------- + + /** First-time setup: password-gated passkey registration. Follow with {@link signin}. */ + async setup(password: string, label: string): Promise { + const begin = await this.#api(API_ROUTES.setupBegin, { password }); + this.#rpId = begin.rpId; + const registration = await this.#webauthn.registerPasskey( + begin.challenge, + begin.rpId, + begin.accountId, + ); + const finish = await this.#api(API_ROUTES.setupFinish, { + password, + credentialId: registration.credentialId, + publicKey: registration.publicKey, + clientDataJSON: registration.clientDataJSON, + label, + }); + // Stash the public key so this device can later build pairing/connect + // requests (the wire never hands it back on sign-in). + this.#storage.setPasskeyPublicKey(registration.credentialId, registration.publicKey); + this.#credentialId = registration.credentialId; + return finish; + } + + /** Sign in with a discoverable passkey; keeps the session token in memory. */ + async signin(): Promise { + const begin = await this.#api(API_ROUTES.signinBegin, {}); + this.#rpId = begin.rpId; + const assertion = await this.#webauthn.getAssertion(begin.challenge, begin.rpId); + const finish = await this.#api(API_ROUTES.signinFinish, { assertion }); + this.#sessionToken = finish.sessionToken; + this.#credentialId = assertion.credentialId; + return finish; + } + + async listHosts(): Promise { + const response = await this.#api( + API_ROUTES.hosts, + undefined, + { method: 'GET', headers: { authorization: `Bearer ${this.#requireToken()}` } }, + ); + return response.hosts; + } + + // --- Relay socket -------------------------------------------------------- + + /** True while a live relay socket exists; false after any close. */ + get socketOpen(): boolean { + return this.#ws !== null; + } + + /** Open the `/ws/client` relay socket; resolves once it is open. */ + openSocket(): Promise { + const token = this.#requireToken(); + const url = `${this.#wsBase}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=${encodeURIComponent(token)}`; + const ws = this.#createWebSocket(url); + this.#ws = ws; + ws.addEventListener('message', (ev) => this.#onFrame((ev as { data?: unknown }).data)); + ws.addEventListener('close', () => this.#onClose()); + return new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', () => reject(new Error('relay socket error'))); + ws.addEventListener('close', () => reject(new Error('relay socket closed before open'))); + }); + } + + // --- Pairing + connect handshake ----------------------------------------- + + /** Send a pairing request built from this device's key + passkey; awaits the Host's decision. */ + async pair(hostId: string, label: string): Promise { + const { credentialId, publicKey } = this.#passkeyForRequest(); + const device = await this.#getDeviceKey(); + const request: PairingRequest = { + accountId: SELFHOST_ACCOUNT_ID, + passkeyCredentialId: credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(publicKey), + devicePublicKey: device.devicePublicKey, + requestedLabel: label, + }; + const awaited = this.#expect('pair-result'); + this.#send({ t: 'pair', hostId, request }); + const frame = (await awaited) as Extract; + if (frame.approved) this.#storage.markPaired(hostId); + return { approved: frame.approved, record: frame.record, error: frame.error }; + } + + /** + * Connect to a paired Host: request a challenge, then produce ONE passkey + * assertion + one device-key signature over it (one biometric prompt), send + * `connect2`, and await the Host's final decision. + */ + async connect(hostId: string): Promise { + const device = await this.#getDeviceKey(); + const challengeAwaited = this.#expect('challenge'); + this.#send({ t: 'connect', hostId }); + const challengeFrame = (await challengeAwaited) as Extract< + ServerToClientFrame, + { t: 'challenge' } + >; + const challenge = challengeFrame.challenge; + + // Scope the assertion to credentials this device has a stored public key for. + // With several synced passkeys for one rpId, an empty allowCredentials lets + // the OS pick a credential whose public key we never stored — an unverifiable + // dead end below. An empty list here (first-time flows) preserves discovery. + const assertion = await this.#webauthn.getAssertion( + challenge, + this.#requireRpId(), + this.#storage.knownCredentialIds(), + ); + const deviceSignature = await signDeviceChallenge(device.privateKey, { + hostId, + challenge, + devicePublicKey: device.devicePublicKey, + }); + const publicKey = this.#storage.getPasskeyPublicKey(assertion.credentialId); + if (!publicKey) throw new Error(PASSKEY_UNAVAILABLE_MESSAGE); + + const request: ConnectionRequest = { + accountId: SELFHOST_ACCOUNT_ID, + devicePublicKey: device.devicePublicKey, + challenge, + deviceSignature, + passkey: { publicKey, assertion }, + }; + const decisionAwaited = this.#expect('decision'); + this.#send({ t: 'connect2', hostId, request }); + const decisionFrame = (await decisionAwaited) as Extract< + ServerToClientFrame, + { t: 'decision' } + >; + const pairingStale = + !decisionFrame.allowed && hasRecoverablePairingFailure(decisionFrame.failures); + if (decisionFrame.allowed) { + this.#connectedHostId = hostId; + } else if (pairingStale) { + this.#storage.unmarkPaired(hostId); + } + return { + allowed: decisionFrame.allowed, + failures: decisionFrame.failures, + ...(pairingStale ? { pairingStale: true } : {}), + }; + } + + // --- Remote-api v1 ------------------------------------------------------- + + hello(): Promise { + return this.request(REMOTE_METHODS.hello, { protocolVersion: 1, viewer: 'phone' }); + } + + /** Subscribe to the directory; returns the `subId` (call {@link unsubscribe} to stop). */ + async watchDirectory(onSnapshot: (entries: DirectoryEntry[]) => void): Promise { + const { subId } = await this.subscribe(REMOTE_METHODS.directoryWatch, {}, (event) => { + if (event.event === REMOTE_EVENTS.directorySnapshot) { + onSnapshot((event.data as DirectorySnapshot).entries); + } + }); + return subId; + } + + /** Attach to a terminal surface with the client's size; streams via {@link TerminalHandlers}. */ + attach( + surfaceId: string, + cols: number, + rows: number, + handlers: TerminalHandlers, + ): Promise<{ subId: string; result: TerminalAttachResult }> { + return this.subscribe( + REMOTE_METHODS.surfaceAttach, + { surfaceId, cols, rows }, + (event) => { + switch (event.event) { + case REMOTE_EVENTS.terminalData: + handlers.onData((event.data as TerminalDataEvent).bytes); + return; + case REMOTE_EVENTS.terminalResize: { + const data = event.data as TerminalResizeEvent; + handlers.onResize?.(data.cols, data.rows); + return; + } + case REMOTE_EVENTS.terminalClosed: + handlers.onClosed?.((event.data as TerminalClosedEvent).exitCode); + return; + default: + return; + } + }, + ); + } + + write(surfaceId: string, bytes: string): Promise { + return this.request(REMOTE_METHODS.terminalWrite, { surfaceId, bytes }); + } + + resize(surfaceId: string, cols: number, rows: number): Promise { + return this.request(REMOTE_METHODS.terminalResize, { surfaceId, cols, rows }); + } + + detach(surfaceId: string, subId?: string): Promise { + if (subId) this.unsubscribe(subId); + return this.request(REMOTE_METHODS.surfaceDetach, { surfaceId }); + } + + /** Correlated request over a `msg` frame; resolves with `result` or rejects on `ok:false`. */ + request(method: string, params?: unknown, requestId: string = uuid()): Promise { + const promise = new Promise((resolve, reject) => { + this.#pending.set(requestId, { resolve: resolve as (r: unknown) => void, reject }); + }); + this.#send({ t: 'msg', data: { requestId, method, params } }); + return promise; + } + + /** Request that also opens an event subscription (Host reuses `requestId` as `subId`). */ + async subscribe( + method: string, + params: unknown, + onEvent: (event: RemoteEventMsg) => void, + ): Promise<{ subId: string; result: T }> { + const subId = uuid(); + this.#events.set(subId, onEvent); + try { + const result = await this.request(method, params, subId); + return { subId, result }; + } catch (error) { + this.#events.delete(subId); + throw error; + } + } + + unsubscribe(subId: string): void { + this.#events.delete(subId); + } + + close(): void { + const ws = this.#ws; + // Tear down BEFORE closing the socket: #onClose reads `#ws === null` as an + // intentional close (no host-gone), and while real sockets emit their close + // event asynchronously, test fakes may emit it synchronously from close(). + this.#teardown('relay socket closed', { notifyGone: false }); + try { + ws?.close(); + } catch { + // already closing + } + } + + // --- Internals ----------------------------------------------------------- + + async #getDeviceKey(): Promise { + if (!this.#deviceKey) this.#deviceKey = await this.#deviceKeyFactory(); + return this.#deviceKey; + } + + #passkeyForRequest(): { credentialId: string; publicKey: string } { + const credentialId = this.#credentialId; + if (!credentialId) throw new Error('sign in before pairing or connecting'); + const publicKey = this.#storage.getPasskeyPublicKey(credentialId); + if (!publicKey) throw new Error(PASSKEY_UNAVAILABLE_MESSAGE); + return { credentialId, publicKey }; + } + + #send(frame: ClientFrame): void { + if (!this.#ws) throw new Error('relay socket is not open'); + this.#ws.send(JSON.stringify(frame)); + } + + #expect(type: 'pair-result' | 'challenge' | 'decision'): Promise { + if (this.#waiters.has(type)) throw new Error(`already awaiting a '${type}' frame`); + return new Promise((resolve, reject) => { + this.#waiters.set(type, { resolve, reject }); + }); + } + + #onFrame(raw: unknown): void { + let frame: ServerToClientFrame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : '') as ServerToClientFrame; + } catch { + return; + } + if (!frame || typeof (frame as { t?: unknown }).t !== 'string') return; + switch (frame.t) { + case 'pair-result': + case 'challenge': + case 'decision': { + const waiter = this.#waiters.get(frame.t); + if (waiter) { + this.#waiters.delete(frame.t); + waiter.resolve(frame); + } + return; + } + case 'msg': + this.#onMsg(frame.data); + return; + case 'host-gone': + this.#connectedHostId = null; + this.#onHostGone?.(); + this.#rejectAll(new Error('host disconnected')); + return; + case 'error': + this.#rejectAll(new Error(frame.error)); + return; + default: + return; + } + } + + #onMsg(data: unknown): void { + const response = data as RemoteResponse; + if (response && typeof response.requestId === 'string') { + const pending = this.#pending.get(response.requestId); + if (!pending) return; + this.#pending.delete(response.requestId); + if (response.ok) pending.resolve(response.result); + else pending.reject(new Error(response.error ?? 'request failed')); + return; + } + const event = data as RemoteEventMsg; + if (event && typeof event.subId === 'string') { + this.#events.get(event.subId)?.(event); + } + } + + #onClose(): void { + // `close()` tears down and nulls #ws before the event fires, so a non-null + // #ws here means the socket died on us (server restart, network drop) rather + // than an intentional close. An unexpected drop of an established session is + // still host loss — the app must leave the wall instead of idling on a dead + // stream — even without a `host-gone` frame. + const unexpected = this.#ws !== null; + const hadSession = this.#connectedHostId !== null; + this.#teardown('relay socket closed', { notifyGone: unexpected && hadSession }); + } + + /** + * Reset all socket-bound state and fail pending work. The one real difference + * between an intentional {@link close} and an unexpected drop is whether to + * fire `onHostGone`, made explicit here via `notifyGone`. + */ + #teardown(reason: string, { notifyGone }: { notifyGone: boolean }): void { + this.#ws = null; // never reuse a closed socket; openSocket() makes a fresh one + this.#connectedHostId = null; + this.#rejectAll(new Error(reason)); + if (notifyGone) this.#onHostGone?.(); + } + + /** Fail every awaited handshake frame and in-flight request (avoids hangs). */ + #rejectAll(error: Error): void { + for (const waiter of this.#waiters.values()) waiter.reject(error); + this.#waiters.clear(); + for (const pending of this.#pending.values()) pending.reject(error); + this.#pending.clear(); + } + + async #api(route: string, body?: unknown, init?: RequestInit): Promise { + const method = init?.method ?? 'POST'; + const response = await this.#fetch(`${this.#baseUrl}${route}`, { + method, + headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, + ...(method === 'GET' ? {} : { body: JSON.stringify(body ?? {}) }), + }); + const parsed = (await response.json().catch(() => ({}))) as T & { error?: string }; + if (!response.ok) throw new Error(parsed.error ?? `request failed (${response.status})`); + return parsed; + } + + #requireToken(): string { + if (!this.#sessionToken) throw new Error('sign in first'); + return this.#sessionToken; + } + + #requireRpId(): string { + if (!this.#rpId) throw new Error('rpId unknown — begin a sign-in or setup first'); + return this.#rpId; + } +} + +export const PASSKEY_UNAVAILABLE_MESSAGE = + "This device does not hold the passkey's public key, so it cannot pair or connect. " + + 'Pair from the device that first created the passkey (POC limitation).'; + +const RECOVERABLE_PAIRING_FAILURES = new Set([ + 'passkey-not-paired', + 'device-not-paired', + 'pairing-mismatch', +]); + +export function hasRecoverablePairingFailure( + failures: readonly ConnectionFailure[] | undefined, +): boolean { + return failures?.some((failure) => RECOVERABLE_PAIRING_FAILURES.has(failure)) ?? false; +} + +function uuid(): string { + return globalThis.crypto.randomUUID(); +} + +/** localStorage-backed {@link PocketStorage}; touches storage only when called. */ +export function localStoragePocketStorage(): PocketStorage { + const PASSKEY_PREFIX = 'dormouse-pocket:passkey:'; + const PAIRED_PREFIX = 'dormouse-pocket:paired:'; + return { + getPasskeyPublicKey: (credentialId) => + globalThis.localStorage.getItem(PASSKEY_PREFIX + credentialId), + setPasskeyPublicKey: (credentialId, publicKey) => + globalThis.localStorage.setItem(PASSKEY_PREFIX + credentialId, publicKey), + knownCredentialIds: () => { + const store = globalThis.localStorage; + const ids: string[] = []; + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (key?.startsWith(PASSKEY_PREFIX)) ids.push(key.slice(PASSKEY_PREFIX.length)); + } + return ids; + }, + isPaired: (hostId) => globalThis.localStorage.getItem(PAIRED_PREFIX + hostId) === '1', + markPaired: (hostId) => globalThis.localStorage.setItem(PAIRED_PREFIX + hostId, '1'), + unmarkPaired: (hostId) => globalThis.localStorage.removeItem(PAIRED_PREFIX + hostId), + }; +} diff --git a/lib/src/remote/client/remote-adapter.test.ts b/lib/src/remote/client/remote-adapter.test.ts new file mode 100644 index 00000000..f74b45bc --- /dev/null +++ b/lib/src/remote/client/remote-adapter.test.ts @@ -0,0 +1,287 @@ +/** + * `RemotePtyAdapter` against a network-free fake {@link RemoteAdapterClient}: + * directory snapshots become `onPtyList` + `getDirectoryEntries`, `setActivePane` + * drives the one-attachment-per-session detach→attach dance, `terminal.data` + * round-trips to `onPtyData`, write/resize reach only the attached pane, + * `terminal.closed` fires `onPtyExit`, and `dispose` cleans up. + */ + +import { describe, expect, it } from 'vitest'; +import { toBase64Url, utf8Encode, type DirectoryEntry } from 'server-lib-common'; + +import { RemotePtyAdapter, type RemoteAdapterClient } from './remote-adapter'; +import type { TerminalHandlers } from './pocket-client'; +import type { PtyInfo } from '../../lib/platform/types'; + +interface AttachCall { + surfaceId: string; + cols: number; + rows: number; + handlers: TerminalHandlers; + subId: string; +} + +class FakeClient implements RemoteAdapterClient { + snapshotListener: ((entries: DirectoryEntry[]) => void) | null = null; + readonly directorySubId = 'dir-sub'; + readonly attaches: AttachCall[] = []; + readonly writes: Array<{ surfaceId: string; bytes: string }> = []; + readonly resizes: Array<{ surfaceId: string; cols: number; rows: number }> = []; + readonly detaches: Array<{ surfaceId: string; subId?: string }> = []; + readonly unsubscribes: string[] = []; + #attachCounter = 0; + + async watchDirectory(onSnapshot: (entries: DirectoryEntry[]) => void): Promise { + this.snapshotListener = onSnapshot; + return this.directorySubId; + } + + async attach( + surfaceId: string, + cols: number, + rows: number, + handlers: TerminalHandlers, + ): Promise<{ subId: string; result: { cols: number; rows: number } }> { + const subId = `attach-${++this.#attachCounter}`; + this.attaches.push({ surfaceId, cols, rows, handlers, subId }); + return { subId, result: { cols, rows } }; + } + + async write(surfaceId: string, bytes: string): Promise { + this.writes.push({ surfaceId, bytes }); + } + + async resize(surfaceId: string, cols: number, rows: number): Promise { + this.resizes.push({ surfaceId, cols, rows }); + } + + async detach(surfaceId: string, subId?: string): Promise { + this.detaches.push({ surfaceId, subId }); + } + + unsubscribe(subId: string): void { + this.unsubscribes.push(subId); + } + + // --- test drivers --- + pushSnapshot(entries: DirectoryEntry[]): void { + this.snapshotListener?.(entries); + } + + lastAttach(): AttachCall { + const call = this.attaches.at(-1); + if (!call) throw new Error('no attach recorded'); + return call; + } +} + +function entry(surfaceId: string, over: Partial = {}): DirectoryEntry { + return { + paneRef: surfaceId, + surfaceId, + type: 'terminal', + title: surfaceId, + focused: false, + alive: true, + ringing: false, + hasTODO: false, + ...over, + }; +} + +describe('RemotePtyAdapter directory', () => { + it('turns a snapshot into onPtyList without treating command exitCode as PTY death', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const lists: PtyInfo[][] = []; + adapter.onPtyList(({ ptys }) => lists.push(ptys)); + + await adapter.init(); + client.pushSnapshot([entry('s1', { title: 'zsh' }), entry('s2', { exitCode: 0 })]); + + expect(lists).toEqual([ + [ + { id: 's1', alive: true }, + { id: 's2', alive: true }, + ], + ]); + expect(adapter.getDirectoryEntries().map((e) => e.surfaceId)).toEqual(['s1', 's2']); + expect(adapter.getDirectoryEntries()[0].title).toBe('zsh'); + expect(adapter.getPaneEntry('s2')?.exitCode).toBe(0); + }); + + it('carries the entry alive bit into onPtyList (a dead pane is not attachable)', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const lists: PtyInfo[][] = []; + adapter.onPtyList(({ ptys }) => lists.push(ptys)); + + await adapter.init(); + // s2's PTY has exited (lingering surface) → alive:false, even with exitCode 0. + client.pushSnapshot([ + entry('s1', { alive: true }), + entry('s2', { alive: false, exitCode: 0 }), + ]); + + expect(lists).toEqual([ + [ + { id: 's1', alive: true }, + { id: 's2', alive: false }, + ], + ]); + }); + + it('notifies subscribeDirectory listeners until they unsubscribe', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + await adapter.init(); + + const seen: DirectoryEntry[][] = []; + const unsub = adapter.subscribeDirectory((entries) => seen.push(entries)); + client.pushSnapshot([entry('s1')]); + expect(seen).toHaveLength(1); + + unsub(); + client.pushSnapshot([entry('s1'), entry('s2')]); + expect(seen).toHaveLength(1); + }); + + it('requestInit re-emits the cached list without re-watching', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + await adapter.init(); + client.pushSnapshot([entry('s1')]); + + const lists: PtyInfo[][] = []; + adapter.onPtyList(({ ptys }) => lists.push(ptys)); + adapter.requestInit(); + + expect(lists).toEqual([[{ id: 's1', alive: true }]]); + expect(client.attaches).toHaveLength(0); + }); +}); + +describe('RemotePtyAdapter attach / active pane', () => { + it('attaches on setActivePane and detaches the previous when switching', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + + await adapter.setActivePane('s1', 80, 24); + expect(client.attaches).toHaveLength(1); + expect(client.attaches[0]).toMatchObject({ surfaceId: 's1', cols: 80, rows: 24 }); + expect(adapter.activeSurfaceId).toBe('s1'); + expect(client.detaches).toHaveLength(0); + + await adapter.setActivePane('s2', 100, 30); + expect(client.detaches).toEqual([{ surfaceId: 's1', subId: 'attach-1' }]); + expect(client.attaches).toHaveLength(2); + expect(client.attaches[1]).toMatchObject({ surfaceId: 's2', cols: 100, rows: 30 }); + expect(adapter.activeSurfaceId).toBe('s2'); + }); + + it('re-activating the same pane resizes rather than re-attaching', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + + await adapter.setActivePane('s1', 80, 24); + await adapter.setActivePane('s1', 120, 40); + + expect(client.attaches).toHaveLength(1); + expect(client.detaches).toHaveLength(0); + expect(client.resizes).toEqual([{ surfaceId: 's1', cols: 120, rows: 40 }]); + }); + + it('decodes terminal.data (base64url utf8) into an onPtyData string', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const data: Array<{ id: string; data: string }> = []; + adapter.onPtyData((d) => data.push(d)); + + await adapter.setActivePane('s1', 80, 24); + client.lastAttach().handlers.onData(toBase64Url(utf8Encode('héllo ▲'))); + + expect(data).toEqual([{ id: 's1', data: 'héllo ▲' }]); + }); + + it('routes write and resize only to the attached pane', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + await adapter.setActivePane('s1', 80, 24); + + adapter.writePty('s1', 'ls\r'); + adapter.writePty('s2', 'ignored'); // not attached → dropped + expect(client.writes).toEqual([{ surfaceId: 's1', bytes: toBase64Url(utf8Encode('ls\r')) }]); + + adapter.resizePty('s1', 90, 20); + adapter.resizePty('s2', 10, 10); // not attached → dropped + expect(client.resizes).toEqual([{ surfaceId: 's1', cols: 90, rows: 20 }]); + }); + + it('spawnPty / killPty are no-ops (panes are Host-owned)', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + adapter.spawnPty(); + adapter.killPty(); + expect(client.attaches).toHaveLength(0); + expect(client.detaches).toHaveLength(0); + }); + + it('terminal.closed fires onPtyExit and clears the attachment', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const exits: Array<{ id: string; exitCode: number }> = []; + adapter.onPtyExit((d) => exits.push(d)); + + await adapter.setActivePane('s1', 80, 24); + client.lastAttach().handlers.onClosed?.(3); + + expect(exits).toEqual([{ id: 's1', exitCode: 3 }]); + expect(adapter.activeSurfaceId).toBeNull(); + + // Once closed the pane is no longer attached, so writes are dropped. + adapter.writePty('s1', 'x'); + expect(client.writes).toHaveLength(0); + }); + + it('terminal.closed with an omitted exitCode surfaces the unknown-exit sentinel (-1), not 0', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const exits: Array<{ id: string; exitCode: number }> = []; + adapter.onPtyExit((d) => exits.push(d)); + + await adapter.setActivePane('s1', 80, 24); + // TerminalClosedEvent.exitCode is optional on the wire; a signal-only / + // killed / non-selfhost close forwards no code. It must not read as 0. + client.lastAttach().handlers.onClosed?.(undefined); + + expect(exits).toEqual([{ id: 's1', exitCode: -1 }]); + expect(adapter.activeSurfaceId).toBeNull(); + }); + + it('terminal.closed with a present exitCode passes it through unchanged (incl. 0)', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + const exits: Array<{ id: string; exitCode: number }> = []; + adapter.onPtyExit((d) => exits.push(d)); + + await adapter.setActivePane('s1', 80, 24); + client.lastAttach().handlers.onClosed?.(0); + + expect(exits).toEqual([{ id: 's1', exitCode: 0 }]); + }); +}); + +describe('RemotePtyAdapter dispose', () => { + it('detaches the live surface and unsubscribes the directory', async () => { + const client = new FakeClient(); + const adapter = new RemotePtyAdapter(client); + await adapter.init(); + await adapter.setActivePane('s1', 80, 24); + + await adapter.dispose(); + + expect(client.unsubscribes).toContain('dir-sub'); + expect(client.detaches).toContainEqual({ surfaceId: 's1', subId: 'attach-1' }); + expect(adapter.activeSurfaceId).toBeNull(); + }); +}); diff --git a/lib/src/remote/client/remote-adapter.ts b/lib/src/remote/client/remote-adapter.ts new file mode 100644 index 00000000..430b59c9 --- /dev/null +++ b/lib/src/remote/client/remote-adapter.ts @@ -0,0 +1,376 @@ +/** + * `RemotePtyAdapter` — a {@link PlatformAdapter} backed by a connected + * {@link PocketClient} session, so the exact mobile terminal UI the website + * proves out with `FakePtyAdapter` (`PocketTerminalExperience`) can render a + * real remote Host over the remote-api v1 wire (docs/specs/pocket-app.md). The + * adapter mapping table is the spec: + * + * onPtyList ← directory.snapshot (id = surfaceId) + * attach semantics ← surface.attach (one attachment per session) + * onPtyData ← terminal.data (base64url utf8 → string) + * writePty → terminal.write (string → base64url utf8) + * resizePty → terminal.resize + * onPtyExit ← terminal.closed + * + * Everything outside that PTY core no-ops or is absent — the interface is built + * for capability degradation (getCwd/getScrollback → null, getOpenPorts → [], + * shells/clipboard empty, alerts no-op; alert/TODO/ringing badges instead ride + * the directory snapshot and are read via {@link getDirectoryEntries}). + * + * ── What phase 1b needs to know about terminal-registry (terminal-lifecycle.ts) + * + * The registry binds a pane purely by string id: `getOrCreateTerminal(id)` + * creates an xterm, registers `onPtyData`/`onPtyExit` handlers that filter on + * `detail.id === id`, and writes matching data straight into that xterm. So the + * ONLY contract this adapter must honor for the data pump is: emit + * `onPtyData({ id: surfaceId, data })` / `onPtyExit({ id: surfaceId, ... })` and + * mount each pane's xterm under a session id equal to its `surfaceId`. + * + * `getOrCreateTerminal` also calls `spawnPty(id, {cols,rows})` and, on xterm + * fit/resize, `resizePty(id, cols, rows)`, and on keystrokes `writePty(id, ..)`. + * `spawnPty` being a no-op here does NOT break session creation — the registry + * never waits on a spawn ack; it just wires listeners and calls spawn for the + * local-PTY adapters. (FakePtyAdapter's `spawnPty` fires an `onPtySpawn` extra + * the playground's shell registry listens to; there is no such shell registry + * on the remote side — the Host owns the shell — so we emit nothing on spawn.) + * + * The catch phase 1b must handle: nothing is streaming until the pane is + * ATTACHED. v1 allows one attachment per session, so the UI must call + * {@link setActivePane}(surfaceId, cols, rows) whenever the active pane changes + * (detach-old → attach-new). Until then `writePty`/`resizePty` for a + * non-attached pane are dropped (the Host would reject them anyway), and the + * attach repaint — not a snapshot transfer — is what fills the client screen. + * The registry's own `onResize → resizePty` path keeps the attached pane sized; + * `setActivePane` seeds the first size. Host-initiated `terminal.resize` events + * are ignored (the PlatformAdapter interface has no inbound-resize channel). + */ + +import { + clampTerminalDimension, + fromBase64Url, + toBase64Url, + utf8Decode, + utf8Encode, + type DirectoryEntry, + type TerminalAttachResult, +} from 'server-lib-common'; +import type { PlatformAdapter, PtyInfo, OpenPort } from '../../lib/platform/types'; +import type { TerminalHandlers } from './pocket-client'; + +/** + * The slice of {@link PocketClient} the adapter drives. A connected + * `PocketClient` satisfies it structurally; tests pass a network-free fake. + */ +export interface RemoteAdapterClient { + watchDirectory(onSnapshot: (entries: DirectoryEntry[]) => void): Promise; + attach( + surfaceId: string, + cols: number, + rows: number, + handlers: TerminalHandlers, + ): Promise<{ subId: string; result: TerminalAttachResult }>; + write(surfaceId: string, bytes: string): Promise; + resize(surfaceId: string, cols: number, rows: number): Promise; + detach(surfaceId: string, subId?: string): Promise; + unsubscribe(subId: string): void; +} + +interface Attachment { + surfaceId: string; + subId: string; +} + +interface Size { + cols: number; + rows: number; +} + +const DEFAULT_SIZE: Size = { cols: 80, rows: 24 }; + +type DataHandler = (detail: { id: string; data: string }) => void; +type ExitHandler = (detail: { id: string; exitCode: number }) => void; +type ListHandler = (detail: { ptys: PtyInfo[] }) => void; +type DirectoryListener = (entries: DirectoryEntry[]) => void; + +export class RemotePtyAdapter implements PlatformAdapter { + readonly #client: RemoteAdapterClient; + + readonly #dataHandlers = new Set(); + readonly #exitHandlers = new Set(); + readonly #listHandlers = new Set(); + readonly #directoryListeners = new Set(); + + /** Latest directory snapshot, in Host order. */ + #entries: DirectoryEntry[] = []; + + /** Memoized directory.watch start; also the "started" guard. */ + #watchPromise: Promise | null = null; + #directorySubId: string | null = null; + + /** The one attached surface (v1: one attachment per session), or null. */ + #attached: Attachment | null = null; + /** Bumped on every setActivePane so a superseded async attach can bail. */ + #activeGeneration = 0; + /** Last size seen, so a re-attach can reuse it if the caller omits one. */ + #lastSize: Size = DEFAULT_SIZE; + + #savedState: unknown = null; + + constructor(client: RemoteAdapterClient) { + this.#client = client; + } + + // --- Lifecycle ----------------------------------------------------------- + + async init(): Promise { + await this.#ensureDirectoryWatch(); + } + + shutdown(): void { + void this.dispose(); + } + + /** Detach the live surface and stop watching the directory. */ + async dispose(): Promise { + const attached = this.#attached; + this.#attached = null; + this.#activeGeneration++; + if (this.#directorySubId) { + this.#client.unsubscribe(this.#directorySubId); + this.#directorySubId = null; + } + this.#directoryListeners.clear(); + if (attached) { + try { + await this.#client.detach(attached.surfaceId, attached.subId); + } catch { + // best effort — the socket may already be gone + } + } + } + + // --- Directory (onPtyList + adapter-specific getters) -------------------- + + requestInit(): void { + void this.#ensureDirectoryWatch(); + // Give a resuming UI the latest known list immediately. + if (this.#entries.length > 0) this.#emitPtyList(); + } + + onPtyList(handler: ListHandler): void { + this.#listHandlers.add(handler); + } + + offPtyList(handler: ListHandler): void { + this.#listHandlers.delete(handler); + } + + /** The full directory snapshot (titles/activity/ringing/hasTODO) without attaching. */ + getDirectoryEntries(): DirectoryEntry[] { + return [...this.#entries]; + } + + /** The directory entry for a surface, or undefined. */ + getPaneEntry(surfaceId: string): DirectoryEntry | undefined { + return this.#entries.find((entry) => entry.surfaceId === surfaceId); + } + + /** Subscribe to directory snapshots; returns an unsubscribe fn. */ + subscribeDirectory(listener: DirectoryListener): () => void { + this.#directoryListeners.add(listener); + return () => { + this.#directoryListeners.delete(listener); + }; + } + + #ensureDirectoryWatch(): Promise { + if (!this.#watchPromise) { + this.#watchPromise = this.#client + .watchDirectory((entries) => this.#onSnapshot(entries)) + .then((subId) => { + this.#directorySubId = subId; + }); + } + return this.#watchPromise; + } + + #onSnapshot(entries: DirectoryEntry[]): void { + this.#entries = entries; + this.#emitPtyList(); + for (const listener of this.#directoryListeners) listener(entries); + } + + #emitPtyList(): void { + const ptys: PtyInfo[] = this.#entries.map((entry) => ({ + id: entry.surfaceId, + // `entry.alive` is real PTY-process liveness (a lingering exited surface + // reports false), NOT `entry.exitCode` — that is the last command's + // shell-integration status, which deriving alive from was the bug. + alive: entry.alive, + })); + for (const handler of this.#listHandlers) handler({ ptys }); + } + + // --- Attach / active pane (adapter-specific extra) ----------------------- + + /** + * Make `id` the single attached surface: detach the previous one, then + * `surface.attach` with `cols`/`rows`. Its `terminal.data` becomes + * `onPtyData`, `terminal.closed` becomes `onPtyExit`. + */ + async setActivePane(id: string, cols?: number, rows?: number): Promise { + const size = normalizeSize(cols, rows, this.#lastSize); + this.#lastSize = size; + + if (this.#attached?.surfaceId === id) { + // Already the active surface — a size change is just a resize. + void this.#client.resize(id, size.cols, size.rows); + return; + } + + const generation = ++this.#activeGeneration; + const prev = this.#attached; + this.#attached = null; + if (prev) { + try { + await this.#client.detach(prev.surfaceId, prev.subId); + } catch { + // best effort + } + } + if (generation !== this.#activeGeneration) return; // superseded mid-detach + + const handlers: TerminalHandlers = { + onData: (bytes) => this.#emitData(id, bytes), + onClosed: (exitCode) => this.#emitExit(id, exitCode), + }; + const { subId } = await this.#client.attach(id, size.cols, size.rows, handlers); + if (generation !== this.#activeGeneration) { + // A newer setActivePane won the race — undo this stale attach. + this.#client.unsubscribe(subId); + void this.#client.detach(id, subId).catch(() => {}); + return; + } + this.#attached = { surfaceId: id, subId }; + } + + /** The currently attached surfaceId, or null. */ + get activeSurfaceId(): string | null { + return this.#attached?.surfaceId ?? null; + } + + // --- PTY core ------------------------------------------------------------ + + writePty(id: string, data: string): void { + if (this.#attached?.surfaceId !== id) return; // Host only accepts the attached pane + void this.#client.write(id, toBase64Url(utf8Encode(data))); + } + + resizePty(id: string, cols: number, rows: number): void { + if (this.#attached?.surfaceId !== id) return; + this.#lastSize = { cols, rows }; + void this.#client.resize(id, cols, rows); + } + + // Panes are Host-owned: the phone never spawns or kills them. + spawnPty(): void {} + killPty(): void {} + + onPtyData(handler: DataHandler): void { + this.#dataHandlers.add(handler); + } + + offPtyData(handler: DataHandler): void { + this.#dataHandlers.delete(handler); + } + + onPtyExit(handler: ExitHandler): void { + this.#exitHandlers.add(handler); + } + + offPtyExit(handler: ExitHandler): void { + this.#exitHandlers.delete(handler); + } + + #emitData(id: string, bytes: string): void { + const data = utf8Decode(fromBase64Url(bytes)); + for (const handler of this.#dataHandlers) handler({ id, data }); + } + + #emitExit(id: string, exitCode?: number): void { + if (this.#attached?.surfaceId === id) this.#attached = null; + // An absent exitCode is an UNKNOWN termination (signal-only, killed, or a + // non-selfhost Host that never reports one) — not a clean exit. Coercing it + // to 0 would paint an abnormal close as success. Map it to the same -1 + // sentinel the local path uses (terminal-lifecycle.ts `exitCode ?? -1`), + // which renders as a nonzero "failure" exit, so remote and local agree. + for (const handler of this.#exitHandlers) handler({ id, exitCode: exitCode ?? -1 }); + } + + // --- Degraded capabilities (absent PTY features) ------------------------- + + async getAvailableShells(): Promise<{ name: string; path: string; args?: string[] }[]> { + return []; + } + + async getCwd(): Promise { + return null; + } + + async getScrollback(): Promise { + return null; + } + + async getOpenPorts(): Promise { + return []; + } + + async readClipboardFilePaths(): Promise { + return null; + } + + async readClipboardImageAsFilePath(): Promise { + return null; + } + + // Resume-path replay: the Host has no per-pane replay buffer in v1, so ignore. + onPtyReplay(): void {} + offPtyReplay(): void {} + + // Host-initiated persistence flush: not driven from the phone. + onRequestSessionFlush(): void {} + offRequestSessionFlush(): void {} + notifySessionFlushComplete(): void {} + + // Alerts are Host-authoritative (surfaced via the directory snapshot), so the + // phone-side alert controls are inert. + alertRemove(): void {} + alertToggle(): void {} + alertDisable(): void {} + alertDismiss(): void {} + alertDismissOrToggle(): void {} + alertAttend(): void {} + alertResize(): void {} + alertClearAttention(): void {} + alertToggleTodo(): void {} + alertMarkTodo(): void {} + alertClearTodo(): void {} + onAlertState(): void {} + offAlertState(): void {} + + saveState(state: unknown): void { + this.#savedState = state; + } + + getState(): unknown { + return this.#savedState; + } +} + +/** Coerce a requested size to positive integers, falling back to `fallback`. */ +function normalizeSize(cols: number | undefined, rows: number | undefined, fallback: Size): Size { + return { + cols: clampTerminalDimension(cols, fallback.cols), + rows: clampTerminalDimension(rows, fallback.rows), + }; +} diff --git a/lib/src/remote/client/webauthn.ts b/lib/src/remote/client/webauthn.ts new file mode 100644 index 00000000..7dc8c3c0 --- /dev/null +++ b/lib/src/remote/client/webauthn.ts @@ -0,0 +1,126 @@ +/** + * Thin wrappers around `navigator.credentials` for the Pocket client, isolated + * here so the protocol client (`pocket-client.ts`) can be driven with a fake in + * vitest — the real thing needs a browser + a physical authenticator. + * + * Registration returns exactly what `POST /api/setup/finish` wants + * (`{ credentialId, publicKey, clientDataJSON }`, all base64url); assertions + * return the wire {@link PasskeyAssertion} shape the server and Host both + * verify with `verifyPasskeyAssertion`. + */ + +import { fromBase64Url, toBase64Url, utf8Encode, type PasskeyAssertion } from 'server-lib-common'; + +/** The result of a passkey registration, ready for `POST /api/setup/finish`. */ +export interface PasskeyRegistration { + /** `PublicKeyCredential.id` — already base64url. */ + readonly credentialId: string; + /** Base64url SPKI from `response.getPublicKey()`. */ + readonly publicKey: string; + /** Base64url `response.clientDataJSON` (type `webauthn.create`). */ + readonly clientDataJSON: string; +} + +/** The two authenticator operations the Pocket client needs; faked in tests. */ +export interface WebAuthnClient { + registerPasskey(challenge: string, rpId: string, accountId: string): Promise; + /** + * Get an assertion bound to `challenge`. Pass `allowCredentials` (base64url + * credential ids) to scope selection to specific passkeys; leave it empty to + * discover any of the account's resident credentials. + */ + getAssertion( + challenge: string, + rpId: string, + allowCredentials?: readonly string[], + ): Promise; +} + +/** + * Copy into a fresh `ArrayBuffer`-backed view. WebAuthn's `BufferSource` + * parameters demand `ArrayBuffer` (not `SharedArrayBuffer`), which the generic + * `Uint8Array` from the byte helpers does not satisfy under recent TS libs. + */ +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +/** + * Create a discoverable ES256 passkey. `attestation: 'none'` keeps the server + * dependency-free (it trusts the browser-provided SPKI key). `residentKey` + * is `'required'`: sign-in discovers credentials with an empty + * `allowCredentials`, so a non-resident credential (which `'preferred'` can + * silently produce) would register fine and then never be able to sign in — + * better to fail here, where re-running setup is a one-tap recovery. + */ +async function registerPasskey( + challenge: string, + rpId: string, + accountId: string, +): Promise { + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: toBufferSource(fromBase64Url(challenge)), + rp: { id: rpId, name: 'Dormouse' }, + user: { + id: toBufferSource(utf8Encode(accountId)), + name: accountId, + displayName: accountId, + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { + residentKey: 'required', + // WebAuthn L1 authenticators ignore `residentKey`; this is its spelling. + requireResidentKey: true, + userVerification: 'preferred', + }, + attestation: 'none', + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error('passkey creation was cancelled'); + const response = credential.response as AuthenticatorAttestationResponse; + const spki = response.getPublicKey(); + if (!spki) throw new Error('authenticator did not return a public key (ES256 required)'); + return { + credentialId: credential.id, + publicKey: toBase64Url(new Uint8Array(spki)), + clientDataJSON: toBase64Url(new Uint8Array(response.clientDataJSON)), + }; +} + +/** + * Get an assertion bound to `challenge`. Sign-in leaves `allowCredentials` empty + * to discover any of the account's resident passkeys; connect passes the stored + * credential id(s) so the authenticator selects a passkey this device can verify + * (with several synced passkeys for one rpId, an empty list lets the OS pick one + * whose public key we never stored). One call feeds both handshakes, so the user + * sees a single biometric prompt. + */ +async function getAssertion( + challenge: string, + rpId: string, + allowCredentials: readonly string[] = [], +): Promise { + const credential = (await navigator.credentials.get({ + publicKey: { + challenge: toBufferSource(fromBase64Url(challenge)), + rpId, + allowCredentials: allowCredentials.map((id) => ({ + type: 'public-key', + id: toBufferSource(fromBase64Url(id)), + })), + userVerification: 'preferred', + }, + })) as PublicKeyCredential | null; + if (!credential) throw new Error('passkey assertion was cancelled'); + const response = credential.response as AuthenticatorAssertionResponse; + return { + credentialId: credential.id, + clientDataJSON: toBase64Url(new Uint8Array(response.clientDataJSON)), + authenticatorData: toBase64Url(new Uint8Array(response.authenticatorData)), + signature: toBase64Url(new Uint8Array(response.signature)), + }; +} + +/** The real, browser-backed implementation. */ +export const browserWebAuthn: WebAuthnClient = { registerPasskey, getAssertion }; diff --git a/lib/src/remote/host/RemotePairingModal.tsx b/lib/src/remote/host/RemotePairingModal.tsx new file mode 100644 index 00000000..ab3cdfc8 --- /dev/null +++ b/lib/src/remote/host/RemotePairingModal.tsx @@ -0,0 +1,67 @@ +import { useRef } from 'react'; +import { ModalFrame, ModalReviewBlock, modalActionButton } from '../../components/design'; +import type { PairingRequest } from 'server-lib-common'; + +/** + * The Host's local pairing-approval modal (server.md → "Pairing approval + * modal"; same pattern as KillConfirm). Approving here is the only path that + * writes the ACL, so the dialog shows exactly who is asking: the requested + * label, the account, and a short fingerprint of the requesting browser's + * device key. + */ +export function RemotePairingModal({ + request, + onApprove, + onDeny, +}: { + request: PairingRequest; + onApprove: () => void; + onDeny: () => void; +}) { + const denyButtonRef = useRef(null); + const fingerprint = request.devicePublicKey.slice(0, 8); + + return ( + +

+ Pair a new device +

+

+ A device is requesting remote access to this terminal. +

+ + + Device + {request.requestedLabel || '(unnamed)'} + Account + {request.accountId} + Key + {fingerprint}… + + +
+ + +
+
+ ); +} diff --git a/lib/src/remote/host/RemotePairingModalHost.tsx b/lib/src/remote/host/RemotePairingModalHost.tsx new file mode 100644 index 00000000..c5bccd2e --- /dev/null +++ b/lib/src/remote/host/RemotePairingModalHost.tsx @@ -0,0 +1,42 @@ +import { useEffect, useSyncExternalStore } from 'react'; +import { RemotePairingModal } from './RemotePairingModal'; +import { + getPairingApprovalSnapshot, + subscribePairingApproval, +} from './pairing-approval'; +import { installRemoteHostConsoleHook } from './activation'; + +/** + * Renders the head of the pairing-approval queue and, on mount, activates the + * remote Host (from any persisted enrollment) and installs the console hook. + * Wired next to the other modal hosts in the wall — additive, and inert unless + * the user has enrolled a Host. + */ +export function RemotePairingModalHost({ + onKeyboardActiveChange, +}: { + onKeyboardActiveChange?: (active: boolean) => void; +}) { + const pending = useSyncExternalStore(subscribePairingApproval, getPairingApprovalSnapshot); + const head = pending[0] ?? null; + + useEffect(() => { + installRemoteHostConsoleHook(); + }, []); + + useEffect(() => { + onKeyboardActiveChange?.(head !== null); + return () => onKeyboardActiveChange?.(false); + }, [onKeyboardActiveChange, head]); + + if (!head) return null; + + return ( + head.approve()} + onDeny={() => head.deny()} + /> + ); +} diff --git a/lib/src/remote/host/acl.test.ts b/lib/src/remote/host/acl.test.ts new file mode 100644 index 00000000..0aa81f48 --- /dev/null +++ b/lib/src/remote/host/acl.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HostAcl } from 'server-lib-common'; +import { ACL_KEY_PREFIX, loadAclRecords, loadHostAcl, saveAclRecords } from './acl'; + +function stubLocalStorage(): Map { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + return store; +} + +function makeRecord(hostId: string) { + const acl = new HostAcl(hostId); + acl.approve({ + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + approvedBy: 'host-user', + label: 'iPhone Safari', + }); + return acl.records(); +} + +describe('remote-host acl persistence', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('round-trips records through localStorage', () => { + const store = stubLocalStorage(); + const records = makeRecord('host-1'); + saveAclRecords('host-1', records); + + expect(store.get(`${ACL_KEY_PREFIX}host-1`)).toBe(JSON.stringify(records)); + expect(loadAclRecords('host-1')).toEqual(records); + + const acl = loadHostAcl('host-1'); + const active = acl.activeRecords(); + expect(active).toHaveLength(1); + expect(active[0]?.label).toBe('iPhone Safari'); + expect(acl.hasActiveDevice('device-1')).toBe(true); + }); + + it('drops records belonging to a different host', () => { + stubLocalStorage(); + saveAclRecords('host-1', makeRecord('host-1')); + // A different host must not inherit host-1's ACL. + expect(loadAclRecords('host-2')).toEqual([]); + expect(loadHostAcl('host-2').activeRecords()).toEqual([]); + }); + + it('returns an empty ACL for malformed storage', () => { + const store = stubLocalStorage(); + store.set(`${ACL_KEY_PREFIX}host-1`, 'not json'); + expect(loadAclRecords('host-1')).toEqual([]); + expect(loadHostAcl('host-1').activeRecords()).toEqual([]); + }); + + it('treats a missing localStorage as an empty ACL', () => { + vi.stubGlobal('localStorage', undefined); + expect(loadAclRecords('host-1')).toEqual([]); + expect(() => saveAclRecords('host-1', [])).not.toThrow(); + }); +}); diff --git a/lib/src/remote/host/acl.ts b/lib/src/remote/host/acl.ts new file mode 100644 index 00000000..06e8d7f9 --- /dev/null +++ b/lib/src/remote/host/acl.ts @@ -0,0 +1,52 @@ +/** + * Host ACL persistence. The ACL is the authorization primitive (see + * `server-lib-common/security/acl.ts`) and — per the security model — it lives + * on the Host, never the Server. Here it is persisted to `localStorage` as the + * record array `HostAcl.records()` produces, restored via `HostAcl.fromRecords`. + * + * Keyed per host so a browser profile that re-enrolls under a new hostId does + * not inherit a stale ACL. + */ + +import { HostAcl, type HostAclRecord } from 'server-lib-common'; +import { loadJson, saveJson } from '../../lib/local-json-store'; + +export const ACL_KEY_PREFIX = 'dormouse.remote-host.acl.'; + +function aclKey(hostId: string): string { + return `${ACL_KEY_PREFIX}${hostId}`; +} + +/** Load the persisted records for a host, dropping anything malformed. */ +export function loadAclRecords(hostId: string): HostAclRecord[] { + // Missing key / malformed JSON / non-array all collapse to `[]`. + const parsed = loadJson(aclKey(hostId), [], Array.isArray); + // Only keep records for this host; fromRecords rejects a mismatched hostId. + return parsed.filter( + (record): record is HostAclRecord => + !!record && typeof record === 'object' && (record as HostAclRecord).hostId === hostId, + ); +} + +export function saveAclRecords(hostId: string, records: readonly HostAclRecord[]): void { + saveJson(aclKey(hostId), records); +} + +/** + * Rehydrate a live `HostAcl` from persisted records, falling back to an empty + * ACL if the stored records cannot be reconciled with `hostId`. `loadRecords` + * is injectable so callers (and tests) can supply their own source. + */ +export function loadHostAcl( + hostId: string, + loadRecords: (hostId: string) => HostAclRecord[] = loadAclRecords, +): HostAcl { + try { + return HostAcl.fromRecords(hostId, loadRecords(hostId)); + } catch (error) { + // Fail closed but loudly: an empty ACL silently de-pairs every client, so + // "all my devices vanished" must at least be explicable from the console. + console.warn(`remote-host: could not load ACL for ${hostId}; starting empty`, error); + return new HostAcl(hostId); + } +} diff --git a/lib/src/remote/host/activation.ts b/lib/src/remote/host/activation.ts new file mode 100644 index 00000000..ee875f57 --- /dev/null +++ b/lib/src/remote/host/activation.ts @@ -0,0 +1,87 @@ +/** + * Activation glue: starts a single {@link RemoteHost} from the persisted + * enrollment on app start, and exposes a `window.dormouseRemoteHost` console + * hook for enrolling in the POC (no settings UI needed). + * + * This is the one module that binds the DOM-free controller to the terminal + * bridge (`RemoteApiSession` touches xterm / the platform adapter), so only the + * running app imports it — the controller and its tests stay DOM-free. + * + * Enroll from the devtools console: + * + * await window.dormouseRemoteHost.enroll('https://your-server', 'SETUP_PASSWORD', 'My Laptop') + * window.dormouseRemoteHost.status() + * window.dormouseRemoteHost.clearEnrollment() + */ + +import { clearEnrollment, enrollHost, getEnrollment, type HostEnrollment } from './enrollment'; +import { RemoteApiSession } from './remote-api'; +import { RemoteHost } from './remote-host'; + +let current: RemoteHost | null = null; + +function startFromEnrollment(enrollment: HostEnrollment): RemoteHost { + const host = new RemoteHost({ + enrollment, + createSession: (opts) => + new RemoteApiSession({ + hostId: opts.hostId, + // The controller sends the untyped remote-api payload inside a `msg`. + send: opts.send, + }), + }); + host.start(); + return host; +} + +/** Start the Host if an enrollment exists and none is running. Idempotent. */ +export function activateRemoteHost(): void { + if (current) return; + const enrollment = getEnrollment(); + if (!enrollment) return; + current = startFromEnrollment(enrollment); +} + +export function stopRemoteHost(): void { + current?.stop(); + current = null; +} + +export interface RemoteHostConsoleStatus { + enrolled: boolean; + serverUrl: string | null; + hostId: string | null; + connection: string; + pairedClients: number; +} + +function remoteHostStatus(): RemoteHostConsoleStatus { + const enrollment = getEnrollment(); + return { + enrolled: !!enrollment, + serverUrl: enrollment?.serverUrl ?? null, + hostId: enrollment?.hostId ?? null, + connection: current?.status ?? 'stopped', + pairedClients: current?.activeRecords.length ?? 0, + }; +} + +/** Install the `window.dormouseRemoteHost` console hook and activate. Idempotent. */ +export function installRemoteHostConsoleHook(): void { + activateRemoteHost(); + const target = globalThis as unknown as { dormouseRemoteHost?: unknown }; + if (target.dormouseRemoteHost) return; + target.dormouseRemoteHost = { + async enroll(serverUrl: string, password: string, label: string) { + const enrollment = await enrollHost(serverUrl, password, label); + stopRemoteHost(); + current = startFromEnrollment(enrollment); + return { hostId: enrollment.hostId, serverUrl: enrollment.serverUrl }; + }, + status: remoteHostStatus, + clearEnrollment() { + stopRemoteHost(); + clearEnrollment(); + }, + }; +} diff --git a/lib/src/remote/host/directory-collect.ts b/lib/src/remote/host/directory-collect.ts new file mode 100644 index 00000000..7822a91a --- /dev/null +++ b/lib/src/remote/host/directory-collect.ts @@ -0,0 +1,70 @@ +/** + * The impure half of the directory: reads the live terminal registry, pane + * state store, and activity store to produce the `DirectoryEntry[]` the phone's + * picker renders. Every entry is a terminal pane (the POC is terminal-only); + * browser/iframe surfaces never enter the xterm registry, so iterating it lists + * exactly the terminal panes. + */ + +import type { DirectoryEntry } from 'server-lib-common'; +import { + buildAppTitleResolver, + deriveHeader, + getActivitySnapshot, + getTerminalPaneState, + getTerminalPaneStateSnapshot, + resolveDisplayPrimary, +} from '../../lib/terminal-registry'; +import { registry } from '../../lib/terminal-store'; +import { buildDirectorySnapshot, type DirectoryPaneInput } from './directory'; + +export function collectDirectorySnapshot(): DirectoryEntry[] { + const paneStates = getTerminalPaneStateSnapshot(); + const activityStates = getActivitySnapshot(); + const appTitleForPane = buildAppTitleResolver(paneStates, activityStates); + + const ids = [...registry.keys()]; + // Reuse these per-pane states in the map below rather than re-fetching (each + // miss would allocate a fresh default twice). + const allPanes = ids.map((id) => getTerminalPaneState(id)); + const active = typeof document !== 'undefined' ? document.activeElement : null; + + const inputs: DirectoryPaneInput[] = ids.map((id, i) => { + const pane = allPanes[i]!; + // Every registry id is present in the activity snapshot (a live pane always + // reads non-null), so this is the same object `getActivity(id)` would build. + const activity = activityStates.get(id); + const element = registry.get(id)?.element ?? null; + const focused = !!element && !!active && element.contains(active); + // The directory entry shows only the derived `primary`; it has no + // secondary/cwd-disambiguation field. `deriveHeader`'s `primary` is a pure + // per-pane value (`headerPrimary`) computed independently of the pane list + // it's given — that list drives only `secondary`, which this path discards. + // Feeding the full set here would rerun deriveHeader's O(n) same-primary + // scan (and `shortestUniqueCwdLabels`) once per pane, i.e. O(n²) per + // 150ms-debounced snapshot, to build a value nothing reads. Compare the + // pane against only itself so that scan is O(1); `primary` is identical. + const title = resolveDisplayPrimary( + deriveHeader(pane, [pane], { appTitleForPane }).primary, + null, + ); + // A pane whose PTY exited lingers in the registry (showing "[Process + // exited…]") with `exited` set; report it as not-alive. A missing entry + // (shouldn't happen — ids come from `registry.keys()`) is also not-alive. + // A live pane has an entry with `exited` unset → alive. + const entry = registry.get(id); + const alive = entry !== undefined && entry.exited !== true; + return { + paneRef: id, + surfaceId: id, + title, + focused, + alive, + pane, + ringing: activity?.status === 'ALERT_RINGING', + hasTODO: activity?.todo === true, + }; + }); + + return buildDirectorySnapshot(inputs); +} diff --git a/lib/src/remote/host/directory.test.ts b/lib/src/remote/host/directory.test.ts new file mode 100644 index 00000000..16e18d27 --- /dev/null +++ b/lib/src/remote/host/directory.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { buildDirectoryEntry, buildDirectorySnapshot, type DirectoryPaneInput } from './directory'; +import type { CwdState, ShellActivity, TerminalPaneState } from '../../lib/terminal-state'; + +function pane(partial: Partial = {}): TerminalPaneState { + return { + cwd: null, + activity: { kind: 'unknown' }, + pendingCommandLine: null, + currentCommand: null, + lastCommand: null, + title: null, + titleCandidates: {}, + ...partial, + }; +} + +function cwd(path: string): CwdState { + return { path, pathKind: 'posix', isRemote: false, source: 'osc7', updatedAt: 1 }; +} + +function input(partial: Partial = {}): DirectoryPaneInput { + return { + paneRef: 'p1', + surfaceId: 'p1', + title: 'shell', + focused: false, + alive: true, + pane: pane(), + ringing: false, + hasTODO: false, + ...partial, + }; +} + +describe('buildDirectoryEntry', () => { + it('maps a running pane with a cwd and carries the flags through', () => { + const entry = buildDirectoryEntry( + input({ + paneRef: 'pane-a', + surfaceId: 'pane-a', + title: 'pnpm dev', + focused: true, + ringing: true, + hasTODO: true, + pane: pane({ activity: { kind: 'running' }, cwd: cwd('/home/me/project') }), + }), + ); + expect(entry).toEqual({ + paneRef: 'pane-a', + surfaceId: 'pane-a', + type: 'terminal', + title: 'pnpm dev', + focused: true, + activity: 'running', + alive: true, + cwd: '/home/me/project', + ringing: true, + hasTODO: true, + }); + // No exitCode field while running. + expect('exitCode' in entry).toBe(false); + }); + + it('passes alive straight through (true for a live pane, false for an exited one)', () => { + expect(buildDirectoryEntry(input({ alive: true })).alive).toBe(true); + // An exited pane lingering in the registry: exitCode may still be present, + // but alive is independently false. + const dead = buildDirectoryEntry( + input({ alive: false, pane: pane({ activity: { kind: 'finished', exitCode: 0 } as ShellActivity }) }), + ); + expect(dead.alive).toBe(false); + expect(dead.exitCode).toBe(0); + }); + + it('includes exitCode only when a finished command has a real code', () => { + const withCode = buildDirectoryEntry( + input({ pane: pane({ activity: { kind: 'finished', exitCode: 1 } as ShellActivity }) }), + ); + expect(withCode.activity).toBe('finished'); + expect(withCode.exitCode).toBe(1); + + const noCode = buildDirectoryEntry( + input({ pane: pane({ activity: { kind: 'finished' } as ShellActivity }) }), + ); + expect(noCode.activity).toBe('finished'); + expect('exitCode' in noCode).toBe(false); + }); + + it('omits cwd when the pane has none', () => { + const entry = buildDirectoryEntry(input()); + expect('cwd' in entry).toBe(false); + expect(entry.activity).toBe('unknown'); + }); + + it('builds a snapshot preserving order', () => { + const entries = buildDirectorySnapshot([ + input({ paneRef: 'a', surfaceId: 'a' }), + input({ paneRef: 'b', surfaceId: 'b' }), + ]); + expect(entries.map((e) => e.surfaceId)).toEqual(['a', 'b']); + }); +}); diff --git a/lib/src/remote/host/directory.ts b/lib/src/remote/host/directory.ts new file mode 100644 index 00000000..9cb3f2af --- /dev/null +++ b/lib/src/remote/host/directory.ts @@ -0,0 +1,51 @@ +/** + * Pure `directory.snapshot` entry construction (remote-api.md → "Directory"). + * Split from the impure collector (`directory-collect.ts`) so the mapping from + * pane state to the wire `DirectoryEntry` is unit-testable without the terminal + * registry, xterm, or the DOM. + */ + +import type { DirectoryEntry } from 'server-lib-common'; +import type { TerminalPaneState } from '../../lib/terminal-state'; + +/** Everything one directory entry needs, already resolved from the live stores. */ +export interface DirectoryPaneInput { + paneRef: string; + surfaceId: string; + /** The derived title the wall header shows (deriveHeader + resolveDisplayPrimary). */ + title: string; + /** Focused on the Host. */ + focused: boolean; + /** The pane's PTY process is still alive (not a lingering exited surface). */ + alive: boolean; + pane: TerminalPaneState; + /** The pane's alert is ringing on the Host (alert-manager). */ + ringing: boolean; + /** The pane has an outstanding TODO. */ + hasTODO: boolean; +} + +export function buildDirectoryEntry(input: DirectoryPaneInput): DirectoryEntry { + const { pane } = input; + // `exitCode` only when the last command finished with a real code; `activity` + // maps straight across (ShellActivity['kind'] is the wire union verbatim). + const exitCode = pane.activity.kind === 'finished' ? pane.activity.exitCode : undefined; + const cwd = pane.cwd?.path; + return { + paneRef: input.paneRef, + surfaceId: input.surfaceId, + type: 'terminal', + title: input.title, + focused: input.focused, + activity: pane.activity.kind, + ...(exitCode !== undefined ? { exitCode } : {}), + alive: input.alive, + ...(cwd ? { cwd } : {}), + ringing: input.ringing, + hasTODO: input.hasTODO, + }; +} + +export function buildDirectorySnapshot(inputs: readonly DirectoryPaneInput[]): DirectoryEntry[] { + return inputs.map(buildDirectoryEntry); +} diff --git a/lib/src/remote/host/enrollment.test.ts b/lib/src/remote/host/enrollment.test.ts new file mode 100644 index 00000000..fba9b442 --- /dev/null +++ b/lib/src/remote/host/enrollment.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ENROLLMENT_KEY, + clearEnrollment, + enrollHost, + getEnrollment, +} from './enrollment'; + +function stubLocalStorage(): Map { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + return store; +} + +describe('remote-host enrollment', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('posts to /api/host/enroll, normalizes the url, and persists', async () => { + const store = stubLocalStorage(); + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + hostId: 'host-abc', + hostToken: 'tok-xyz', + origin: 'https://dormouse.example', + rpId: 'dormouse.example', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + // Trailing slash should be stripped before appending the route. + const enrollment = await enrollHost('https://dormouse.example/', 'hunter2', 'My Laptop'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://dormouse.example/api/host/enroll', + expect.objectContaining({ method: 'POST' }), + ); + const body = JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string); + expect(body).toEqual({ password: 'hunter2', label: 'My Laptop' }); + + expect(enrollment).toEqual({ + serverUrl: 'https://dormouse.example', + hostId: 'host-abc', + hostToken: 'tok-xyz', + origin: 'https://dormouse.example', + rpId: 'dormouse.example', + }); + expect(JSON.parse(store.get(ENROLLMENT_KEY)!)).toEqual(enrollment); + expect(getEnrollment()).toEqual(enrollment); + }); + + it('throws on a non-ok response', async () => { + stubLocalStorage(); + vi.stubGlobal('fetch', vi.fn(async () => new Response('bad password', { status: 401 }))); + await expect(enrollHost('https://dormouse.example', 'wrong', 'x')).rejects.toThrow(/401/); + }); + + it('clears and rejects malformed persisted enrollment', () => { + const store = stubLocalStorage(); + expect(getEnrollment()).toBeNull(); + + store.set(ENROLLMENT_KEY, JSON.stringify({ serverUrl: 'x' })); // missing fields + expect(getEnrollment()).toBeNull(); + + store.set( + ENROLLMENT_KEY, + JSON.stringify({ + serverUrl: 's', + hostId: 'h', + hostToken: 't', + origin: 'o', + rpId: 'r', + }), + ); + expect(getEnrollment()).not.toBeNull(); + + clearEnrollment(); + expect(store.has(ENROLLMENT_KEY)).toBe(false); + expect(getEnrollment()).toBeNull(); + }); +}); diff --git a/lib/src/remote/host/enrollment.ts b/lib/src/remote/host/enrollment.ts new file mode 100644 index 00000000..59891f2a --- /dev/null +++ b/lib/src/remote/host/enrollment.ts @@ -0,0 +1,90 @@ +/** + * Host enrollment against the selfhost Server (docs/specs/server.md → "Host + * side"). Enrollment is the one-time exchange that turns a setup password into + * the durable credentials the Host needs to hold its `/ws/host` socket: + * `{ serverUrl, hostId, hostToken, origin, rpId }`. `origin`/`rpId` become the + * Host's `ConnectionPolicy` — the Server tells the Host what it must enforce, + * and the Host enforces it as final authority regardless. + * + * Persisted in `localStorage` (browser-only, no platform adapter dependency) so + * the standalone app can rehydrate and reconnect on the next launch. + */ + +import { API_ROUTES, type HostEnrollResponse } from 'server-lib-common'; +import { loadJson, saveJson } from '../../lib/local-json-store'; + +export interface HostEnrollment { + /** Origin the Server is reachable at, e.g. `https://dormouse.tailnet.ts.net`. */ + serverUrl: string; + hostId: string; + /** Bearer credential for the `token` query param of `/ws/host`. */ + hostToken: string; + /** The Host's `ConnectionPolicy.origin`. */ + origin: string; + /** The Host's `ConnectionPolicy.rpId`. */ + rpId: string; +} + +/** Single localStorage key holding the whole enrollment blob. */ +export const ENROLLMENT_KEY = 'dormouse.remote-host.enrollment'; + +function isEnrollment(value: unknown): value is HostEnrollment { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.serverUrl === 'string' && + typeof v.hostId === 'string' && + typeof v.hostToken === 'string' && + typeof v.origin === 'string' && + typeof v.rpId === 'string' + ); +} + +export function getEnrollment(): HostEnrollment | null { + // Missing key / malformed JSON / failed guard all collapse to `null`. + return loadJson(ENROLLMENT_KEY, null, isEnrollment); +} + +export function clearEnrollment(): void { + try { + globalThis.localStorage?.removeItem(ENROLLMENT_KEY); + } catch { + // No localStorage (some host/test contexts): nothing to clear. + } +} + +function saveEnrollment(enrollment: HostEnrollment): void { + saveJson(ENROLLMENT_KEY, enrollment); +} + +/** + * `POST /api/host/enroll` with the setup password, persist the returned + * credentials, and hand the enrollment back. Throws with the server's status + * text on failure so the caller (console hook / settings UI) can surface it. + */ +export async function enrollHost( + serverUrl: string, + password: string, + label: string, +): Promise { + const base = serverUrl.replace(/\/+$/, ''); + const response = await fetch(`${base}${API_ROUTES.hostEnroll}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password, label }), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`host enroll failed (${response.status})${detail ? `: ${detail}` : ''}`); + } + const body = (await response.json()) as HostEnrollResponse; + const enrollment: HostEnrollment = { + serverUrl: base, + hostId: body.hostId, + hostToken: body.hostToken, + origin: body.origin, + rpId: body.rpId, + }; + saveEnrollment(enrollment); + return enrollment; +} diff --git a/lib/src/remote/host/pairing-approval.ts b/lib/src/remote/host/pairing-approval.ts new file mode 100644 index 00000000..60f35375 --- /dev/null +++ b/lib/src/remote/host/pairing-approval.ts @@ -0,0 +1,51 @@ +/** + * The pairing-approval queue: an external store (same shape as + * `external-link-confirmation.ts`) that bridges the {@link RemoteHost}'s frame + * loop to the React approval modal. A `pair` frame enqueues a request; the modal + * renders the head of the queue and calls `approve`/`deny`, which run the real + * `PairingCeremony` on the Host (the only path that writes the ACL). + */ + +import type { PairingRequest } from 'server-lib-common'; + +export interface PendingPairing { + /** Server-assigned client socket id; the approve/deny reply is keyed by it. */ + clientId: string; + request: PairingRequest; + requestedAt: number; + /** Approve locally on the Host — writes the ACL and replies `pair-result`. */ + approve: (label?: string) => void; + /** Deny locally — the ACL is untouched. */ + deny: (error?: string) => void; +} + +let queue: readonly PendingPairing[] = []; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +export function enqueuePairingApproval(pending: PendingPairing): void { + // Coalesce by clientId: a re-sent pair for the same client replaces the old. + queue = [...queue.filter((p) => p.clientId !== pending.clientId), pending]; + emit(); +} + +export function resolvePairingApproval(clientId: string): void { + const next = queue.filter((p) => p.clientId !== clientId); + if (next.length === queue.length) return; + queue = next; + emit(); +} + +export function getPairingApprovalSnapshot(): readonly PendingPairing[] { + return queue; +} + +export function subscribePairingApproval(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/lib/src/remote/host/remote-api.test.ts b/lib/src/remote/host/remote-api.test.ts new file mode 100644 index 00000000..3e15bfe1 --- /dev/null +++ b/lib/src/remote/host/remote-api.test.ts @@ -0,0 +1,369 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + REMOTE_EVENTS, + REMOTE_METHODS, + fromBase64Url, + toBase64Url, + utf8Decode, + utf8Encode, + type RemoteEventMsg, + type RemoteResponse, +} from 'server-lib-common'; +import { FakePtyAdapter, setPlatform, type PlatformAdapter } from '../../lib/platform'; +import { registry, type TerminalEntry } from '../../lib/terminal-store'; +import { RemoteApiSession } from './remote-api'; + +type SentPayload = RemoteResponse | RemoteEventMsg; +type DataHandler = (detail: { id: string; data: string }) => void; +type ExitHandler = (detail: { id: string; exitCode: number }) => void; + +class RepaintOnResizePlatform { + readonly dataHandlers = new Set(); + readonly exitHandlers = new Set(); + readonly resizePty = vi.fn((id: string, cols: number, rows: number) => { + this.emitData(id, `pty-resize:${cols}x${rows}`); + }); + readonly writePty = vi.fn(); + + onPtyData(handler: DataHandler): void { + this.dataHandlers.add(handler); + } + + offPtyData(handler: DataHandler): void { + this.dataHandlers.delete(handler); + } + + onPtyExit(handler: ExitHandler): void { + this.exitHandlers.add(handler); + } + + offPtyExit(handler: ExitHandler): void { + this.exitHandlers.delete(handler); + } + + emitData(id: string, data: string): void { + for (const handler of this.dataHandlers) { + handler({ id, data }); + } + } + + emitExit(id: string, exitCode: number): void { + for (const handler of this.exitHandlers) { + handler({ id, exitCode }); + } + } + + asAdapter(): PlatformAdapter { + return this as unknown as PlatformAdapter; + } +} + +function registerSurface( + platform: RepaintOnResizePlatform, + cols: number, + rows: number, + surfaceId = 'surface-1', + ptyId = 'pty-1', +): void { + const terminal = { + cols, + rows, + resize: vi.fn((nextCols: number, nextRows: number) => { + terminal.cols = nextCols; + terminal.rows = nextRows; + platform.emitData(ptyId, `terminal-resize:${nextCols}x${nextRows}`); + }), + }; + + registry.set(surfaceId, { + ptyId, + terminal, + } as unknown as TerminalEntry); +} + +function attach(session: RemoteApiSession, cols: number, rows: number, surfaceId = 'surface-1'): void { + session.handle({ + requestId: 'attach-1', + method: REMOTE_METHODS.surfaceAttach, + params: { surfaceId, cols, rows }, + }); +} + +function decodeTerminalData(payload: SentPayload): string { + const event = payload as RemoteEventMsg; + return utf8Decode(fromBase64Url((event.data as { bytes: string }).bytes)); +} + +describe('RemoteApiSession surface.attach', () => { + afterEach(() => { + vi.useRealTimers(); + registry.clear(); + setPlatform(new FakePtyAdapter()); + }); + + it('keeps synchronous repaint data from terminal resize', () => { + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 100, 30); + + expect(sent[0]).toMatchObject({ + requestId: 'attach-1', + ok: true, + result: { cols: 100, rows: 30 }, + }); + expect(sent[1]).toMatchObject({ + subId: 'attach-1', + event: REMOTE_EVENTS.terminalData, + }); + expect(decodeTerminalData(sent[1]!)).toBe('terminal-resize:100x30'); + }); + + it('keeps synchronous repaint data from the same-size PTY bounce', () => { + vi.useFakeTimers(); + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 80, 24); + + expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + expect(sent[0]).toMatchObject({ + requestId: 'attach-1', + ok: true, + result: { cols: 80, rows: 24 }, + }); + expect(sent[1]).toMatchObject({ + subId: 'attach-1', + event: REMOTE_EVENTS.terminalData, + }); + expect(decodeTerminalData(sent[1]!)).toBe('pty-resize:80x23'); + + vi.advanceTimersByTime(60); + expect(platform.resizePty).toHaveBeenNthCalledWith(2, 'pty-1', 80, 24); + }); + + it('does not fire the same-size bounce restore after detaching', () => { + vi.useFakeTimers(); + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 80, 24); + + // The synchronous bounce away from `rows` has fired; the restore is pending. + expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + expect(platform.resizePty).toHaveBeenCalledTimes(1); + + // Detach inside the ~60ms window, before the restore fires. + session.handle({ + requestId: 'detach', + method: REMOTE_METHODS.surfaceDetach, + params: { surfaceId: 'surface-1' }, + }); + + vi.advanceTimersByTime(60); + + // The stale restore must never touch the now-detached PTY. + expect(platform.resizePty).toHaveBeenCalledTimes(1); + expect(platform.resizePty).not.toHaveBeenCalledWith('pty-1', 80, 24); + }); + + it('does not let a stale bounce restore clobber a newer attachment', () => { + vi.useFakeTimers(); + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); + registerSurface(platform, 80, 24, 'surface-2', 'pty-2'); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + // First attach schedules a restore bounce for pty-1. + attach(session, 80, 24, 'surface-1'); + expect(platform.resizePty).toHaveBeenNthCalledWith(1, 'pty-1', 80, 23); + + // Re-attaching to a different surface replaces the attachment (last-attach-wins) + // and must cancel the prior pty-1 restore. + attach(session, 80, 24, 'surface-2'); + expect(platform.resizePty).toHaveBeenNthCalledWith(2, 'pty-2', 80, 23); + + vi.advanceTimersByTime(60); + + // Only the current attachment's restore fires; pty-1's stale restore does not. + expect(platform.resizePty).toHaveBeenNthCalledWith(3, 'pty-2', 80, 24); + expect(platform.resizePty).toHaveBeenCalledTimes(3); + expect(platform.resizePty).not.toHaveBeenCalledWith('pty-1', 80, 24); + }); + + it('rejects write and resize unless the surface is the current attachment', () => { + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); + registerSurface(platform, 100, 30, 'surface-2', 'pty-2'); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 80, 24, 'surface-1'); + sent.length = 0; + + session.handle({ + requestId: 'write-background', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-2', bytes: toBase64Url(utf8Encode('invisible\r')) }, + }); + session.handle({ + requestId: 'resize-background', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-2', cols: 120, rows: 40 }, + }); + + expect(platform.writePty).not.toHaveBeenCalled(); + expect((registry.get('surface-2')!.terminal as { cols: number; rows: number }).cols).toBe(100); + expect(sent).toEqual([ + { + requestId: 'write-background', + ok: false, + error: 'surface is not attached: surface-2', + }, + { + requestId: 'resize-background', + ok: false, + error: 'surface is not attached: surface-2', + }, + ]); + + session.handle({ + requestId: 'detach', + method: REMOTE_METHODS.surfaceDetach, + params: { surfaceId: 'surface-1' }, + }); + sent.length = 0; + + session.handle({ + requestId: 'write-detached', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-1', bytes: toBase64Url(utf8Encode('stale\r')) }, + }); + + expect(platform.writePty).not.toHaveBeenCalled(); + expect(sent).toEqual([ + { + requestId: 'write-detached', + ok: false, + error: 'surface is not attached: surface-1', + }, + ]); + }); + + it('keeps write and resize pinned to the attached terminal after pane swaps', () => { + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); + registerSurface(platform, 100, 30, 'surface-2', 'pty-2'); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 90, 25, 'surface-1'); + const attachedEntry = registry.get('surface-1')!; + const swappedInEntry = registry.get('surface-2')!; + registry.set('surface-1', swappedInEntry); + registry.set('surface-2', attachedEntry); + sent.length = 0; + + session.handle({ + requestId: 'write-after-swap', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-1', bytes: toBase64Url(utf8Encode('still-attached\r')) }, + }); + + expect(platform.writePty).toHaveBeenCalledWith('pty-1', 'still-attached\r'); + expect(platform.writePty).not.toHaveBeenCalledWith('pty-2', expect.any(String)); + + session.handle({ + requestId: 'resize-after-swap', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-1', cols: 120, rows: 40 }, + }); + + expect((attachedEntry.terminal as { cols: number; rows: number }).cols).toBe(120); + expect((attachedEntry.terminal as { cols: number; rows: number }).rows).toBe(40); + expect((swappedInEntry.terminal as { cols: number; rows: number }).cols).toBe(100); + expect((swappedInEntry.terminal as { cols: number; rows: number }).rows).toBe(30); + expect(sent).toEqual([ + { + requestId: 'write-after-swap', + ok: true, + result: {}, + }, + { + subId: 'attach-1', + event: REMOTE_EVENTS.terminalData, + data: { bytes: toBase64Url(utf8Encode('terminal-resize:120x40')) }, + }, + { + requestId: 'resize-after-swap', + ok: true, + result: { cols: 120, rows: 40 }, + }, + ]); + }); + + it('tears down the attachment when the attached PTY exits', () => { + const platform = new RepaintOnResizePlatform(); + setPlatform(platform.asAdapter()); + registerSurface(platform, 80, 24, 'surface-1', 'pty-1'); + const sent: SentPayload[] = []; + const session = new RemoteApiSession({ hostId: 'host-1', send: (payload) => sent.push(payload) }); + + attach(session, 100, 30, 'surface-1'); + sent.length = 0; + + // The attached PTY exits (process death, or the pane disposed on the Host). + platform.emitExit('pty-1', 0); + + // The client is told the terminal closed... + expect(sent).toEqual([ + { + subId: 'attach-1', + event: REMOTE_EVENTS.terminalClosed, + data: { exitCode: 0 }, + }, + ]); + sent.length = 0; + + // ...and the attachment is gone, so a later write/resize for that surface + // fails safe instead of touching the dead PTY / disposed xterm. + session.handle({ + requestId: 'write-after-exit', + method: REMOTE_METHODS.terminalWrite, + params: { surfaceId: 'surface-1', bytes: toBase64Url(utf8Encode('ghost\r')) }, + }); + session.handle({ + requestId: 'resize-after-exit', + method: REMOTE_METHODS.terminalResize, + params: { surfaceId: 'surface-1', cols: 120, rows: 40 }, + }); + + expect(platform.writePty).not.toHaveBeenCalled(); + expect(platform.resizePty).not.toHaveBeenCalled(); + expect(sent).toEqual([ + { + requestId: 'write-after-exit', + ok: false, + error: 'surface is not attached: surface-1', + }, + { + requestId: 'resize-after-exit', + ok: false, + error: 'surface is not attached: surface-1', + }, + ]); + }); +}); diff --git a/lib/src/remote/host/remote-api.ts b/lib/src/remote/host/remote-api.ts new file mode 100644 index 00000000..327327fb --- /dev/null +++ b/lib/src/remote/host/remote-api.ts @@ -0,0 +1,355 @@ +/** + * Remote-api v1, terminal-only (remote-api.md → "v1 scope"). One + * {@link RemoteApiSession} per authorized Client session translates the wire + * protocol into the Host's existing terminal plumbing: + * + * - `hello` → capabilities (input yes, layout no). + * - `directory.watch` → an immediate snapshot plus coalesced re-snapshots + * whenever pane state / activity / focus changes. + * - `surface.attach` → resize the real PTY through the existing xterm resize + * path (attach-is-the-resize) and stream its output as + * `terminal.data`; `terminal.closed` on PTY exit. + * - `terminal.write` → the existing PTY input path. + * - `terminal.resize` → take size authority (last-attach-wins). + * - `surface.detach` → stop streaming. + * + * The bytes on the wire are base64url PTY bytes; xterm on the Client renders + * them, exactly as the Host's own xterm renders the same stream locally. + */ + +import { + REMOTE_EVENTS, + REMOTE_METHODS, + clampTerminalDimension, + fromBase64Url, + toBase64Url, + utf8Decode, + utf8Encode, + type AttachParams, + type HelloResult, + type RemoteEventMsg, + type RemoteRequest, + type RemoteResponse, + type TerminalAttachResult, + type TerminalResizeParams, + type TerminalWriteParams, +} from 'server-lib-common'; +import { getPlatform } from '../../lib/platform'; +import { registry } from '../../lib/terminal-store'; +import type { TerminalEntry } from '../../lib/terminal-store'; +import { subscribeToActivity } from '../../lib/session-activity-store'; +import { subscribeToTerminalPaneState } from '../../lib/terminal-state-store'; +import { collectDirectorySnapshot } from './directory-collect'; + +/** Coalesce window for directory re-snapshots (remote-api.md: "Host coalesces"). */ +const DIRECTORY_DEBOUNCE_MS = 150; +/** + * When an attach requests the size the PTY already has, `terminal.resize` is a + * no-op, so we bounce the PTY's rows to force one SIGWINCH-driven repaint. + */ +const FORCE_REPAINT_BOUNCE_MS = 60; + +interface Attachment { + surfaceId: string; + ptyId: string; + entry: TerminalEntry; + subId: string; + onData: (detail: { id: string; data: string }) => void; + onExit: (detail: { id: string; exitCode: number }) => void; + /** Pending same-size repaint bounce (see FORCE_REPAINT_BOUNCE_MS), if any. */ + bounceTimer: ReturnType | null; +} + +export interface RemoteApiSessionOptions { + hostId: string; + /** Sends a remote-api response/event; the caller wraps it in a `msg` frame. */ + send: (payload: RemoteResponse | RemoteEventMsg) => void; +} + +export class RemoteApiSession { + readonly #hostId: string; + readonly #send: (payload: RemoteResponse | RemoteEventMsg) => void; + + #directorySubId: string | null = null; + #unsubDirectory: (() => void) | null = null; + #directoryTimer: ReturnType | null = null; + #attachment: Attachment | null = null; + + constructor(options: RemoteApiSessionOptions) { + this.#hostId = options.hostId; + this.#send = options.send; + } + + handle(data: unknown): void { + const request = data as RemoteRequest; + if (!request || typeof request.requestId !== 'string' || typeof request.method !== 'string') { + return; + } + try { + switch (request.method) { + case REMOTE_METHODS.hello: + return this.#hello(request); + case REMOTE_METHODS.directoryWatch: + return this.#directoryWatch(request); + case REMOTE_METHODS.surfaceAttach: + return this.#attach(request); + case REMOTE_METHODS.surfaceDetach: + return this.#detach(request); + case REMOTE_METHODS.terminalWrite: + return this.#write(request); + case REMOTE_METHODS.terminalResize: + return this.#resize(request); + default: + return this.#fail(request, `unknown method: ${request.method}`); + } + } catch (error) { + this.#fail(request, error instanceof Error ? error.message : 'internal error'); + } + } + + dispose(): void { + this.#directorySubId = null; + if (this.#directoryTimer) { + clearTimeout(this.#directoryTimer); + this.#directoryTimer = null; + } + this.#unsubDirectory?.(); + this.#unsubDirectory = null; + this.#teardownAttachment(); + } + + // --- Responses --- + + #ok(request: RemoteRequest, result: unknown): void { + this.#send({ requestId: request.requestId, ok: true, result }); + } + + #fail(request: RemoteRequest, error: string): void { + this.#send({ requestId: request.requestId, ok: false, error }); + } + + #event(subId: string, event: string, data: unknown): void { + this.#send({ subId, event, data }); + } + + /** + * Resolve the live terminal a `surface.*` request targets, or fail the request + * (and return null) if the params or the surface are missing. Shared by + * attach/write/resize so the not-found contract lives in one place. + */ + #resolveSurface

( + request: RemoteRequest, + ): { params: P; entry: TerminalEntry } | null { + const params = request.params as P | undefined; + const entry = params ? registry.get(params.surfaceId) : undefined; + if (!params || !entry) { + this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); + return null; + } + return { params, entry }; + } + + #requireAttached(request: RemoteRequest, surfaceId: string): Attachment | null { + if (this.#attachment?.surfaceId === surfaceId) return this.#attachment; + this.#fail(request, `surface is not attached: ${surfaceId}`); + return null; + } + + #attachedParams

( + request: RemoteRequest, + ): { params: P; attachment: Attachment } | null { + const params = request.params as P | undefined; + if (!params || typeof params.surfaceId !== 'string') { + this.#fail(request, `no such surface: ${params?.surfaceId ?? '(none)'}`); + return null; + } + const attachment = this.#requireAttached(request, params.surfaceId); + return attachment ? { params, attachment } : null; + } + + // --- Methods --- + + #hello(request: RemoteRequest): void { + // v1 selfhost: every paired session is the owner, so full input, no layout. + const result: HelloResult = { + protocolVersion: 1, + hostId: this.#hostId, + grants: { input: true, layout: false }, + }; + this.#ok(request, result); + } + + #directoryWatch(request: RemoteRequest): void { + // The subscription id the client correlates snapshots by is this request id. + this.#directorySubId = request.requestId; + this.#ok(request, { subId: request.requestId }); + this.#emitDirectory(); + + if (this.#unsubDirectory) return; + const trigger = () => this.#scheduleDirectory(); + const unsubPane = subscribeToTerminalPaneState(trigger); + const unsubActivity = subscribeToActivity(trigger); + const hasDocument = typeof document !== 'undefined'; + if (hasDocument) { + document.addEventListener('focusin', trigger); + document.addEventListener('focusout', trigger); + } + this.#unsubDirectory = () => { + unsubPane(); + unsubActivity(); + if (hasDocument) { + document.removeEventListener('focusin', trigger); + document.removeEventListener('focusout', trigger); + } + }; + } + + #scheduleDirectory(): void { + if (this.#directorySubId === null || this.#directoryTimer) return; + this.#directoryTimer = setTimeout(() => { + this.#directoryTimer = null; + this.#emitDirectory(); + }, DIRECTORY_DEBOUNCE_MS); + } + + #emitDirectory(): void { + if (this.#directorySubId === null) return; + this.#event(this.#directorySubId, REMOTE_EVENTS.directorySnapshot, { + entries: collectDirectorySnapshot(), + }); + } + + #attach(request: RemoteRequest): void { + const resolved = this.#resolveSurface(request); + if (!resolved) return; + const { params, entry } = resolved; + // v1: one attachment per session — replace any prior stream. + this.#teardownAttachment(); + + const ptyId = entry.ptyId; + const term = entry.terminal; + const cols = clampTerminalDimension(params.cols, term.cols); + const rows = clampTerminalDimension(params.rows, term.rows); + const platform = getPlatform(); + const subId = request.requestId; + const pendingEvents: Array<{ event: string; data: unknown }> = []; + let streaming = false; + const emitOrBuffer = (event: string, data: unknown): void => { + if (streaming) { + this.#event(subId, event, data); + } else { + pendingEvents.push({ event, data }); + } + }; + const onData = (detail: { id: string; data: string }): void => { + if (detail.id !== ptyId) return; + // The PTY delivers strings on this path; be defensive about the Uint8Array + // path some adapters use. Either way it goes out as base64url PTY bytes. + const raw: unknown = detail.data; + const bytes = typeof raw === 'string' ? utf8Encode(raw) : (raw as Uint8Array); + emitOrBuffer(REMOTE_EVENTS.terminalData, { bytes: toBase64Url(bytes) }); + }; + const onExit = (detail: { id: string; exitCode: number }): void => { + if (detail.id !== ptyId) return; + // Deliver the close to the client first, then drop the attachment so a + // later write/resize for this surface fails safe with "not attached" + // instead of touching the now-dead PTY / disposed xterm (the pre-pin code + // re-resolved via the registry and got that fail-safe for free). Teardown + // offPtyExit(onExit)s mid-callback, which is safe — this handler, having + // filtered to its own ptyId, won't fire again — and nulls #attachment so + // #requireAttached fails and the bounce timer + PTY listeners are cleaned. + emitOrBuffer(REMOTE_EVENTS.terminalClosed, { exitCode: detail.exitCode }); + this.#teardownAttachment(); + }; + platform.onPtyData(onData); + platform.onPtyExit(onExit); + const attachment: Attachment = { + surfaceId: params.surfaceId, + ptyId, + entry, + subId, + onData, + onExit, + bounceTimer: null, + }; + this.#attachment = attachment; + + // Attach-is-the-resize: resizing the real xterm fires its onResize handler, + // which drives resizePty → SIGWINCH → the TUI/shell repaints, and that + // repaint is what fills the client's screen (no snapshot transfer). The + // stream is subscribed first because some PTYs repaint synchronously. + if (term.cols !== cols || term.rows !== rows) { + term.resize(cols, rows); + } else { + // Same size: force one repaint with a quick rows bounce on the PTY only, + // leaving the already-correct local xterm buffer untouched. Bounce away + // from `rows` in whichever direction stays >= 1 (a 1-row surface must + // bounce up, since rows-1 would be an identical no-op that fires no + // SIGWINCH and so never repaints). + const bounced = rows > 1 ? rows - 1 : rows + 1; + platform.resizePty(ptyId, cols, bounced); + // The restore runs ~60ms later, so the client may detach, re-attach at a + // different size, or dispose the session first. Cancel on teardown and, + // as a backstop, re-check this is still the current attachment before + // touching the PTY — a stale restore would clobber the newer size owner + // (last-attach-wins) or resize a detached/exited PTY. + attachment.bounceTimer = setTimeout(() => { + attachment.bounceTimer = null; + if (this.#attachment !== attachment) return; + platform.resizePty(ptyId, cols, rows); + }, FORCE_REPAINT_BOUNCE_MS); + } + + const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; + this.#ok(request, result); + streaming = true; + for (const event of pendingEvents) { + this.#event(subId, event.event, event.data); + } + } + + #detach(request: RemoteRequest): void { + // Detach names its surface: a stale detach for a pane the client already + // switched away from must not kill the newer attachment. Detaching a + // surface that is not the current attachment is an idempotent no-op. + const params = request.params as { surfaceId?: string } | undefined; + if (this.#attachment && this.#attachment.surfaceId === params?.surfaceId) { + this.#teardownAttachment(); + } + this.#ok(request, {}); + } + + #write(request: RemoteRequest): void { + const resolved = this.#attachedParams(request); + if (!resolved) return; + const { params, attachment } = resolved; + // Feed the existing PTY input path; the local echo returns via onPtyData. + getPlatform().writePty(attachment.ptyId, utf8Decode(fromBase64Url(params.bytes))); + this.#ok(request, {}); + } + + #resize(request: RemoteRequest): void { + const resolved = this.#attachedParams(request); + if (!resolved) return; + const { params, attachment } = resolved; + const entry = attachment.entry; + const term = entry.terminal; + const cols = clampTerminalDimension(params.cols, term.cols); + const rows = clampTerminalDimension(params.rows, term.rows); + if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); + const result: TerminalAttachResult = { cols: term.cols, rows: term.rows }; + this.#ok(request, result); + } + + #teardownAttachment(): void { + if (!this.#attachment) return; + if (this.#attachment.bounceTimer) { + clearTimeout(this.#attachment.bounceTimer); + this.#attachment.bounceTimer = null; + } + const platform = getPlatform(); + platform.offPtyData(this.#attachment.onData); + platform.offPtyExit(this.#attachment.onExit); + this.#attachment = null; + } +} diff --git a/lib/src/remote/host/remote-host.test.ts b/lib/src/remote/host/remote-host.test.ts new file mode 100644 index 00000000..ca672174 --- /dev/null +++ b/lib/src/remote/host/remote-host.test.ts @@ -0,0 +1,386 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + DEFAULT_PAIRING_TTL_MS, + concatBytes, + ecdsaRawToDer, + generateDeviceKeyPair, + hashPasskeyPublicKey, + signDeviceChallenge, + toBase64Url, + utf8Encode, + type ConnectionRequest, + type HostAclRecord, + type PairingRequest, +} from 'server-lib-common'; +import { RemoteHost, type WebSocketLike } from './remote-host'; +import type { HostEnrollment } from './enrollment'; +import type { PendingPairing } from './pairing-approval'; + +// --- A fake `/ws/host` socket the test drives directly --- + +class FakeSocket implements WebSocketLike { + readyState = 1; + readonly sent: Array> = []; + readonly #handlers = new Map void>>(); + + addEventListener(type: string, handler: (ev: unknown) => void): void { + const list = this.#handlers.get(type) ?? []; + list.push(handler); + this.#handlers.set(type, list); + } + + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + + close(): void { + this.readyState = 3; + this.#emit('close', { code: 1000 }); + } + + #emit(type: string, ev: unknown): void { + for (const handler of this.#handlers.get(type) ?? []) handler(ev); + } + + open(): void { + this.#emit('open', {}); + } + + /** Deliver a server→host frame. */ + receive(frame: unknown): void { + this.#emit('message', { data: JSON.stringify(frame) }); + } + + frames(t: string): Array> { + return this.sent.filter((frame) => frame.t === t); + } +} + +const ENROLLMENT: HostEnrollment = { + serverUrl: 'https://host.example', + hostId: 'host-1', + hostToken: 'tok', + origin: 'https://host.example', + rpId: 'host.example', +}; + +// --- Minimal faithful WebAuthn authenticator (mirrors test/harness/actors.mjs) --- + +const subtle = globalThis.crypto.subtle; + +async function sha256(bytes: Uint8Array): Promise { + return new Uint8Array(await subtle.digest('SHA-256', bytes)); +} + +async function createAuthenticator(rpId: string) { + const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + const spki = new Uint8Array(await subtle.exportKey('spki', keyPair.publicKey)); + const publicKey = toBase64Url(spki); + const credentialId = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(16))); + let signCount = 0; + + async function assert(challenge: string, origin: string) { + const clientDataJSON = utf8Encode( + JSON.stringify({ type: 'webauthn.get', challenge, origin, crossOrigin: false }), + ); + const rpIdHash = await sha256(utf8Encode(rpId)); + signCount += 1; + const flags = 0x01 | 0x04; // user present + user verified + const authenticatorData = concatBytes( + rpIdHash, + Uint8Array.of(flags, (signCount >>> 24) & 0xff, (signCount >>> 16) & 0xff, (signCount >>> 8) & 0xff, signCount & 0xff), + ); + const rawSignature = new Uint8Array( + await subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + keyPair.privateKey, + concatBytes(authenticatorData, await sha256(clientDataJSON)), + ), + ); + return { + credentialId, + clientDataJSON: toBase64Url(clientDataJSON), + authenticatorData: toBase64Url(authenticatorData), + signature: toBase64Url(ecdsaRawToDer(rawSignature)), + }; + } + + return { publicKey, credentialId, assert }; +} + +async function flushUntil(get: () => T | undefined, timeoutMs = 2000): Promise { + const start = Date.now(); + for (;;) { + const value = get(); + if (value !== undefined) return value; + if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for frame'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +/** Drive connect → connect2 and return the decision frame. */ +async function runConnect( + socket: FakeSocket, + clientId: string, + build: (challenge: string) => Promise, +): Promise> { + socket.sent.length = 0; + socket.receive({ t: 'connect', clientId }); + const challengeFrame = socket.frames('challenge')[0]!; + const request = await build(challengeFrame.challenge as string); + socket.receive({ t: 'connect2', clientId, request }); + return flushUntil(() => socket.frames('decision')[0]); +} + +describe('RemoteHost frame handling', () => { + let socket: FakeSocket; + let savedRecords: HostAclRecord[] = []; + let approvals: PendingPairing[] = []; + + function makeHost( + loadAcl: () => HostAclRecord[] = () => [], + now: () => number = () => Date.now(), + ) { + savedRecords = []; + approvals = []; + const host = new RemoteHost({ + enrollment: ENROLLMENT, + reconnect: false, + createWebSocket: () => (socket = new FakeSocket()), + loadAcl, + saveAcl: (_hostId, records) => { + savedRecords = [...records]; + }, + requestApproval: (pending) => approvals.push(pending), + dismissApproval: () => {}, + now, + }); + host.start(); + socket.open(); + return host; + } + + beforeEach(() => { + socket = new FakeSocket(); + }); + + it('pair → local approval → pair-result with the ACL record, and persists', () => { + makeHost(); + const request: PairingRequest = { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + }; + socket.receive({ t: 'pair', clientId: 'c1', request }); + + // No ACL write until the local user approves. + expect(socket.frames('pair-result')).toHaveLength(0); + expect(approvals).toHaveLength(1); + + approvals[0]!.approve(); + + const result = socket.frames('pair-result')[0]!; + expect(result).toMatchObject({ clientId: 'c1', approved: true }); + expect((result.record as HostAclRecord).devicePublicKey).toBe('device-1'); + expect((result.record as HostAclRecord).label).toBe('iPhone Safari'); + // The approval wrote and persisted the ACL. + expect(savedRecords).toHaveLength(1); + expect(savedRecords[0]!.passkeyCredentialId).toBe('cred-1'); + }); + + it('deny → pair-result approved:false, ACL untouched', () => { + makeHost(); + socket.receive({ + t: 'pair', + clientId: 'c1', + request: { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + } satisfies PairingRequest, + }); + approvals[0]!.deny(); + + const result = socket.frames('pair-result')[0]!; + expect(result).toMatchObject({ clientId: 'c1', approved: false }); + expect(result.record).toBeUndefined(); + expect(savedRecords).toEqual([]); + }); + + it('expired approval → pair-result approved:false, ACL untouched', () => { + let now = 1_000; + makeHost(() => [], () => now); + socket.receive({ + t: 'pair', + clientId: 'c1', + request: { + accountId: 'owner', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + } satisfies PairingRequest, + }); + now += DEFAULT_PAIRING_TTL_MS; + + approvals[0]!.approve(); + + const result = socket.frames('pair-result')[0]!; + expect(result).toMatchObject({ + clientId: 'c1', + approved: false, + error: 'pairing approval expired', + }); + expect(result.record).toBeUndefined(); + expect(savedRecords).toEqual([]); + }); + + it('connect issues a challenge frame', () => { + makeHost(); + socket.receive({ t: 'connect', clientId: 'c1' }); + const challenge = socket.frames('challenge')[0]!; + expect(challenge.clientId).toBe('c1'); + expect(typeof challenge.challenge).toBe('string'); + expect(typeof challenge.expiresAt).toBe('number'); + }); + + it('connect2 for an unpaired device denies with failures', async () => { + makeHost(); + const authenticator = await createAuthenticator(ENROLLMENT.rpId); + const deviceKey = await generateDeviceKeyPair(); + + const decision = await runConnect(socket, 'c1', async (challenge) => ({ + accountId: 'owner', + devicePublicKey: deviceKey.devicePublicKey, + challenge, + deviceSignature: await signDeviceChallenge(deviceKey.privateKey, { + hostId: ENROLLMENT.hostId, + challenge, + devicePublicKey: deviceKey.devicePublicKey, + }), + passkey: { + publicKey: authenticator.publicKey, + assertion: await authenticator.assert(challenge, ENROLLMENT.origin), + }, + })); + + expect(decision).toMatchObject({ clientId: 'c1', allowed: false }); + expect(decision.failures).toEqual( + expect.arrayContaining(['passkey-not-paired', 'device-not-paired']), + ); + }); + + it('pair then connect2 allows and omits failures', async () => { + makeHost(); + const authenticator = await createAuthenticator(ENROLLMENT.rpId); + const deviceKey = await generateDeviceKeyPair(); + const passkeyPublicKeyHash = await hashPasskeyPublicKey(authenticator.publicKey); + + // Pair this exact (passkey, device) pair through the real ceremony. + socket.receive({ + t: 'pair', + clientId: 'c1', + request: { + accountId: 'owner', + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash, + devicePublicKey: deviceKey.devicePublicKey, + requestedLabel: 'iPhone Safari', + } satisfies PairingRequest, + }); + approvals[0]!.approve(); + expect(socket.frames('pair-result')[0]).toMatchObject({ approved: true }); + + const decision = await runConnect(socket, 'c1', async (challenge) => ({ + accountId: 'owner', + devicePublicKey: deviceKey.devicePublicKey, + challenge, + deviceSignature: await signDeviceChallenge(deviceKey.privateKey, { + hostId: ENROLLMENT.hostId, + challenge, + devicePublicKey: deviceKey.devicePublicKey, + }), + passkey: { + publicKey: authenticator.publicKey, + assertion: await authenticator.assert(challenge, ENROLLMENT.origin), + }, + })); + + expect(decision).toMatchObject({ clientId: 'c1', allowed: true }); + // `failures` is omitted from an allowed decision. + expect('failures' in decision).toBe(false); + }); + + it('gates msg on an allowed decision and routes to a session', async () => { + const handled: unknown[] = []; + let disposed = 0; + savedRecords = []; + approvals = []; + const host = new RemoteHost({ + enrollment: ENROLLMENT, + reconnect: false, + createWebSocket: () => (socket = new FakeSocket()), + loadAcl: () => [], + requestApproval: (pending) => pending.approve(), + dismissApproval: () => {}, + createSession: () => ({ + handle: (data) => handled.push(data), + dispose: () => { + disposed += 1; + }, + }), + }); + host.start(); + socket.open(); + + // Before any allowed decision, msg is dropped (the host-side gate). + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r', method: 'hello' } }); + expect(handled).toHaveLength(0); + + // Force an allowed decision by pairing + connecting. + const authenticator = await createAuthenticator(ENROLLMENT.rpId); + const deviceKey = await generateDeviceKeyPair(); + const passkeyPublicKeyHash = await hashPasskeyPublicKey(authenticator.publicKey); + socket.receive({ + t: 'pair', + clientId: 'c1', + request: { + accountId: 'owner', + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash, + devicePublicKey: deviceKey.devicePublicKey, + requestedLabel: 'x', + } satisfies PairingRequest, + }); + await runConnect(socket, 'c1', async (challenge) => ({ + accountId: 'owner', + devicePublicKey: deviceKey.devicePublicKey, + challenge, + deviceSignature: await signDeviceChallenge(deviceKey.privateKey, { + hostId: ENROLLMENT.hostId, + challenge, + devicePublicKey: deviceKey.devicePublicKey, + }), + passkey: { + publicKey: authenticator.publicKey, + assertion: await authenticator.assert(challenge, ENROLLMENT.origin), + }, + })); + + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r', method: 'hello' } }); + expect(handled).toHaveLength(1); + + // client-gone disposes the session and re-gates. + socket.receive({ t: 'client-gone', clientId: 'c1' }); + expect(disposed).toBe(1); + socket.receive({ t: 'msg', clientId: 'c1', data: { requestId: 'r2', method: 'hello' } }); + expect(handled).toHaveLength(1); + }); +}); diff --git a/lib/src/remote/host/remote-host.ts b/lib/src/remote/host/remote-host.ts new file mode 100644 index 00000000..aeee9589 --- /dev/null +++ b/lib/src/remote/host/remote-host.ts @@ -0,0 +1,371 @@ +/** + * The Host controller: holds the `/ws/host` relay socket and speaks the Host + * side of the wire contract (`server-lib-common/remote/wire.ts`), mirroring the + * headless reference in `server/test/harness/fake-host.mjs`. + * + * - `pair` → begin the ceremony and surface a local approval; approval + * runs `PairingCeremony.approve` (the only ACL write), + * persists the ACL, and replies `pair-result` with the record. + * - `connect` → issue a Host challenge. + * - `connect2` → `authorizeConnection` (final authority); `failures` is + * omitted from an allowed `decision`. + * - `msg` → only for a client with an allowed decision; routed to the + * remote-api handler. + * - `client-gone` → drop that client's transient state. + * + * The remote-api handler is injected (`createSession`) so this controller has no + * dependency on the terminal registry / xterm / DOM — the wiring lives in + * `activation.ts`, and this file stays unit-testable against a fake socket. + */ + +import { + HostAcl, + HostChallengeIssuer, + PairingError, + PairingCeremony, + WS_ROUTES, + WS_TOKEN_PARAM, + authorizeConnection, + type ConnectionPolicy, + type ConnectionRequest, + type HostAclRecord, + type HostFrame, + type PairingRequest, + type ServerToHostFrame, +} from 'server-lib-common'; +import type { HostEnrollment } from './enrollment'; +import type { RemoteWebSocket } from '../ws'; +import { loadHostAcl, saveAclRecords } from './acl'; +import { + enqueuePairingApproval, + resolvePairingApproval, + type PendingPairing, +} from './pairing-approval'; + +/** The remote-api handler this controller drives per authorized client. */ +export interface RemoteApiSessionLike { + handle(data: unknown): void; + dispose(): void; +} + +/** Minimal WebSocket surface, so tests can inject a fake. */ +export type WebSocketLike = RemoteWebSocket; + +/** Per-client lifecycle state tracked by the Host, keyed by clientId. */ +interface ClientState { + /** True once the Host allowed this client's connection — the `msg` gate. */ + established: boolean; + /** The in-flight pairing awaiting local approval, if any. */ + pending?: PendingPairing; + /** The remote-api handler, created on the first authorized `msg`. */ + session?: RemoteApiSessionLike; +} + +export type RemoteHostStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'stopped'; + +export interface RemoteHostOptions { + enrollment: HostEnrollment; + createWebSocket?: (url: string) => WebSocketLike; + /** Build the remote-api handler for an authorized client (see activation.ts). */ + createSession?: (opts: { + hostId: string; + send: (payload: unknown) => void; + }) => RemoteApiSessionLike; + loadAcl?: (hostId: string) => HostAclRecord[]; + saveAcl?: (hostId: string, records: readonly HostAclRecord[]) => void; + /** Surface a pairing request for local approval (default: the modal queue). */ + requestApproval?: (pending: PendingPairing) => void; + /** Dismiss a surfaced request once resolved (default: the modal queue). */ + dismissApproval?: (clientId: string) => void; + now?: () => number; + /** Auto-reconnect with backoff (default true; tests pass false). */ + reconnect?: boolean; +} + +const INITIAL_BACKOFF_MS = 1_000; +const MAX_BACKOFF_MS = 30_000; + +export class RemoteHost { + readonly #enrollment: HostEnrollment; + readonly #policy: ConnectionPolicy; + readonly #acl: HostAcl; + readonly #challenges: HostChallengeIssuer; + readonly #ceremony: PairingCeremony; + + readonly #createWebSocket: (url: string) => WebSocketLike; + readonly #createSession?: RemoteHostOptions['createSession']; + readonly #saveAcl: (hostId: string, records: readonly HostAclRecord[]) => void; + readonly #requestApproval: (pending: PendingPairing) => void; + readonly #dismissApproval: (clientId: string) => void; + readonly #now: () => number; + readonly #reconnect: boolean; + + /** + * Per-client lifecycle state keyed by clientId. Folding the three concerns + * (allowed connection, in-flight pairing, live session) into one record makes + * teardown a single `delete` — no handler can leave the collections out of + * sync. + */ + readonly #clients = new Map(); + + #ws: WebSocketLike | null = null; + #status: RemoteHostStatus = 'idle'; + #stopped = false; + #backoffMs = INITIAL_BACKOFF_MS; + #reconnectTimer: ReturnType | null = null; + + constructor(options: RemoteHostOptions) { + this.#enrollment = options.enrollment; + this.#policy = { rpId: options.enrollment.rpId, origin: options.enrollment.origin }; + this.#now = options.now ?? (() => Date.now()); + this.#acl = loadHostAcl(options.enrollment.hostId, options.loadAcl); + this.#challenges = new HostChallengeIssuer({ now: this.#now }); + this.#ceremony = new PairingCeremony(this.#acl, { now: this.#now }); + + this.#createWebSocket = + options.createWebSocket ?? ((url) => new WebSocket(url) as unknown as WebSocketLike); + this.#createSession = options.createSession; + this.#saveAcl = options.saveAcl ?? saveAclRecords; + this.#requestApproval = options.requestApproval ?? enqueuePairingApproval; + this.#dismissApproval = options.dismissApproval ?? resolvePairingApproval; + this.#reconnect = options.reconnect ?? true; + } + + get status(): RemoteHostStatus { + return this.#status; + } + + get hostId(): string { + return this.#enrollment.hostId; + } + + get activeRecords(): HostAclRecord[] { + return this.#acl.activeRecords(); + } + + start(): void { + this.#stopped = false; + this.#connect(); + } + + stop(): void { + this.#stopped = true; + this.#status = 'stopped'; + if (this.#reconnectTimer) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + this.#dropTransientState(); + try { + this.#ws?.close(); + } catch { + // already closing + } + this.#ws = null; + } + + // --- Socket lifecycle --- + + #connect(): void { + if (this.#ws || this.#stopped) return; + this.#status = 'connecting'; + const wsBase = this.#enrollment.serverUrl.replace(/^http/, 'ws'); + const url = `${wsBase}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${encodeURIComponent(this.#enrollment.hostToken)}`; + const ws = this.#createWebSocket(url); + this.#ws = ws; + ws.addEventListener('open', () => { + this.#status = 'connected'; + this.#backoffMs = INITIAL_BACKOFF_MS; + }); + ws.addEventListener('message', (ev) => { + this.#onFrame((ev as { data?: unknown }).data); + }); + ws.addEventListener('error', () => { + // A `close` always follows; reconnection is handled there. + }); + ws.addEventListener('close', () => { + this.#ws = null; + this.#onClose(); + }); + } + + #onClose(): void { + this.#dropTransientState(); + if (this.#stopped || !this.#reconnect) { + this.#status = 'stopped'; + return; + } + this.#status = 'disconnected'; + const delay = this.#backoffMs; + this.#backoffMs = Math.min(this.#backoffMs * 2, MAX_BACKOFF_MS); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + this.#connect(); + }, delay); + } + + /** Connection-scoped state resets on a dropped socket (the ACL persists). */ + #dropTransientState(): void { + for (const state of this.#clients.values()) state.session?.dispose(); + for (const [clientId, state] of this.#clients) { + if (state.pending) this.#dismissApproval(clientId); + } + this.#clients.clear(); + } + + /** Get or create the per-client state record for `clientId`. */ + #clientState(clientId: string): ClientState { + let state = this.#clients.get(clientId); + if (!state) { + state = { established: false }; + this.#clients.set(clientId, state); + } + return state; + } + + #send(frame: HostFrame): void { + try { + this.#ws?.send(JSON.stringify(frame)); + } catch { + // socket mid-close + } + } + + // --- Frame handling (mirrors fake-host.mjs) --- + + #onFrame(raw: unknown): void { + let frame: ServerToHostFrame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : '') as ServerToHostFrame; + } catch { + return; + } + if ( + !frame || + typeof (frame as { t?: unknown }).t !== 'string' || + typeof (frame as { clientId?: unknown }).clientId !== 'string' + ) { + return; + } + const clientId = frame.clientId; + switch (frame.t) { + case 'pair': + return this.#onPair(clientId, frame.request); + case 'connect': + return this.#onConnect(clientId); + case 'connect2': + void this.#onConnect2(clientId, frame.request); + return; + case 'msg': + return this.#onMsg(clientId, frame.data); + case 'client-gone': + return this.#onClientGone(clientId); + default: + return; + } + } + + #onPair(clientId: string, request: PairingRequest): void { + const ticket = this.#ceremony.begin(request); + const pending: PendingPairing = { + clientId, + request, + requestedAt: this.#now(), + approve: (label) => this.#approvePairing(clientId, ticket.pairingId, label), + deny: (error) => this.#denyPairing(clientId, ticket.pairingId, error), + }; + this.#clientState(clientId).pending = pending; + this.#requestApproval(pending); + } + + /** The local approval — the ONLY path that writes the ACL. */ + #approvePairing(clientId: string, pairingId: string, label?: string): void { + const state = this.#clients.get(clientId); + if (!state?.pending) return; // already resolved + state.pending = undefined; + let record: HostAclRecord; + try { + record = this.#ceremony.approve(pairingId, { approvedBy: 'host-user', label }); + } catch (error) { + this.#send({ + t: 'pair-result', + clientId, + approved: false, + error: pairingApprovalError(error), + }); + this.#dismissApproval(clientId); + return; + } + this.#saveAcl(this.#enrollment.hostId, this.#acl.records()); + this.#send({ t: 'pair-result', clientId, approved: true, record }); + this.#dismissApproval(clientId); + } + + #denyPairing(clientId: string, pairingId: string, error = 'pairing denied by host'): void { + const state = this.#clients.get(clientId); + if (!state?.pending) return; + state.pending = undefined; + try { + this.#ceremony.deny(pairingId); + } catch { + // already expired/resolved — deny is still what we report. + } + this.#send({ t: 'pair-result', clientId, approved: false, error }); + this.#dismissApproval(clientId); + } + + #onConnect(clientId: string): void { + const { challenge, expiresAt } = this.#challenges.issue(); + this.#send({ t: 'challenge', clientId, challenge, expiresAt }); + } + + async #onConnect2(clientId: string, request: ConnectionRequest): Promise { + const decision = await authorizeConnection( + { + hostId: this.#enrollment.hostId, + acl: this.#acl, + challenges: this.#challenges, + policy: this.#policy, + }, + request, + ); + if (decision.allowed) this.#clientState(clientId).established = true; + // `failures` is optional on the wire; omit it on an allowed decision. + this.#send({ + t: 'decision', + clientId, + allowed: decision.allowed, + ...(decision.allowed ? {} : { failures: decision.failures }), + }); + } + + #onMsg(clientId: string, data: unknown): void { + const state = this.#clients.get(clientId); + if (!state?.established) return; // never before an allowed decision + let session = state.session; + if (!session) { + if (!this.#createSession) return; + session = this.#createSession({ + hostId: this.#enrollment.hostId, + send: (payload) => this.#send({ t: 'msg', clientId, data: payload }), + }); + state.session = session; + } + session.handle(data); + } + + #onClientGone(clientId: string): void { + this.#clients.get(clientId)?.session?.dispose(); + this.#clients.delete(clientId); + this.#dismissApproval(clientId); + } +} + +function pairingApprovalError(error: unknown): string { + if (error instanceof PairingError) { + return error.code === 'expired' + ? 'pairing approval expired' + : 'pairing approval is no longer pending'; + } + return 'pairing approval failed'; +} diff --git a/lib/src/remote/pocket-app/App.tsx b/lib/src/remote/pocket-app/App.tsx new file mode 100644 index 00000000..7d1fca63 --- /dev/null +++ b/lib/src/remote/pocket-app/App.tsx @@ -0,0 +1,363 @@ +/** + * Dormouse Pocket — the phone-side app (docs/specs/pocket-app.md). + * + * Auth screens over {@link PocketClient} — sign in (or first-time passkey setup) + * → pick a host (pair once, then connect) — then, on a successful connect, the + * real mobile experience: a {@link RemotePtyAdapter} over the session drives + * `MobileTerminalUi`/`MobileWall` (the same composition the website playground + * proves out with `FakePtyAdapter`). No bespoke terminal UI. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + PocketClient, + type ConnectDecision, + type PocketSocket, +} from '../client/pocket-client'; +import { browserWebAuthn } from '../client/webauthn'; +import { getOrCreateDeviceKey } from '../client/device-key'; +import { RemotePtyAdapter } from '../client/remote-adapter'; +import { setPlatform } from '../../lib/platform'; +import { disposeAllSessions, initAlertStateReceiver } from '../../lib/terminal-registry'; +import { PocketWall } from './PocketWall'; +import '../../index.css'; +import './pocket.css'; + +type Phase = 'auth' | 'hosts' | 'wall'; + +export interface HostView { + hostId: string; + label: string; + online: boolean; +} + +/** A phone-friendly default pairing label. */ +const DEVICE_LABEL = 'Dormouse Pocket'; + +export default function App(): React.ReactElement { + const client = useMemo( + () => + new PocketClient({ + wsBase: location.origin.replace(/^http/, 'ws'), + fetch: window.fetch.bind(window), + webauthn: browserWebAuthn, + createWebSocket: (url) => new WebSocket(url) as unknown as PocketSocket, + deviceKey: () => getOrCreateDeviceKey(), + }), + [], + ); + + const [phase, setPhase] = useState('auth'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [hosts, setHosts] = useState([]); + const [pairedIds, setPairedIds] = useState>(() => new Set()); + const [activeHost, setActiveHost] = useState(null); + const adapterRef = useRef(null); + + // The client nulls its socket on any close, so an action taken after a + // server restart / network drop must reopen it rather than reuse a dead + // socket (which would throw 'relay socket is not open'). Every user action + // that sends a frame funnels through here so it self-heals. + const ensureSocket = useCallback(async () => { + if (!client.socketOpen) await client.openSocket(); + }, [client]); + + const run = useCallback(async (label: string, fn: () => Promise) => { + setError(null); + setBusy(label); + try { + await fn(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(null); + } + }, []); + + const loadHosts = useCallback(async () => { + await ensureSocket(); + const list = await client.listHosts(); + setHosts(list); + setPairedIds(new Set(list.filter((h) => client.isPaired(h.hostId)).map((h) => h.hostId))); + setPhase('hosts'); + }, [client, ensureSocket]); + + /** Tear down the live session and return to the hosts list. */ + const teardownAdapter = useCallback(() => { + void adapterRef.current?.dispose(); + adapterRef.current = null; + disposeAllSessions(); + }, []); + + // Socket drop / host-gone: dispose the adapter and fall back to Hosts. + useEffect(() => { + client.setOnHostGone(() => { + teardownAdapter(); + setError('The host disconnected.'); + setActiveHost(null); + setPhase('hosts'); + }); + return () => client.setOnHostGone(null); + }, [client, teardownAdapter]); + + const onConnect = (host: HostView) => + run('connect', async () => { + await ensureSocket(); + const decision: ConnectDecision = await client.connect(host.hostId); + if (!decision.allowed) { + if (decision.pairingStale) { + setPairedIds((prev) => { + if (!prev.has(host.hostId)) return prev; + const next = new Set(prev); + next.delete(host.hostId); + return next; + }); + } + throw new Error(`Connection denied${decision.failures ? `: ${decision.failures.join(', ')}` : ''}`); + } + await client.hello(); + + // Stand up the remote adapter as the platform, prep a clean registry, + // then start watching the directory before the wall renders. + const adapter = new RemotePtyAdapter(client); + adapterRef.current = adapter; + setPlatform(adapter); + disposeAllSessions(); + initAlertStateReceiver(); + await adapter.init(); + + setActiveHost(host); + setPhase('wall'); + }); + + const onPair = (host: HostView) => + run('pair', async () => { + await ensureSocket(); + const result = await client.pair(host.hostId, DEVICE_LABEL); + if (!result.approved) throw new Error(result.error ?? 'Pairing was denied.'); + setPairedIds((prev) => new Set(prev).add(host.hostId)); + }); + + const leaveWall = () => { + teardownAdapter(); + client.close(); + setActiveHost(null); + setPhase('hosts'); + }; + + // --- Views --------------------------------------------------------------- + + if (phase === 'auth') { + return ( + + run('signin', async () => { + await client.signin(); + await loadHosts(); + })} + onSetup={(password, label) => + run('setup', async () => { + await client.setup(password, label); + await client.signin(); + await loadHosts(); + })} + /> + ); + } + + if (phase === 'hosts') { + return ( + pairedIds.has(id)} + onRefresh={() => run('refresh', loadHosts)} + onPair={onPair} + onConnect={onConnect} + /> + ); + } + + if (phase === 'wall' && activeHost && adapterRef.current) { + return ( +

+
+ +

{activeHost.label || activeHost.hostId}

+
+
+ +
+
+ ); + } + + return
; +} + +// --- SetupOrSignin --------------------------------------------------------- + +export function SetupOrSignin({ + busy, + error, + onSignin, + onSetup, +}: { + busy: string | null; + error: string | null; + onSignin: () => void; + onSetup: (password: string, label: string) => void; +}): React.ReactElement { + const [showSetup, setShowSetup] = useState(false); + const [password, setPassword] = useState(''); + const [label, setLabel] = useState('My Phone'); + + return ( +
+
+

Dormouse Pocket

+
+
+
+

Welcome back

+

+ Sign in with your passkey to reach your enrolled hosts and pick up a terminal session. +

+
+ {error ?
{error}
: null} + + + + + {showSetup ? ( +
+

+ Create the account and register this device's passkey. Requires the server's setup + password. +

+
+ + setPassword(e.target.value)} + /> +
+
+ + setLabel(e.target.value)} + /> +
+ +
+ ) : null} +
+
+ ); +} + +// --- HostsView ------------------------------------------------------------- + +export function HostsView({ + hosts, + busy, + error, + isPaired, + onRefresh, + onPair, + onConnect, +}: { + hosts: HostView[]; + busy: string | null; + error: string | null; + isPaired: (hostId: string) => boolean; + onRefresh: () => void; + onPair: (host: HostView) => void; + onConnect: (host: HostView) => void; +}): React.ReactElement { + return ( +
+
+

Hosts

+ +
+
+ {error ?
{error}
: null} + {hosts.length === 0 ? ( +
No hosts enrolled yet. Enroll one from your laptop.
+ ) : ( + hosts.map((host) => { + const paired = isPaired(host.hostId); + return ( +
+
+
{host.label || host.hostId}
+
{paired ? 'Paired' : 'Not paired'}
+
+ + {host.online ? 'online' : 'offline'} + +
+ {!paired ? ( + + ) : null} + +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/lib/src/remote/pocket-app/PocketWall.tsx b/lib/src/remote/pocket-app/PocketWall.tsx new file mode 100644 index 00000000..bac609a2 --- /dev/null +++ b/lib/src/remote/pocket-app/PocketWall.tsx @@ -0,0 +1,149 @@ +/** + * The Pocket wall experience: `MobileTerminalUi` + `MobileWall` driven by a + * connected {@link RemotePtyAdapter}. Same composition the website playground + * proves out with `FakePtyAdapter` (`PocketTerminalExperience`), minus the + * tutorial/shell-registry machinery — the Host owns the shells here. + * + * Sessions come straight from the adapter's directory snapshot (id = surfaceId). + * v1 allows one attachment per session, so every active-pane change funnels + * through {@link RemotePtyAdapter.setActivePane}; the registry's own resize path + * keeps the attached pane sized. Writes and paste target the active pane. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import type { DirectoryEntry } from 'server-lib-common'; +import { + MobileTerminalUi, + type MobileTerminalKeyboardMode, + type MobileTerminalTouchMode, +} from '../../components/MobileTerminalUi'; +import { MobileWall } from '../../components/MobileWall'; +import { restoreActiveTheme } from '../../lib/themes'; +import { + getMouseSelectionSnapshot, + setOverride as setMouseOverride, + subscribeToMouseSelection, +} from '../../lib/mouse-selection'; +import { getTerminalInstance, refitSession } from '../../lib/terminal-registry'; +import { doPaste } from '../../lib/clipboard'; +import type { RemotePtyAdapter } from '../client/remote-adapter'; +import { activatePane, directorySessionItems, directoryWallSessions } from './wall-model'; + +/** Same default theme the website playground restores, unless the user picked one. */ +const POCKET_THEME_ID = 'vscode.theme-kimbie-dark.kimbie-dark'; + +const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +function usePocketTheme() { + const restoredRef = useRef(false); + if (!restoredRef.current) { + restoreActiveTheme(POCKET_THEME_ID); + restoredRef.current = true; + } + // Repeat after hydration so MobileWall reads real theme variables even if + // React reconciled away render-time body styles. + useBrowserLayoutEffect(() => { + restoreActiveTheme(POCKET_THEME_ID); + }, []); +} + +export function PocketWall({ adapter }: { adapter: RemotePtyAdapter }): React.ReactElement { + usePocketTheme(); + const [entries, setEntries] = useState(() => adapter.getDirectoryEntries()); + const [activePaneId, setActivePaneId] = useState(null); + const [touchMode, setTouchMode] = useState('gestures'); + const [keyboardMode, setKeyboardMode] = useState('type'); + + // Track the live directory. Re-read on subscribe in case a snapshot landed + // between the initial render and this effect. + useEffect(() => { + setEntries(adapter.getDirectoryEntries()); + return adapter.subscribeDirectory(setEntries); + }, [adapter]); + + // Default to (and stay on a valid) pane as the directory changes. + useEffect(() => { + if (activePaneId && entries.some((entry) => entry.surfaceId === activePaneId)) return; + setActivePaneId(entries[0]?.surfaceId ?? null); + }, [entries, activePaneId]); + + // One attachment at a time: on every active-pane change, attach it with the + // pane's current xterm dims (if it exists yet), then refit through the now- + // valid attached resize path. + useEffect(() => { + if (!activePaneId) return; + const term = getTerminalInstance(activePaneId); + const dims = term ? { cols: term.cols, rows: term.rows } : null; + void activatePane(adapter, activePaneId, dims, refitSession); + }, [adapter, activePaneId]); + + const wallSessions = useMemo(() => directoryWallSessions(entries), [entries]); + const sessionItems = useMemo( + () => directorySessionItems(entries, activePaneId), + [entries, activePaneId], + ); + + const mouseStates = useSyncExternalStore( + subscribeToMouseSelection, + getMouseSelectionSnapshot, + getMouseSelectionSnapshot, + ); + const activeMouseState = activePaneId ? mouseStates.get(activePaneId) : undefined; + 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. + 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); + } + }, [entries, mouseStates, touchMode]); + + const handleSendInput = useCallback( + (data: string) => { + if (activePaneId) adapter.writePty(activePaneId, data); + }, + [adapter, activePaneId], + ); + + const handlePaste = useCallback(async () => { + if (!activePaneId) return; + await doPaste(activePaneId); + }, [activePaneId]); + + return ( + setKeyboardMode('sessions')} + showKillButton={false} + /> + } + interactive + activeTouchMode={touchMode} + onTouchModeChange={setTouchMode} + activeKeyboardMode={keyboardMode} + onKeyboardModeChange={setKeyboardMode} + cursorTouchAvailable={cursorTouchAvailable} + sessions={sessionItems} + onSessionSelect={setActivePaneId} + onSendInput={handleSendInput} + onPaste={handlePaste} + /> + ); +} diff --git a/lib/src/remote/pocket-app/main.tsx b/lib/src/remote/pocket-app/main.tsx new file mode 100644 index 00000000..dcb8f854 --- /dev/null +++ b/lib/src/remote/pocket-app/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +const root = document.getElementById('pocket-root'); +if (!root) throw new Error('#pocket-root is missing'); + +createRoot(root).render( + + + , +); diff --git a/lib/src/remote/pocket-app/pocket.css b/lib/src/remote/pocket-app/pocket.css new file mode 100644 index 00000000..e64d1f65 --- /dev/null +++ b/lib/src/remote/pocket-app/pocket.css @@ -0,0 +1,289 @@ +/* + * Dark, phone-first styling for the Dormouse Pocket auth views (setup/signin → + * hosts). Self-contained plain-CSS variables, independent of the `--vscode-*` + * token system the wall uses — the two never overlap (auth uses `pk-*`, the wall + * lives inside `MobileTerminalUi`). Imported after `index.css`, so these + * unlayered rules win over Tailwind's layered preflight. + */ + +:root { + --pk-bg: #16181d; + --pk-surface: #1e2128; + --pk-surface-raised: #262a33; + --pk-border: #333844; + --pk-fg: #e6e8ec; + --pk-muted: #9aa1ad; + --pk-accent: #4a9eff; + --pk-accent-fg: #0b1220; + --pk-danger: #f0616d; + --pk-ok: #3fb98a; + --pk-ring: #4a9eff; + --pk-font: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + color-scheme: dark; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + background: var(--pk-bg); + color: var(--pk-fg); + font-family: var(--pk-font); + -webkit-text-size-adjust: 100%; + overscroll-behavior: none; +} + +#pocket-root { + height: 100dvh; + display: flex; + flex-direction: column; +} + +.pk-app { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +/* --- Header --- */ +.pk-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + padding-top: max(12px, env(safe-area-inset-top)); + background: var(--pk-surface); + border-bottom: 1px solid var(--pk-border); + flex: 0 0 auto; +} + +.pk-header h1 { + font-size: 15px; + font-weight: 600; + margin: 0; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pk-header .pk-sub { + font-size: 12px; + color: var(--pk-muted); + font-weight: 400; +} + +/* --- Scrolling content --- */ +.pk-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 16px 14px; + padding-bottom: max(16px, env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + gap: 14px; +} + +.pk-body.pk-center { + justify-content: center; +} + +/* --- Wall host: the flex slot MobileTerminalUi fills below the app header --- */ +.pk-wall-host { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* --- Cards / list rows --- */ +.pk-card { + background: var(--pk-surface); + border: 1px solid var(--pk-border); + border-radius: 12px; + padding: 14px; +} + +.pk-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + background: var(--pk-surface); + border: 1px solid var(--pk-border); + border-radius: 12px; + padding: 14px; + color: inherit; + font: inherit; + cursor: pointer; +} + +.pk-row:active { + background: var(--pk-surface-raised); +} + +.pk-row-main { + flex: 1; + min-width: 0; +} + +.pk-row-title { + font-size: 15px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pk-row-secondary { + font-size: 12px; + color: var(--pk-muted); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pk-row-actions { + display: flex; + gap: 8px; + flex: 0 0 auto; +} + +/* --- Badges --- */ +.pk-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid var(--pk-border); + color: var(--pk-muted); + white-space: nowrap; +} + +.pk-badge.online { + color: var(--pk-ok); + border-color: color-mix(in srgb, var(--pk-ok) 45%, transparent); +} + +.pk-badge.offline { + color: var(--pk-muted); +} + +/* --- Buttons --- */ +.pk-btn { + font: inherit; + font-size: 14px; + font-weight: 600; + border-radius: 10px; + border: 1px solid var(--pk-border); + background: var(--pk-surface-raised); + color: var(--pk-fg); + padding: 10px 14px; + cursor: pointer; + min-height: 42px; +} + +.pk-btn:active { + filter: brightness(1.15); +} + +.pk-btn:disabled { + opacity: 0.5; + pointer-events: none; +} + +.pk-btn.primary { + background: var(--pk-accent); + color: var(--pk-accent-fg); + border-color: transparent; +} + +.pk-btn.ghost { + background: transparent; +} + +.pk-btn.small { + padding: 8px 12px; + min-height: 36px; + font-size: 13px; +} + +.pk-btn.block { + width: 100%; +} + +/* --- Forms --- */ +.pk-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.pk-field label { + font-size: 12px; + color: var(--pk-muted); +} + +.pk-input { + font: inherit; + font-size: 16px; /* >=16px prevents iOS zoom-on-focus */ + background: var(--pk-bg); + color: var(--pk-fg); + border: 1px solid var(--pk-border); + border-radius: 10px; + padding: 12px; + width: 100%; +} + +.pk-input:focus { + outline: 2px solid var(--pk-ring); + outline-offset: -1px; +} + +/* --- Misc text --- */ +.pk-title { + font-size: 22px; + font-weight: 700; + margin: 0 0 4px; +} + +.pk-lead { + color: var(--pk-muted); + font-size: 13px; + line-height: 1.5; + margin: 0; +} + +.pk-error { + color: var(--pk-danger); + font-size: 13px; + background: color-mix(in srgb, var(--pk-danger) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--pk-danger) 40%, transparent); + border-radius: 10px; + padding: 10px 12px; +} + +.pk-empty { + color: var(--pk-muted); + font-size: 13px; + text-align: center; + padding: 32px 12px; +} + +.pk-disclosure { + background: transparent; + border: none; + color: var(--pk-accent); + font: inherit; + font-size: 13px; + padding: 8px 0; + cursor: pointer; + text-align: left; +} diff --git a/lib/src/remote/pocket-app/wall-model.test.ts b/lib/src/remote/pocket-app/wall-model.test.ts new file mode 100644 index 00000000..5256dffe --- /dev/null +++ b/lib/src/remote/pocket-app/wall-model.test.ts @@ -0,0 +1,125 @@ +/** + * Pure wall-model logic: directory snapshots → MobileWall/MobileTerminalUi + * session shapes, and the active-pane-change → setActivePane sequencing the + * wall performs (exercised against a recording fake adapter). + */ + +import { describe, expect, it } from 'vitest'; +import type { DirectoryEntry } from 'server-lib-common'; + +import { + activatePane, + directorySessionItems, + directoryWallSessions, + type PaneActivator, +} from './wall-model'; + +function entry(surfaceId: string, over: Partial = {}): DirectoryEntry { + return { + paneRef: surfaceId, + surfaceId, + type: 'terminal', + title: surfaceId, + focused: false, + alive: true, + ringing: false, + hasTODO: false, + ...over, + }; +} + +describe('directoryWallSessions', () => { + it('maps id = surfaceId and title from the entry, in Host order', () => { + const sessions = directoryWallSessions([entry('s1', { title: 'zsh' }), entry('s2', { title: 'vim' })]); + expect(sessions).toEqual([ + { id: 's1', title: 'zsh' }, + { id: 's2', title: 'vim' }, + ]); + }); + + it('falls back to a default title when the Host sends an empty one', () => { + expect(directoryWallSessions([entry('s1', { title: '' })])).toEqual([{ id: 's1', title: 'Terminal' }]); + }); +}); + +describe('directorySessionItems', () => { + it('marks the active pane and carries title/secondary', () => { + const items = directorySessionItems( + [entry('s1', { title: 'zsh', cwd: '/home/me' }), entry('s2', { title: 'vim' })], + 's2', + ); + expect(items).toEqual([ + { id: 's1', title: 'zsh', secondary: '/home/me', active: false, status: undefined, todo: false }, + { id: 's2', title: 'vim', secondary: null, active: true, status: undefined, todo: false }, + ]); + }); + + it('maps ringing → ALERT_RINGING status and hasTODO → todo pill', () => { + const [ringingItem, todoItem] = directorySessionItems( + [entry('s1', { ringing: true }), entry('s2', { hasTODO: true })], + null, + ); + expect(ringingItem.status).toBe('ALERT_RINGING'); + expect(ringingItem.todo).toBe(false); + expect(todoItem.status).toBeUndefined(); + expect(todoItem.todo).toBe(true); + }); + + it('uses activity as the secondary line when there is no cwd', () => { + const [item] = directorySessionItems([entry('s1', { activity: 'running' })], null); + expect(item.secondary).toBe('running'); + }); + + it('prefers cwd over activity for the secondary line', () => { + const [item] = directorySessionItems([entry('s1', { activity: 'running', cwd: '/tmp' })], null); + expect(item.secondary).toBe('/tmp'); + }); +}); + +describe('activatePane', () => { + class RecordingAdapter implements PaneActivator { + readonly calls: Array<{ id: string; cols?: number; rows?: number }> = []; + async setActivePane(id: string, cols?: number, rows?: number): Promise { + this.calls.push({ id, cols, rows }); + } + } + + it('forwards the pane dims and runs onAttached after the attach resolves', async () => { + const adapter = new RecordingAdapter(); + const order: string[] = []; + await activatePane(adapter, 's1', { cols: 40, rows: 60 }, (id) => order.push(`attached:${id}`)); + + expect(adapter.calls).toEqual([{ id: 's1', cols: 40, rows: 60 }]); + expect(order).toEqual(['attached:s1']); + }); + + it('passes undefined dims when none are known (adapter default + registry corrects)', async () => { + const adapter = new RecordingAdapter(); + await activatePane(adapter, 's1', null); + expect(adapter.calls).toEqual([{ id: 's1', cols: undefined, rows: undefined }]); + }); + + it('issues one setActivePane per active-pane change, in order', async () => { + const adapter = new RecordingAdapter(); + await activatePane(adapter, 's1', { cols: 80, rows: 24 }); + await activatePane(adapter, 's2', { cols: 100, rows: 30 }); + expect(adapter.calls).toEqual([ + { id: 's1', cols: 80, rows: 24 }, + { id: 's2', cols: 100, rows: 30 }, + ]); + }); + + it('waits for an async attach before signaling onAttached', async () => { + let release: (() => void) | null = null; + const adapter: PaneActivator = { + setActivePane: () => new Promise((resolve) => { release = resolve; }), + }; + const order: string[] = []; + const done = activatePane(adapter, 's1', null, () => order.push('attached')); + + expect(order).toEqual([]); // still attaching + release?.(); + await done; + expect(order).toEqual(['attached']); + }); +}); diff --git a/lib/src/remote/pocket-app/wall-model.ts b/lib/src/remote/pocket-app/wall-model.ts new file mode 100644 index 00000000..3a5ca65f --- /dev/null +++ b/lib/src/remote/pocket-app/wall-model.ts @@ -0,0 +1,86 @@ +/** + * Pure glue between a remote `directory.snapshot` and the mobile wall UI. Kept + * free of React and the terminal registry so it is unit-testable in isolation + * (`wall-model.test.ts`). + * + * Two shapes come out of one directory snapshot: + * - {@link directoryWallSessions} — the `{id,title}` list `MobileWall` renders + * (which pane is live + its header title). + * - {@link directorySessionItems} — the badge-carrying items the session list + * in `MobileTerminalUi` shows. On the remote side, badge state is + * Host-authoritative and rides the directory (not local terminal parsing), + * so this replaces `useMobileWallSessionItems` rather than layering on it. + */ + +import type { DirectoryEntry } from 'server-lib-common'; +import type { MobileWallSession } from '../../components/MobileWall'; +import type { MobileTerminalSessionItem } from '../../components/MobileTerminalUi'; +import type { SessionStatus } from '../../lib/terminal-registry'; + +const DEFAULT_TITLE = 'Terminal'; + +/** Title for a surface, falling back to a friendly default when the Host sends none. */ +function paneTitle(entry: DirectoryEntry): string { + return entry.title || DEFAULT_TITLE; +} + +/** The `{id,title}` sessions `MobileWall` mounts, in Host order. */ +export function directoryWallSessions(entries: DirectoryEntry[]): MobileWallSession[] { + return entries.map((entry) => ({ id: entry.surfaceId, title: paneTitle(entry) })); +} + +/** + * Map the directory snapshot onto the affordances a {@link MobileTerminalSessionItem} + * exposes: `ringing` → `ALERT_RINGING` (the only status the session list renders + * a bell for), `hasTODO` → the TODO pill, and `cwd`/`activity` → the secondary + * line. `id` is the surfaceId so the registry binds each pane's xterm by it. + */ +export function directorySessionItems( + entries: DirectoryEntry[], + activeSurfaceId: string | null, +): MobileTerminalSessionItem[] { + return entries.map((entry) => ({ + id: entry.surfaceId, + title: paneTitle(entry), + secondary: secondaryLine(entry), + active: entry.surfaceId === activeSurfaceId, + status: statusFor(entry), + todo: entry.hasTODO, + })); +} + +function statusFor(entry: DirectoryEntry): SessionStatus | undefined { + return entry.ringing ? 'ALERT_RINGING' : undefined; +} + +function secondaryLine(entry: DirectoryEntry): string | null { + if (entry.cwd) return entry.cwd; + if (entry.activity && entry.activity !== 'unknown') return entry.activity; + return null; +} + +export interface PaneDims { + cols: number; + rows: number; +} + +/** The slice of {@link RemotePtyAdapter} the wall drives on an active-pane change. */ +export interface PaneActivator { + setActivePane(id: string, cols?: number, rows?: number): Promise | void; +} + +/** + * Attach `id` as the active pane, forwarding the pane's current dims when known + * (else the adapter defaults and the registry's resize path corrects it), then + * run `onAttached` — the wall uses it to refit xterm through the now-valid, + * attached resize path. Awaiting keeps the refit strictly after the attach. + */ +export async function activatePane( + adapter: PaneActivator, + id: string, + dims: PaneDims | null, + onAttached?: (id: string) => void, +): Promise { + await Promise.resolve(adapter.setActivePane(id, dims?.cols, dims?.rows)); + onAttached?.(id); +} diff --git a/lib/src/remote/ws.ts b/lib/src/remote/ws.ts new file mode 100644 index 00000000..92a74a12 --- /dev/null +++ b/lib/src/remote/ws.ts @@ -0,0 +1,14 @@ +/** + * The minimal WebSocket surface the remote client and host actually use — just + * enough to send, close, and listen, so tests can inject a fake in place of a + * real browser `WebSocket`. Shared by both sides so the contract cannot drift. + */ +export interface RemoteWebSocket { + send(data: string): void; + close(): void; + addEventListener( + type: 'open' | 'message' | 'close' | 'error', + handler: (ev: unknown) => void, + ): void; + readyState: number; +} diff --git a/lib/src/stories/HostsView.stories.tsx b/lib/src/stories/HostsView.stories.tsx new file mode 100644 index 00000000..ac1acddd --- /dev/null +++ b/lib/src/stories/HostsView.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactNode } from 'react'; +// Importing from App.tsx runs its `index.css` / `pocket.css` side-effect imports, +// so the `pk-*` styles and `:root` palette are loaded for these stories. +import { HostsView, type HostView } from '../remote/pocket-app/App'; + +// A paired online host, an unpaired online host (shows Pair + Connect), and an +// offline host (both actions disabled) — the full row matrix in one frame. +const MIXED_HOSTS: HostView[] = [ + { hostId: 'host-studio', label: 'Studio iMac', online: true }, + { hostId: 'host-laptop', label: 'MacBook Pro', online: true }, + { hostId: 'host-nas', label: 'Basement NAS', online: false }, +]; + +const PAIRED = new Set(['host-studio']); +const isPaired = (hostId: string) => PAIRED.has(hostId); + +// Phone-sized frame with the pocket dark background, matching the real app shell. +function PhoneFrame({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +const meta: Meta = { + title: 'Pocket/HostsView', + component: HostsView, + parameters: { layout: 'centered' }, + args: { + hosts: MIXED_HOSTS, + busy: null, + error: null, + isPaired, + onRefresh: () => {}, + onPair: () => {}, + onConnect: () => {}, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// No hosts enrolled yet → the `pk-empty` message. +export const Empty: Story = { + args: { hosts: [] }, +}; + +// Paired+online (Connect only), unpaired+online (Pair + Connect), offline (disabled). +export const MixedList: Story = {}; + +// Pairing in flight → the unpaired online host's Pair button shows "…". +export const Pairing: Story = { + args: { busy: 'pair' }, +}; + +// Connecting in flight → Connect buttons show "…" and disable. +export const Connecting: Story = { + args: { busy: 'connect' }, +}; + +// Refreshing the list → the header Refresh button shows "…". +export const Refreshing: Story = { + args: { busy: 'refresh' }, +}; + +// Host dropped → the `pk-error` banner above the list. +export const Error: Story = { + args: { error: 'The host disconnected.' }, +}; diff --git a/lib/src/stories/PocketWall.stories.tsx b/lib/src/stories/PocketWall.stories.tsx new file mode 100644 index 00000000..8dd43c78 --- /dev/null +++ b/lib/src/stories/PocketWall.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useEffect, useRef } from 'react'; +import { toBase64Url, utf8Encode, type DirectoryEntry } from 'server-lib-common'; +import { PocketWall } from '../remote/pocket-app/PocketWall'; +import { RemotePtyAdapter, type RemoteAdapterClient } from '../remote/client/remote-adapter'; +import type { TerminalHandlers } from '../remote/client/pocket-client'; +import { setPlatform } from '../lib/platform'; +import { disposeAllSessions, initAlertStateReceiver } from '../lib/terminal-registry'; + +// The adapter decodes `terminal.data` as base64url UTF-8, so encode splash text +// the same way the real wire does. +const b64 = (text: string) => toBase64Url(utf8Encode(text)); + +// One-terminal directory snapshot (id = surfaceId, as the registry binds panes). +const SINGLE_SESSION: DirectoryEntry[] = [ + { + paneRef: 'pane-1', + surfaceId: 'pane-1', + type: 'terminal', + title: 'zsh', + focused: true, + activity: 'prompt', + alive: true, + ringing: false, + hasTODO: false, + }, +]; + +// Streamed once the wall attaches the pane — stands in for the Host's repaint. +const SPLASH = b64( + [ + '\x1b[32mDormouse Pocket\x1b[0m connected to \x1b[36mStudio iMac\x1b[0m\r\n', + '\x1b[2mremote session · one attachment at a time\x1b[0m\r\n', + '\r\n', + 'ned@studio ~/projects/dormouse % \x1b[7m \x1b[0m\r\n', + ].join(''), +); + +/** + * Network-free {@link RemoteAdapterClient}: serves a fixed directory snapshot and + * echoes a splash through the attach handlers, so `PocketWall` renders its real + * `MobileTerminalUi` + `MobileWall` composition without a live Host. + */ +class FakeRemoteClient implements RemoteAdapterClient { + #snapshot: DirectoryEntry[]; + #n = 0; + + constructor(snapshot: DirectoryEntry[]) { + this.#snapshot = snapshot; + } + + async watchDirectory(onSnapshot: (entries: DirectoryEntry[]) => void): Promise { + // Deliver synchronously so the adapter has entries before PocketWall's first + // render reads `getDirectoryEntries()`. + onSnapshot(this.#snapshot); + return 'dir-sub'; + } + + async attach(_surfaceId: string, cols: number, rows: number, handlers: TerminalHandlers) { + const subId = `attach-${++this.#n}`; + // Push after the pane's xterm (and its onPtyData handler) is mounted. + queueMicrotask(() => handlers.onData(SPLASH)); + return { subId, result: { cols, rows } }; + } + + async write() {} + async resize() {} + async detach() {} + unsubscribe() {} +} + +function PocketWallStory() { + const adapterRef = useRef(null); + if (!adapterRef.current) { + // Mirror App.tsx's onConnect: stand up the adapter as the platform, prep a + // clean registry, then start the directory watch. + const adapter = new RemotePtyAdapter(new FakeRemoteClient(SINGLE_SESSION)); + setPlatform(adapter); + disposeAllSessions(); + initAlertStateReceiver(); + void adapter.init(); + adapterRef.current = adapter; + } + + useEffect(() => { + const adapter = adapterRef.current; + return () => { + void adapter?.dispose(); + disposeAllSessions(); + }; + }, []); + + return ( +
+ +
+ ); +} + +const meta: Meta = { + title: 'Pocket/PocketWall', + component: PocketWallStory, + parameters: { layout: 'centered' }, +}; + +export default meta; +type Story = StoryObj; + +// Smoke story: one attached session streaming a splash. Proves the +// adapter → directory → MobileWall wiring (the composition itself is also +// exercised by `App/MobileTerminalUi`'s PocketWall story with a FakePtyAdapter). +export const SingleSession: Story = {}; diff --git a/lib/src/stories/RemotePairingModal.stories.tsx b/lib/src/stories/RemotePairingModal.stories.tsx new file mode 100644 index 00000000..25537275 --- /dev/null +++ b/lib/src/stories/RemotePairingModal.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { PairingRequest } from 'server-lib-common'; +import { RemotePairingModal } from '../remote/host/RemotePairingModal'; + +// A plausible pairing request; individual stories override the fields that +// drive the visible surface (requested label, account, key fingerprint). +function pairingRequest(over: Partial = {}): PairingRequest { + return { + accountId: 'ned@example.com', + passkeyCredentialId: 'cred-abc123', + passkeyPublicKeyHash: 'ph_9f2c1a77', + devicePublicKey: 'abcd1234ef567890deadbeefcafef00d', + requestedLabel: 'Ned’s iPhone', + ...over, + }; +} + +function RemotePairingModalStory({ request }: { request: PairingRequest }) { + return ( +
+ {/* Simulated terminal content behind the viewport-scoped modal. */} +
+
dev@dormouse:~/repo$ dormouse remote enroll
+
Waiting for a device to pair…
+
+ {}} onDeny={() => {}} /> +
+ ); +} + +const meta: Meta = { + title: 'Modals/RemotePairingModal', + component: RemotePairingModalStory, +}; + +export default meta; +type Story = StoryObj; + +// Named device, normal account, key long enough to show an `abcd1234…` fingerprint. +export const Default: Story = { + args: { request: pairingRequest() }, +}; + +// Empty requested label → the `(unnamed)` fallback. +export const UnnamedDevice: Story = { + args: { request: pairingRequest({ requestedLabel: '' }) }, +}; + +// Long account + long label to exercise the review block's `break-words` wrapping. +export const LongValues: Story = { + args: { + request: pairingRequest({ + requestedLabel: + 'Ned’s work iPhone 15 Pro Max in the downstairs office by the window (personal profile)', + accountId: 'ned.twigg+dormouse-remote-selfhost-poc-longaddress@subdomain.example-company.com', + }), + }, +}; diff --git a/lib/src/stories/SetupOrSignin.stories.tsx b/lib/src/stories/SetupOrSignin.stories.tsx new file mode 100644 index 00000000..65b66769 --- /dev/null +++ b/lib/src/stories/SetupOrSignin.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactNode } from 'react'; +// Importing from App.tsx runs its `index.css` / `pocket.css` side-effect imports, +// so the `pk-*` styles and `:root` palette are loaded for these stories. +import { SetupOrSignin } from '../remote/pocket-app/App'; + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// The first-time-setup panel is internal state (`useState(showSetup)`), toggled +// by the `+ First-time setup` disclosure. Click it so the setup fields render. +async function openSetup({ canvasElement }: { canvasElement: HTMLElement }) { + await wait(50); + const disclosure = Array.from(canvasElement.querySelectorAll('button')).find( + (button) => button.textContent?.includes('First-time setup'), + ); + disclosure?.click(); + await wait(50); +} + +// Phone-sized frame with the pocket dark background, matching the real app shell. +function PhoneFrame({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +const meta: Meta = { + title: 'Pocket/SetupOrSignin', + component: SetupOrSignin, + parameters: { layout: 'centered' }, + args: { + busy: null, + error: null, + onSignin: () => {}, + onSetup: () => {}, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// Idle: welcome copy, "Sign in with passkey", setup collapsed. +export const Welcome: Story = {}; + +// Disclosure opened → setup password + label fields + "Create passkey & sign in". +export const SetupExpanded: Story = { + play: openSetup, +}; + +// Sign-in in flight: primary button reads "Signing in…" and is disabled. +export const SigningIn: Story = { + args: { busy: 'signin' }, +}; + +// Account creation in flight: setup panel open with the button reading "Creating…". +export const CreatingAccount: Story = { + args: { busy: 'setup' }, + play: openSetup, +}; + +// Failed sign-in: the red `pk-error` banner above the button. +export const Error: Story = { + args: { error: 'Passkey sign-in was cancelled.' }, +}; diff --git a/lib/tsconfig.node.json b/lib/tsconfig.node.json index 8e5b203b..405fa64c 100644 --- a/lib/tsconfig.node.json +++ b/lib/tsconfig.node.json @@ -14,5 +14,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vite.pocket.config.ts"] } diff --git a/lib/vite.pocket.config.ts b/lib/vite.pocket.config.ts new file mode 100644 index 00000000..9d1a1c1c --- /dev/null +++ b/lib/vite.pocket.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +// Second entry, separate from the main `lib` app (index.html): the standalone +// Pocket phone web app. Its HTML lives in `pocket/index.html` and pulls in +// `src/remote/pocket-app/main.tsx`; the build lands in `dist-pocket/` for the +// server to serve statically (docs/specs/pocket-app.md). It shares the full +// terminal UI (`MobileTerminalUi`/`MobileWall`) with the main app, so it needs +// the same Tailwind + `--vscode-*` theme plumbing (`src/index.css`); the auth +// views layer their own self-contained `pocket.css` on top. +export default defineConfig({ + plugins: [react(), tailwindcss()], + root: fileURLToPath(new URL("./pocket", import.meta.url)), + resolve: { + dedupe: ["react", "react-dom"], + alias: { + // The Pocket app imports the remote modules, which import + // `server-lib-common`; its package `exports` resolve to a `dist` that a + // clean checkout has not built yet (this vite-only build has no `tsc -b` + // step to generate it). Alias to source, same as the website and + // Storybook configs. + "server-lib-common": fileURLToPath(new URL("../server-lib-common/src", import.meta.url)), + }, + }, + build: { + outDir: fileURLToPath(new URL("./dist-pocket", import.meta.url)), + emptyOutDir: true, + }, +}); diff --git a/package.json b/package.json index a15cb11f..c8c601fe 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:standalone": "node scripts/free-dev-port.mjs && pnpm --filter dormouse-standalone tauri dev", "dev:standalone:ab": "pnpm --filter dormouse-standalone dev:agent-browser", "dev:server": "pnpm --filter server dev", + "dev:pocket-server": "pnpm --filter dormouse-lib build:pocket && pnpm --filter server build && pnpm --filter server start", "dev:website": "pnpm --filter dormouse-website dev", "build:vscode": "pnpm --filter dormouse-lib build && pnpm --filter dormouse build:frontend && pnpm --filter dormouse build", "build:standalone": "pnpm --filter dormouse-standalone tauri build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4137d2e8..19eb81a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: '@hono/node-server': specifier: ^1.13.0 version: 1.19.14(hono@4.12.27) + '@hono/node-ws': + specifier: ^1.3.1 + version: 1.3.1(@hono/node-server@1.19.14(hono@4.12.27))(hono@4.12.27) hono: specifier: ^4.6.0 version: 4.12.27 @@ -757,6 +760,13 @@ packages: peerDependencies: hono: ^4 + '@hono/node-ws@1.3.1': + resolution: {integrity: sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.19.11 + hono: ^4.6.0 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -4616,6 +4626,15 @@ snapshots: dependencies: hono: 4.12.27 + '@hono/node-ws@1.3.1(@hono/node-server@1.19.14(hono@4.12.27))(hono@4.12.27)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + hono: 4.12.27 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': diff --git a/server-lib-common/src/index.ts b/server-lib-common/src/index.ts index af9cead5..9cbc47b5 100644 --- a/server-lib-common/src/index.ts +++ b/server-lib-common/src/index.ts @@ -12,6 +12,7 @@ * connection authorization. */ +export * from './remote/wire.js'; export * from './security/webcrypto.js'; export * from './security/bytes.js'; export * from './security/ecdsa.js'; diff --git a/server-lib-common/src/remote/wire.ts b/server-lib-common/src/remote/wire.ts new file mode 100644 index 00000000..cca77804 --- /dev/null +++ b/server-lib-common/src/remote/wire.ts @@ -0,0 +1,244 @@ +/** + * The wire contract for the selfhost POC (docs/specs/server.md): HTTP routes + * and payloads, relay frames, and the terminal-only remote-api v1 messages. + * Shared by `server`, the Host module in `lib`, and the Pocket UI so the + * three sides cannot drift — the same pattern as HELLO_ROUTE. + */ + +import type { HostAclRecord } from '../security/acl.js'; +import type { ConnectionFailure, ConnectionRequest } from '../security/connection.js'; +import type { PairingRequest } from '../security/pairing.js'; +import type { PasskeyAssertion } from '../security/passkey.js'; + +// --------------------------------------------------------------------------- +// HTTP API (see server.md "HTTP API") + +export const API_ROUTES = { + setupBegin: '/api/setup/begin', + setupFinish: '/api/setup/finish', + signinBegin: '/api/signin/begin', + signinFinish: '/api/signin/finish', + hostEnroll: '/api/host/enroll', + hosts: '/api/hosts', +} as const; + +export const WS_ROUTES = { + host: '/ws/host', + client: '/ws/client', +} as const; + +/** WS auth rides a query parameter (browsers cannot set WS headers). */ +export const WS_TOKEN_PARAM = 'token'; + +/** The selfhost mode has exactly one account. */ +export const SELFHOST_ACCOUNT_ID = 'owner'; + +export interface SetupBeginRequest { + password: string; +} +export interface SetupBeginResponse { + /** Base64url challenge for `navigator.credentials.create()`. */ + challenge: string; + rpId: string; + accountId: string; +} + +export interface SetupFinishRequest { + password: string; + /** Base64url credential id (`PublicKeyCredential.id`). */ + credentialId: string; + /** Base64url SPKI from `response.getPublicKey()`. */ + publicKey: string; + /** Base64url `response.clientDataJSON` (type `webauthn.create`). */ + clientDataJSON: string; + label: string; +} +export interface SetupFinishResponse { + accountId: string; + credentialId: string; +} + +export interface SigninBeginResponse { + /** Base64url challenge for `navigator.credentials.get()`. */ + challenge: string; + rpId: string; +} + +export interface SigninFinishRequest { + assertion: PasskeyAssertion; +} +export interface SigninFinishResponse { + /** Bearer token for `/api/hosts` and the `token` param of /ws/client. */ + sessionToken: string; + accountId: string; + expiresAt: number; +} + +export interface HostEnrollRequest { + password: string; + label: string; +} +export interface HostEnrollResponse { + hostId: string; + /** Bearer credential for the `token` param of /ws/host. */ + hostToken: string; + /** What the Host must enforce as its ConnectionPolicy. */ + origin: string; + rpId: string; +} + +export interface HostsResponse { + hosts: Array<{ hostId: string; label: string; online: boolean }>; +} + +// --------------------------------------------------------------------------- +// Relay frames (see server.md "Relay"). One JSON frame per WS message. +// `clientId` is assigned by the server per client socket; the client itself +// never sees or sends it. + +/** Client → server. `msg` is only forwarded once the session is authorized. */ +export type ClientFrame = + | { t: 'pair'; hostId: string; request: PairingRequest } + | { t: 'connect'; hostId: string } + | { t: 'connect2'; hostId: string; request: ConnectionRequest } + | { t: 'msg'; data: unknown }; + +/** Server → client. */ +export type ServerToClientFrame = + | { t: 'pair-result'; approved: boolean; record?: HostAclRecord; error?: string } + | { t: 'challenge'; hostId: string; challenge: string; expiresAt: number } + | { t: 'decision'; allowed: boolean; failures?: readonly ConnectionFailure[] } + | { t: 'msg'; data: unknown } + | { t: 'host-gone' } + | { t: 'error'; error: string }; + +/** Server → host. */ +export type ServerToHostFrame = + | { t: 'pair'; clientId: string; request: PairingRequest } + | { t: 'connect'; clientId: string } + | { t: 'connect2'; clientId: string; request: ConnectionRequest } + | { t: 'msg'; clientId: string; data: unknown } + | { t: 'client-gone'; clientId: string }; + +/** Host → server. */ +export type HostFrame = + | { t: 'pair-result'; clientId: string; approved: boolean; record?: HostAclRecord; error?: string } + | { t: 'challenge'; clientId: string; challenge: string; expiresAt: number } + | { t: 'decision'; clientId: string; allowed: boolean; failures?: readonly ConnectionFailure[] } + | { t: 'msg'; clientId: string; data: unknown }; + +// --------------------------------------------------------------------------- +// Remote-api v1, terminal-only (see remote-api.md "v1 scope" and server.md). +// These ride inside `msg` frames once a session is authorized. + +export interface RemoteRequest { + requestId: string; + method: string; + params?: unknown; +} +export interface RemoteResponse { + requestId: string; + ok: boolean; + result?: unknown; + error?: string; +} +export interface RemoteEventMsg { + subId: string; + event: string; + data: unknown; +} + +export const REMOTE_METHODS = { + hello: 'hello', + directoryWatch: 'directory.watch', + surfaceAttach: 'surface.attach', + surfaceDetach: 'surface.detach', + terminalWrite: 'terminal.write', + terminalResize: 'terminal.resize', +} as const; + +export const REMOTE_EVENTS = { + directorySnapshot: 'directory.snapshot', + terminalData: 'terminal.data', + terminalResize: 'terminal.resize', + terminalSemantic: 'terminal.semantic', + terminalClosed: 'terminal.closed', +} as const; + +export interface HelloParams { + protocolVersion: 1; + viewer: 'phone' | 'vr' | 'desktop'; +} +export interface HelloResult { + protocolVersion: 1; + hostId: string; + grants: { input: boolean; layout: boolean }; +} + +/** Terminal-only for the POC: no browser entries, so no `url`. */ +export interface DirectoryEntry { + paneRef: string; + surfaceId: string; + type: 'terminal'; + title: string; + focused: boolean; + activity?: 'unknown' | 'prompt' | 'editing' | 'running' | 'finished'; + exitCode?: number; + /** + * The pane's PTY process is still alive. A registry surface whose process has + * exited (Dormouse keeps it open showing "[Process exited…]" until closed) + * reports `alive: false` — distinct from `exitCode`, which is the last + * shell-integration command's status, not process lifetime. + */ + alive: boolean; + cwd?: string; + ringing: boolean; + hasTODO: boolean; +} +export interface DirectorySnapshot { + entries: DirectoryEntry[]; +} + +export interface AttachParams { + surfaceId: string; + cols: number; + rows: number; +} +export interface TerminalAttachResult { + cols: number; + rows: number; +} + +export interface TerminalDataEvent { + /** Base64url PTY output bytes. */ + bytes: string; +} +export interface TerminalResizeEvent { + cols: number; + rows: number; +} +export interface TerminalClosedEvent { + exitCode?: number; +} + +export interface TerminalWriteParams { + surfaceId: string; + /** Base64url input bytes. */ + bytes: string; +} +export interface TerminalResizeParams { + surfaceId: string; + cols: number; + rows: number; +} + +/** + * Coerce a requested terminal dimension (cols or rows) to a positive integer, + * falling back to `fallback` when the value is absent or not finite. Shared so + * the Host api, the client adapter, and the test harness all sanitize sizes the + * same way. + */ +export function clampTerminalDimension(value: number | undefined, fallback: number): number { + if (value === undefined || !Number.isFinite(value)) return fallback; + return Math.max(1, Math.floor(value)); +} diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 00000000..8be0fc58 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,2 @@ +# Selfhost server runtime state (accounts, passkeys, host tokens) — never commit. +data/ diff --git a/server/package.json b/server/package.json index f117f321..f9eaad1e 100644 --- a/server/package.json +++ b/server/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@hono/node-server": "^1.13.0", + "@hono/node-ws": "^1.3.1", "hono": "^4.6.0", "server-lib-common": "workspace:*" }, diff --git a/server/scripts/fake-host.mjs b/server/scripts/fake-host.mjs new file mode 100644 index 00000000..7dc7400c --- /dev/null +++ b/server/scripts/fake-host.mjs @@ -0,0 +1,79 @@ +/** + * Manual smoke tool: enroll a Host against a running selfhost server and run one + * auto-approving `FakeHost`, logging every handshake event. This is the headless + * stand-in for the standalone Host (slice 4) — handy for driving a real Pocket + * page through pairing + connect without a laptop app. + * + * DORMOUSE_SETUP_PASSWORD=... node scripts/fake-host.mjs http://localhost:3000 + * + * The server URL (default http://localhost:3000) is argv[2]. The setup password + * comes from DORMOUSE_SETUP_PASSWORD (same secret that gates enrollment). Build + * first (`pnpm --filter server build`) so `server-lib-common` is compiled. + */ + +import { API_ROUTES } from 'server-lib-common'; + +import { FakeHost } from '../test/harness/fake-host.mjs'; + +const serverUrl = (process.argv[2] ?? 'http://localhost:3000').replace(/\/$/, ''); +const password = process.env.DORMOUSE_SETUP_PASSWORD; +if (!password) { + console.error('DORMOUSE_SETUP_PASSWORD is required (it gates host enrollment).'); + process.exit(1); +} + +const label = process.env.FAKE_HOST_LABEL ?? 'Fake Host (script)'; + +async function main() { + const res = await fetch(`${serverUrl}${API_ROUTES.hostEnroll}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password, label }), + }); + if (!res.ok) { + console.error(`enroll failed: ${res.status} ${await res.text()}`); + process.exit(1); + } + const host = await res.json(); + console.log(`enrolled host ${host.hostId} (origin ${host.origin}, rpId ${host.rpId})`); + + const fakeHost = new FakeHost({ + serverUrl, + hostToken: host.hostToken, + hostId: host.hostId, + origin: host.origin, + rpId: host.rpId, + autoApprove: true, + }); + + fakeHost.on('open', () => console.log('host socket open — waiting for clients')); + fakeHost.on('pair', ({ clientId, request }) => + console.log(`pair ← ${clientId} label=${request?.requestedLabel} (auto-approving)`), + ); + fakeHost.on('paired', ({ clientId }) => console.log(`paired ✓ ${clientId}`)); + fakeHost.on('connect', ({ clientId }) => console.log(`connect ← ${clientId} (issued challenge)`)); + fakeHost.on('decision', ({ clientId, allowed, failures }) => + console.log(`decision → ${clientId} allowed=${allowed}${allowed ? '' : ` ${failures?.join(',')}`}`), + ); + fakeHost.on('msg', ({ clientId, request, response }) => + console.log(`msg ${clientId} ${request.method} → ok=${response.ok}`), + ); + fakeHost.on('client-gone', ({ clientId }) => console.log(`client-gone ${clientId}`)); + fakeHost.on('close', (ev) => { + console.log(`host socket closed (${ev?.code ?? '?'}) — exiting`); + process.exit(0); + }); + + await fakeHost.ready; + + process.on('SIGINT', () => { + console.log('\nshutting down'); + fakeHost.close(); + process.exit(0); + }); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/src/app.ts b/server/src/app.ts index ae08ca1a..89b418c9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,16 +1,515 @@ +/** + * The selfhost POC server (docs/specs/server.md), slice 1: accounts & passkeys. + * + * Built as a factory — `createApp(config)` — rather than a module-level + * singleton so tests can spin up an isolated server (its own state dir, its own + * in-memory challenge/session stores, its own injectable clock) per case, and + * so `index.ts` stays a thin env-to-config adapter. + * + * "WebAuthn without a WebAuthn library" (server.md): registration trusts the + * browser-provided SPKI public key and only sanity-checks `clientDataJSON`; + * assertions are verified by `verifyPasskeyAssertion` from `server-lib-common`, + * the exact same verifier the Host uses, so Server and Host cannot disagree on + * what a valid assertion is. Challenges are minted by `HostChallengeIssuer` + * (a generic single-use/TTL store despite the name). Setup and sign-in get + * SEPARATE issuers so a challenge minted for one flow can never be redeemed in + * the other. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + import { Hono } from 'hono'; -import { HELLO_ROUTE, helloResponse } from 'server-lib-common'; +import { cors } from 'hono/cors'; +import type { Context, MiddlewareHandler } from 'hono'; +import { createNodeWebSocket } from '@hono/node-ws'; +import type { NodeWebSocket } from '@hono/node-ws'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { + API_ROUTES, + HELLO_ROUTE, + HostChallengeIssuer, + SELFHOST_ACCOUNT_ID, + WS_ROUTES, + WS_TOKEN_PARAM, + fromBase64Url, + getWebCrypto, + helloResponse, + toBase64Url, + utf8Decode, + verifyPasskeyAssertion, +} from 'server-lib-common'; +import type { + HostEnrollRequest, + HostEnrollResponse, + HostsResponse, + PasskeyAssertion, + SetupBeginRequest, + SetupBeginResponse, + SetupFinishRequest, + SetupFinishResponse, + SigninBeginResponse, + SigninFinishRequest, + SigninFinishResponse, +} from 'server-lib-common'; + +import { Handshake } from './handshake.js'; +import { RelayHub } from './relay.js'; +import type { ClientConn, HostConn } from './relay.js'; +import { AccountStore, DuplicateCredentialError, HostStore } from './state.js'; +import type { StoredHost } from './state.js'; + +/** Runtime configuration; see `index.ts` for how env maps onto this. */ +export interface AppConfig { + /** Gates account creation and passkey enrollment. */ + readonly setupPassword: string; + /** External origin, e.g. `https://dormouse.tailnet.ts.net`; source of `rpId`. */ + readonly origin: string; + /** + * Demand the authenticator's user-verification flag (biometric/PIN) on the + * relay's connection-handshake assertions, mirroring the Host's + * `ConnectionPolicy.requireUserVerification` so Server and Host cannot disagree + * on what a valid assertion is. Omitted/false keeps the current presence-only + * behavior; a deployment opts in explicitly (env → config in `index.ts`). + */ + readonly requireUserVerification?: boolean; + /** Directory holding `account.json`. */ + readonly stateDir: string; + /** + * Directory of the built Pocket web app (`lib`'s `dist-pocket`). When it + * exists it is served statically at `/*`; otherwise `GET /` is a stub telling + * you how to build it. API and `/ws` routes always take precedence. + */ + readonly pocketDir?: string; + /** Injectable clock (epoch ms) for tests; defaults to `Date.now`. */ + readonly now?: () => number; +} + +/** A live sign-in session held in memory (server.md: everything transient is in memory). */ +export interface Session { + readonly accountId: string; + readonly expiresAt: number; +} + +type AppEnv = { Variables: { session: Session; host: StoredHost } }; + +/** Sessions live 12 hours (server.md: "hours-scale TTL"). */ +const SESSION_TTL_MS = 12 * 60 * 60 * 1000; +/** A small fixed delay on password failure — the extent of POC brute-force hardening. */ +const PASSWORD_FAILURE_DELAY_MS = 250; + +/** + * In-memory session store. Exposed on the created app so slice 2's WS path can + * validate a raw `token` query param, and the `requireSession` middleware can + * validate a `Bearer` header, against one shared source of truth. + */ +export class SessionStore { + readonly #sessions = new Map(); + readonly #now: () => number; + + constructor(now: () => number) { + this.#now = now; + } + + /** Mint a fresh session token (32 random bytes, base64url) for an account. */ + mint(accountId: string): { token: string; session: Session } { + const token = toBase64Url(randomBytes(32)); + const session: Session = { accountId, expiresAt: this.#now() + SESSION_TTL_MS }; + this.#sessions.set(token, session); + return { token, session }; + } + + /** Validate a raw token; returns the session or `null` if unknown/expired. */ + validate(token: string): Session | null { + const session = this.#sessions.get(token); + if (!session) return null; + if (this.#now() >= session.expiresAt) { + this.#sessions.delete(token); + return null; + } + return session; + } +} + +/** What {@link createApp} hands back: the Hono app plus its auth internals. */ +export interface CreatedApp { + readonly app: Hono; + readonly sessions: SessionStore; + /** Middleware for session-gated routes (`/api/hosts`, etc.). */ + readonly requireSession: MiddlewareHandler; + /** The relay hub; exposed so `/api/hosts` presence and tests can read it. */ + readonly hub: RelayHub; + /** + * Bind the WS relay onto the http server returned by `serve()`. `index.ts` + * (and tests) MUST call this after `serve()`, per the `@hono/node-ws` pattern + * — the WebSocket routes are inert until the upgrade handler is injected. + */ + readonly injectWebSocket: NodeWebSocket['injectWebSocket']; +} + +export function createApp(config: AppConfig): CreatedApp { + const now = config.now ?? (() => Date.now()); + const originUrl = new URL(config.origin); + const origin = originUrl.origin; + const rpId = originUrl.hostname; + const accounts = new AccountStore(config.stateDir, now); + const hostStore = new HostStore(config.stateDir, now); + const sessions = new SessionStore(now); + // Server-side handshake policy layered on the transport-dumb hub (slice 3). + const handshake = new Handshake(accounts, { + origin, + rpId, + requireUserVerification: config.requireUserVerification, + now, + }); + const hub = new RelayHub(handshake); + // Separate issuers per flow: a setup challenge cannot be redeemed at sign-in. + const setupChallenges = new HostChallengeIssuer({ now }); + const signinChallenges = new HostChallengeIssuer({ now }); + + // Precompute a fixed-length digest of the expected password so the + // constant-time compare never has to branch on length (timingSafeEqual + // throws on unequal-length buffers). + const expectedPasswordHash = sha256(config.setupPassword); + const passwordOk = (provided: unknown): boolean => + typeof provided === 'string' && timingSafeEqual(sha256(provided), expectedPasswordHash); + + // Read a JSON body and enforce the setup password. Returns the parsed body, or + // a ready 401 `Response` (after the standard failure delay) the caller returns + // as-is — so the three password-gated routes share one policy. + async function readPasswordGated( + c: Context, + ): Promise { + const body = await readJson(c); + if (!body || !passwordOk(body.password)) { + await delay(PASSWORD_FAILURE_DELAY_MS); + return c.json({ error: 'invalid setup password' }, 401); + } + return body; + } + + const app = new Hono(); + // The WS relay routes need the http server that `serve()` builds later, so the + // adapter is created here and `injectWebSocket` is handed back to the caller. + const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app }); + + // The Host (standalone webview) and dev Pocket builds call the API from + // other origins, so preflights must succeed. Permissive CORS is safe here: + // every endpoint is gated by the setup password or a bearer token, and no + // cookies exist for a foreign origin to ride on. + app.use('/api/*', cors({ origin: '*', allowHeaders: ['Content-Type', 'Authorization'] })); + + // Shared greeting, kept from the skeleton so `lib` and `server` stay agreed. + app.get(HELLO_ROUTE, (c) => c.json(helloResponse())); + + // --- Setup: password-gated passkey registration ------------------------- + + app.post(API_ROUTES.setupBegin, async (c) => { + const body = await readPasswordGated(c); + if (body instanceof Response) return body; + const { challenge } = setupChallenges.issue(); + const res: SetupBeginResponse = { challenge, rpId, accountId: SELFHOST_ACCOUNT_ID }; + return c.json(res); + }); + + app.post(API_ROUTES.setupFinish, async (c) => { + const body = await readPasswordGated(c); + if (body instanceof Response) return body; + + // Decode and sanity-check clientDataJSON — we do NOT parse attestation + // (attestation: 'none'); the browser already handed us the public key. + const clientData = decodeClientData(body.clientDataJSON); + if (!clientData) return c.json({ error: 'malformed clientDataJSON' }, 400); + if (clientData.type !== 'webauthn.create') { + return c.json({ error: 'clientData type must be webauthn.create' }, 400); + } + const challenge = normalizeChallenge(clientData.challenge); + if (!challenge || !setupChallenges.consume(challenge)) { + return c.json({ error: 'unrecognized or expired challenge' }, 400); + } + if (clientData.origin !== origin) { + return c.json({ error: 'origin mismatch' }, 400); + } + + // Reject any key we could not verify assertions against later. + if (!(await importableSpkiP256(body.publicKey))) { + return c.json({ error: 'unimportable public key' }, 400); + } + + try { + await accounts.appendPasskey({ + credentialId: body.credentialId, + publicKey: body.publicKey, + label: typeof body.label === 'string' ? body.label : '', + }); + } catch (err) { + if (err instanceof DuplicateCredentialError) { + return c.json({ error: 'credential already registered' }, 409); + } + throw err; + } + + const res: SetupFinishResponse = { + accountId: SELFHOST_ACCOUNT_ID, + credentialId: body.credentialId, + }; + return c.json(res); + }); + + // --- Sign-in: passkey assertion → session token ------------------------- + + app.post(API_ROUTES.signinBegin, (c) => { + const { challenge } = signinChallenges.issue(); + const res: SigninBeginResponse = { challenge, rpId }; + return c.json(res); + }); + + app.post(API_ROUTES.signinFinish, async (c) => { + const body = await readJson(c); + const assertion = body?.assertion; + if (!assertion || typeof assertion.credentialId !== 'string') { + return c.json({ error: 'malformed assertion' }, 400); + } + + const stored = await accounts.findPasskey(assertion.credentialId); + if (!stored) return c.json({ error: 'unknown credential' }, 404); + + // Pull the challenge out of the assertion's own clientDataJSON so we can + // consume it (single-use) before verifying. Consuming first guarantees a + // captured assertion can never be replayed even if verification succeeds. + const clientData = decodeClientData(assertion.clientDataJSON); + if (!clientData || typeof clientData.challenge !== 'string') { + return c.json({ error: 'malformed clientDataJSON' }, 400); + } + const challenge = normalizeChallenge(clientData.challenge); + if (!challenge) { + return c.json({ error: 'malformed clientDataJSON' }, 400); + } + if (!signinChallenges.consume(challenge)) { + return c.json({ error: 'unrecognized or expired challenge' }, 400); + } + + const result = await verifyPasskeyAssertion(assertion as PasskeyAssertion, stored.publicKey, { + challenge, + origin, + rpId, + // Same server-wide UV policy the connect handshake enforces, so sign-in + // is not a softer path than a remote connect when UV is required. + requireUserVerification: config.requireUserVerification, + }); + if (!result.ok) { + return c.json({ error: `assertion rejected: ${result.reason}` }, 401); + } + + const { token, session } = sessions.mint(SELFHOST_ACCOUNT_ID); + const res: SigninFinishResponse = { + sessionToken: token, + accountId: session.accountId, + expiresAt: session.expiresAt, + }; + return c.json(res); + }); + + // --- Host enrollment: password-gated, appends to hosts.json -------------- + + app.post(API_ROUTES.hostEnroll, async (c) => { + const body = await readPasswordGated(c); + if (body instanceof Response) return body; + const label = typeof body.label === 'string' ? body.label : ''; + const host = await hostStore.enroll(label); + // The Host enforces `origin`/`rpId` as its ConnectionPolicy (server.md). + const res: HostEnrollResponse = { + hostId: host.hostId, + hostToken: host.hostToken, + origin, + rpId, + }; + return c.json(res); + }); + + // Gate a route on a valid `Authorization: Bearer` session token. + const requireSession: MiddlewareHandler = async (c, next) => { + const header = c.req.header('Authorization') ?? ''; + const match = /^Bearer (.+)$/.exec(header); + const session = match ? sessions.validate(match[1]!) : null; + if (!session) return c.json({ error: 'unauthorized' }, 401); + c.set('session', session); + await next(); + }; + + // --- Host presence: enrolled hosts + whether each is connected ----------- + + app.get(API_ROUTES.hosts, requireSession, async (c) => { + const hosts = await hostStore.list(); + const res: HostsResponse = { + hosts: hosts.map((h) => ({ + hostId: h.hostId, + label: h.label, + online: hub.isHostOnline(h.hostId), + })), + }; + return c.json(res); + }); + + // --- The relay: one host socket per hostId, many client sockets ---------- + // Auth rides the `token` query param (browsers cannot set WS headers). A bad + // token short-circuits with 401 here, so `injectWebSocket` never upgrades it. + + app.get( + WS_ROUTES.host, + async (c, next) => { + const token = c.req.query(WS_TOKEN_PARAM); + const host = token ? await hostStore.findByToken(token) : undefined; + if (!host) return c.json({ error: 'unknown host token' }, 401); + c.set('host', host); + return next(); + }, + upgradeWebSocket((c) => { + // The auth middleware above ran on this same context and stashed `host`. + const host = (c as Context).get('host'); + let conn: HostConn | undefined; + return { + onOpen: (_evt, ws) => { + conn = hub.registerHost(host.hostId, ws); + }, + onMessage: (evt) => { + if (conn && typeof evt.data === 'string') hub.onHostFrame(conn, evt.data); + }, + onClose: () => { + if (conn) hub.unregisterHost(conn); + }, + }; + }), + ); + + app.get( + WS_ROUTES.client, + (c, next) => { + const token = c.req.query(WS_TOKEN_PARAM); + const session = token ? sessions.validate(token) : null; + if (!session) return c.json({ error: 'unauthorized' }, 401); + return next(); + }, + upgradeWebSocket(() => { + let conn: ClientConn | undefined; + // `onClientFrame` is async (pair/connect2 verification), so serialize + // frames from this socket through a promise chain — a client's frames + // must be processed in the order they arrived, not raced by the gate. + let chain: Promise = Promise.resolve(); + return { + onOpen: (_evt, ws) => { + conn = hub.registerClient(ws); + }, + onMessage: (evt) => { + if (conn && typeof evt.data === 'string') { + const c = conn; + const data = evt.data; + chain = chain.then(() => hub.onClientFrame(c, data)).catch(() => undefined); + } + }, + onClose: () => { + if (conn) hub.unregisterClient(conn); + }, + }; + }), + ); + + // --- Static Pocket app: GET /* fallback, registered LAST so every API and + // /ws route above wins. Missing build → a stub with the build command. + registerPocketServing(app, config.pocketDir); + + return { app, sessions, requireSession, hub, injectWebSocket }; +} + +/** Message shown at `GET /` when the Pocket app has not been built yet. */ +const POCKET_MISSING_MESSAGE = + 'Dormouse selfhost server. The Pocket web app is not built yet — run ' + + '`pnpm --filter dormouse-lib build:pocket` (or set DORMOUSE_POCKET_DIR).'; /** - * The Hono application. Built here — separate from the `serve()` entrypoint in - * `index.ts` — so tests can exercise routes via `app.request()` without binding - * a port. + * Serve the built Pocket app from `pocketDir` at `/*`, falling back to + * `index.html` for any non-file GET (the app is a single page). When the + * directory or its `index.html` is absent, keep the old stub at `GET /`. */ -export const app = new Hono(); +function registerPocketServing(app: Hono, pocketDir?: string): void { + const indexHtmlPath = pocketDir ? join(pocketDir, 'index.html') : null; + if (!pocketDir || !indexHtmlPath || !existsSync(indexHtmlPath)) { + app.get('/', (c) => c.text(POCKET_MISSING_MESSAGE)); + return; + } + // `serveStatic` joins its `root` onto the request path relative to cwd, so a + // path relative to cwd is the portable way to point it at an arbitrary dir. + const root = relative(process.cwd(), pocketDir) || '.'; + app.get('/*', serveStatic({ root })); + // Re-read the SPA shell per deep-link fallback: a Pocket rebuild swaps in an + // index.html referencing new content-hashed assets, and a cached copy would + // keep pointing at deleted files until the server restarts. The fallback is + // not a hot path, and a read failure degrades to a 404 instead of a crash. + app.get('*', async (c) => { + const html = await readFile(indexHtmlPath, 'utf8').catch(() => null); + return html ? c.html(html) : c.notFound(); + }); +} + +// --------------------------------------------------------------------------- +// Helpers + +/** SHA-256 of a UTF-8 string, as a fixed 32-byte buffer. */ +function sha256(text: string): Buffer { + return createHash('sha256').update(text, 'utf8').digest(); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readJson(c: { req: { json(): Promise } }): Promise { + try { + return (await c.req.json()) as T; + } catch { + return null; + } +} + +/** Decode base64url clientDataJSON to its parsed object, or `null` if malformed. */ +function decodeClientData( + clientDataJSON: unknown, +): { type?: unknown; challenge?: unknown; origin?: unknown } | null { + if (typeof clientDataJSON !== 'string') return null; + try { + const parsed: unknown = JSON.parse(utf8Decode(fromBase64Url(clientDataJSON))); + if (typeof parsed !== 'object' || parsed === null) return null; + return parsed; + } catch { + return null; + } +} -app.get('/', (c) => c.text('Hello from Hono!')); +/** Canonicalize browser-serialized base64url challenges before single-use lookup. */ +function normalizeChallenge(challenge: unknown): string | null { + if (typeof challenge !== 'string') return null; + try { + return toBase64Url(fromBase64Url(challenge)); + } catch { + return null; + } +} -// The route path and response shape both come from `server-lib-common`, the -// package `lib` (frontend) and `server` (backend) share, so the two sides can -// never drift out of agreement. -app.get(HELLO_ROUTE, (c) => c.json(helloResponse())); +/** True if `publicKey` (base64url SPKI) imports as an ECDSA P-256 verify key. */ +async function importableSpkiP256(publicKey: unknown): Promise { + if (typeof publicKey !== 'string') return false; + try { + await getWebCrypto().subtle.importKey( + 'spki', + fromBase64Url(publicKey), + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['verify'], + ); + return true; + } catch { + return false; + } +} diff --git a/server/src/handshake.ts b/server/src/handshake.ts new file mode 100644 index 00000000..e31624c7 --- /dev/null +++ b/server/src/handshake.ts @@ -0,0 +1,207 @@ +/** + * Server-side handshake verification (docs/specs/server.md "Relay"; + * docs/specs/remote-security-model.md). The {@link RelayHub} stays a + * transport-dumb pipe — this module is the policy it consults before forwarding + * the two security-critical Client frames: + * + * - `pair`: the pairing request must be consistent with the authenticated + * session — the owner account, a registered passkey credential, + * and the matching stored public-key hash. Otherwise the server + * refuses to relay it to the Host at all. + * - `connect2`: the WebAuthn assertion must verify against the STORED passkey + * public key (not the one the request carries), over the exact + * Host challenge the server just relayed to this client. This is + * the Server's half of "fresh user presence is validated by the + * Server and the Host". + * + * A rejection never reaches the Host, so a forged request cannot even burn a + * Host challenge. The relayed challenge is single-use on the server side too, so + * a replayed `connect2` is refused here before it is forwarded. The Host's + * `authorizeConnection` remains the final authority on everything the server + * cannot see (the ACL, the device key, the challenge it actually issued). + */ + +import { + DEFAULT_CHALLENGE_TTL_MS, + SELFHOST_ACCOUNT_ID, + hashPasskeyPublicKey, + verifyPasskeyAssertion, +} from 'server-lib-common'; +import type { ConnectionFailure, ConnectionRequest, PairingRequest } from 'server-lib-common'; + +import type { AccountStore } from './state.js'; + +/** Result of {@link HandshakeGate.checkPair}. */ +export type PairCheck = { readonly ok: true } | { readonly ok: false; readonly error: string }; + +/** Result of {@link HandshakeGate.checkConnect2}. Failures reuse the Host's vocabulary. */ +export type Connect2Check = + | { readonly ok: true } + | { readonly ok: false; readonly failures: readonly ConnectionFailure[] }; + +/** + * The policy surface the {@link RelayHub} consults. Kept as an interface so the + * hub depends on the contract, not the concrete {@link Handshake}, and stays + * transport-dumb. + */ +export interface HandshakeGate { + /** Verify a `pair` request before relaying it to the Host. */ + checkPair(request: unknown): Promise; + /** Remember the Host challenge the server just relayed to a client (freshness half). */ + observeChallenge(clientId: string, hostId: string, challenge: string, expiresAt: number): void; + /** Verify a `connect2` request before relaying it to the target Host. */ + checkConnect2( + clientId: string, + targetHostId: string, + request: ConnectionRequest, + ): Promise; + /** Drop any remembered challenge for a client that disconnected. */ + forgetClient(clientId: string): void; +} + +export interface HandshakeConfig { + /** External origin the WebAuthn assertion must have been produced for. */ + readonly origin: string; + /** Relying-party id the assertion must be scoped to. */ + readonly rpId: string; + /** + * Demand the authenticator's user-verification flag (biometric/PIN), not just + * user presence. This must mirror the Host's `ConnectionPolicy.requireUserVerification` + * (connection.ts `authorizeConnection`): both verifiers evaluate the same + * assertion, so if only one demands UV they silently disagree on what a valid + * assertion is. Undefined/false keeps the current presence-only behavior. + */ + readonly requireUserVerification?: boolean; + /** Injectable clock (epoch ms) for tests; defaults to `Date.now`. */ + readonly now?: () => number; + /** Relay-side TTL for Host challenges observed by the server. */ + readonly relayedChallengeTtlMs?: number; +} + +/** The last Host challenge the server relayed to a client. */ +interface RelayedChallenge { + readonly hostId: string; + readonly challenge: string; + /** Server-local expiry, derived when the relay observed the challenge. */ + readonly expiresAt: number; +} + +export class Handshake implements HandshakeGate { + readonly #accounts: AccountStore; + readonly #origin: string; + readonly #rpId: string; + readonly #requireUserVerification: boolean; + readonly #now: () => number; + readonly #relayedChallengeTtlMs: number; + /** clientId → the last Host challenge relayed to it; consumed single-use. */ + readonly #relayed = new Map(); + + constructor(accounts: AccountStore, config: HandshakeConfig) { + this.#accounts = accounts; + this.#origin = config.origin; + this.#rpId = config.rpId; + this.#requireUserVerification = config.requireUserVerification ?? false; + this.#now = config.now ?? (() => Date.now()); + this.#relayedChallengeTtlMs = config.relayedChallengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS; + } + + async checkPair(request: unknown): Promise { + if (!isPairingRequest(request)) { + return { ok: false, error: 'malformed pairing request' }; + } + if (request.accountId !== SELFHOST_ACCOUNT_ID) { + return { ok: false, error: 'pairing request is not for this account' }; + } + if (typeof request.passkeyCredentialId !== 'string') { + return { ok: false, error: 'pairing request has no passkey credential' }; + } + const stored = await this.#accounts.findPasskey(request.passkeyCredentialId); + if (!stored) { + return { ok: false, error: 'passkey credential is not registered to this account' }; + } + const expectedHash = await hashPasskeyPublicKey(stored.publicKey); + if (request.passkeyPublicKeyHash !== expectedHash) { + return { ok: false, error: 'passkey public key hash does not match the registered key' }; + } + return { ok: true }; + } + + observeChallenge(clientId: string, hostId: string, challenge: string, _hostExpiresAt: number): void { + this.#relayed.set(clientId, { + hostId, + challenge, + expiresAt: this.#now() + this.#relayedChallengeTtlMs, + }); + } + + forgetClient(clientId: string): void { + this.#relayed.delete(clientId); + } + + async checkConnect2( + clientId: string, + targetHostId: string, + request: ConnectionRequest, + ): Promise { + const failures: ConnectionFailure[] = []; + + // (d) Freshness half: the request must answer the exact Host challenge the + // server relayed to THIS client, unexpired. Consume it unconditionally + // (single-use) so a replayed connect2 is refused here before forwarding. + const relayed = this.#relayed.get(clientId); + this.#relayed.delete(clientId); + const challengeFresh = + relayed !== undefined && + relayed.hostId === targetHostId && + typeof request?.challenge === 'string' && + relayed.challenge === request.challenge && + this.#now() < relayed.expiresAt; + if (!challengeFresh) failures.push('challenge-invalid'); + + // (a) Only the single owner account. + if (!request || request.accountId !== SELFHOST_ACCOUNT_ID) failures.push('account-mismatch'); + + // (b) The asserted credential must be a registered passkey, and the request's + // publicKey must equal the STORED key for it (plain string compare). + const assertion = request?.passkey?.assertion; + const credentialId = assertion?.credentialId; + const stored = + typeof credentialId === 'string' ? await this.#accounts.findPasskey(credentialId) : undefined; + if (!stored) { + failures.push('passkey-not-paired'); + } else if (request.passkey.publicKey !== stored.publicKey) { + failures.push('passkey-key-mismatch'); + } + + // (c) The assertion must verify against the STORED key over request.challenge. + // Verifying against the stored key — never against request.passkey.publicKey — + // is what makes a substituted publicKey useless to an attacker. + if (stored && assertion && typeof request.challenge === 'string') { + const result = await verifyPasskeyAssertion(assertion, stored.publicKey, { + challenge: request.challenge, + origin: this.#origin, + rpId: this.#rpId, + // Mirror the Host's UV demand so Server and Host cannot drift on what a + // valid assertion is (connection.ts `authorizeConnection`). + requireUserVerification: this.#requireUserVerification, + }); + if (!result.ok) failures.push('passkey-assertion-invalid'); + } else { + failures.push('passkey-assertion-invalid'); + } + + return failures.length === 0 ? { ok: true } : { ok: false, failures }; + } +} + +function isPairingRequest(request: unknown): request is PairingRequest { + return ( + !!request && + typeof request === 'object' && + typeof (request as PairingRequest).accountId === 'string' && + typeof (request as PairingRequest).passkeyCredentialId === 'string' && + typeof (request as PairingRequest).passkeyPublicKeyHash === 'string' && + typeof (request as PairingRequest).devicePublicKey === 'string' && + typeof (request as PairingRequest).requestedLabel === 'string' + ); +} diff --git a/server/src/index.ts b/server/src/index.ts index 86d3d923..9ac080b4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,9 +1,40 @@ +/** + * Process entrypoint: translate environment variables (docs/specs/server.md, + * "Configuration") into an {@link AppConfig} and bind a port. Kept separate from + * `app.ts` so the app itself stays testable without touching env or the network. + */ + +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { serve } from '@hono/node-server'; -import { app } from './app.js'; +import { createApp } from './app.js'; const port = Number(process.env.PORT ?? 3000); -serve({ fetch: app.fetch, port }, (info) => { - console.log(`server listening on http://localhost:${info.port}`); +const setupPassword = process.env.DORMOUSE_SETUP_PASSWORD; +if (!setupPassword) { + console.error( + 'DORMOUSE_SETUP_PASSWORD is required — it gates account creation and host enrollment.', + ); + process.exit(1); +} + +const origin = process.env.DORMOUSE_ORIGIN ?? `http://localhost:${port}`; +const stateDir = process.env.DORMOUSE_STATE_DIR ?? './data'; + +// Default to `lib/dist-pocket` resolved from this compiled file's location +// (server/dist/index.js → repo root two levels up), so it works regardless of +// the process's cwd. Override with DORMOUSE_POCKET_DIR. +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const pocketDir = process.env.DORMOUSE_POCKET_DIR ?? join(repoRoot, 'lib', 'dist-pocket'); + +const { app, injectWebSocket } = createApp({ setupPassword, origin, stateDir, pocketDir }); + +const server = serve({ fetch: app.fetch, port }, (info) => { + console.log(`server listening on http://localhost:${info.port} (origin ${origin})`); }); + +// Bind the relay's WS upgrade handler onto the running server (@hono/node-ws). +injectWebSocket(server); diff --git a/server/src/relay.ts b/server/src/relay.ts new file mode 100644 index 00000000..08639a16 --- /dev/null +++ b/server/src/relay.ts @@ -0,0 +1,342 @@ +/** + * The relay hub (docs/specs/server.md, "Relay"): routes JSON envelopes between + * Client sockets and Host sockets. It is the coordinating Server's dumb pipe — + * before a session is authorized it forwards only the handshake allowlist + * (`pair`/`connect`/`connect2` up, `pair-result`/`challenge`/`decision` down); + * after authorization it forwards `msg` frames verbatim. + * + * State is deliberately tiny and in-memory (a server restart just means everyone + * reconnects) and the machine is kept small so slice 3 can layer connection + * *verification* on top without reshaping it: + * + * - one live socket per `hostId` (a reconnect replaces the old socket); + * - each client is bound to at most one host (`clientId → hostId`) and carries + * an `established` flag that gates `msg` in both directions; + * - the session becomes established purely on the Host's authority — when the + * Host sends `{ t: 'decision', allowed: true }` for that client. + * + * `clientId` is a server-assigned secret: it is stamped onto every host-bound + * frame so the Host can address replies, but is never sent to the client. + * + * Slice 3 layers *verification* on top without reshaping any of this: the hub + * consults an injected {@link HandshakeGate} before relaying the two + * security-critical Client frames (`pair`, `connect2`) and remembers each Host + * challenge it relays, but the routing and session model are untouched. + */ + +import { randomBytes } from 'node:crypto'; + +import { toBase64Url } from 'server-lib-common'; +import type { + ClientFrame, + HostFrame, + ServerToClientFrame, + ServerToHostFrame, +} from 'server-lib-common'; + +import type { HandshakeGate } from './handshake.js'; + +/** + * The slice of a WebSocket the hub actually uses. `WSContext` from + * `@hono/node-ws` satisfies it, but keeping the surface this small keeps the + * routing logic transport-agnostic and unit-testable. + */ +export interface RelaySocket { + send(data: string): void; + close(code?: number, reason?: string): void; +} + +/** A live Host socket. */ +export interface HostConn { + readonly hostId: string; + readonly socket: RelaySocket; +} + +/** A live Client socket and its (single) relationship to a Host. */ +export interface ClientConn { + readonly clientId: string; + readonly socket: RelaySocket; + /** The Host this client is currently talking to, or `null` if unbound. */ + hostId: string | null; + /** Whether `msg` frames may flow — set true only by an allowed Host decision. */ + established: boolean; +} + +export class RelayHub { + readonly #hosts = new Map(); + readonly #clients = new Map(); + readonly #gate: HandshakeGate; + + constructor(gate: HandshakeGate) { + this.#gate = gate; + } + + /** True while a socket for `hostId` is connected — drives `GET /api/hosts` presence. */ + isHostOnline(hostId: string): boolean { + return this.#hosts.has(hostId); + } + + // --- Host lifecycle ------------------------------------------------------- + + /** + * Register a freshly-opened Host socket. Only one socket may own a `hostId`, + * so an existing one is displaced and closed; the displaced socket's `close` + * event is ignored by {@link unregisterHost} because the map already points + * at the new connection (a generation guard). + * + * A replacement also invalidates every session established with the OLD Host + * process: the new process has a fresh ACL and no memory of them, so their + * in-flight `msg` frames must never be treated as authorized. Slice 2 did this + * only on disconnect; because the displaced socket's `close` is a no-op here, + * the invalidation has to happen at replacement time too. + */ + registerHost(hostId: string, socket: RelaySocket): HostConn { + const conn: HostConn = { hostId, socket }; + const existing = this.#hosts.get(hostId); + this.#hosts.set(hostId, conn); + if (existing) { + this.#dropClientsOf(hostId); + safeClose(existing.socket, 4000, 'replaced by a newer host connection'); + } + return conn; + } + + /** Handle one raw frame from a Host socket. Unknown/malformed frames are ignored. */ + onHostFrame(host: HostConn, raw: string): void { + // Only the socket the map points at speaks for a hostId: a socket displaced + // by registerHost can still deliver queued frames, and treating them as + // current would resurrect sessions the replacement just invalidated (a late + // `decision` re-establishing, or `msg` data from the dead host process). + if (this.#hosts.get(host.hostId) !== host) return; + const frame = parseFrame(raw); + if (!frame || typeof frame.t !== 'string' || typeof frame.clientId !== 'string') return; + // Every host frame addresses a specific client; if it has already gone, + // there is nothing to route (and no session to establish). + const client = this.#clients.get(frame.clientId); + if (!client) return; + // Host replies are only meaningful while the client is still bound to that + // host. A client socket may leave host A for host B before A answers; late + // handshake replies from A must not reach the active client or re-establish + // an old session. + if (client.hostId !== host.hostId) return; + switch (frame.t) { + case 'pair-result': + this.#toClient(client, { + t: 'pair-result', + approved: frame.approved, + record: frame.record, + error: frame.error, + }); + return; + case 'challenge': + // The client's `challenge` frame carries the originating hostId (the + // host frame does not — the hub knows it from the socket). The server + // also remembers the challenge so it can do its half of `connect2` + // freshness validation before forwarding. + this.#gate.observeChallenge(client.clientId, host.hostId, frame.challenge, frame.expiresAt); + this.#toClient(client, { + t: 'challenge', + hostId: host.hostId, + challenge: frame.challenge, + expiresAt: frame.expiresAt, + }); + return; + case 'decision': + // The Host is the final authority: an allowed decision is what + // establishes the (host, client) session and unblocks `msg`. + if (frame.allowed) { + client.hostId = host.hostId; + client.established = true; + } + this.#toClient(client, { + t: 'decision', + allowed: frame.allowed, + failures: frame.failures, + }); + return; + case 'msg': + // Blocked unless this exact host/client pair has an established session. + if (client.established && client.hostId === host.hostId) { + this.#toClient(client, { t: 'msg', data: frame.data }); + } + return; + default: + return; // unknown host frame type — ignore + } + } + + /** + * Tear down a Host socket. Guarded so a socket displaced by + * {@link registerHost} is a no-op. Its clients are told `host-gone` and their + * sessions cleared (no resume protocol — they reconnect). + */ + unregisterHost(host: HostConn): void { + if (this.#hosts.get(host.hostId) !== host) return; // already replaced + this.#hosts.delete(host.hostId); + this.#dropClientsOf(host.hostId); + } + + /** + * Tell every client bound to `hostId` its Host is gone and clear its session, + * so no `msg` can flow to a Host that is no longer the one it handshook with. + * Used on both Host disconnect and Host replacement. + */ + #dropClientsOf(hostId: string): void { + for (const client of this.#clients.values()) { + if (client.hostId === hostId) { + this.#toClient(client, { t: 'host-gone' }); + client.hostId = null; + client.established = false; + } + } + } + + // --- Client lifecycle ----------------------------------------------------- + + /** Register a freshly-opened Client socket with a fresh secret `clientId`. */ + registerClient(socket: RelaySocket): ClientConn { + const clientId = toBase64Url(randomBytes(16)); + const conn: ClientConn = { clientId, socket, hostId: null, established: false }; + this.#clients.set(clientId, conn); + return conn; + } + + /** + * Handle one raw frame from a Client socket. Malformed/unknown frames get an + * `error`. Async because `pair` and `connect2` consult the {@link HandshakeGate} + * (account lookups, crypto); the WS handler serializes calls per socket so + * frames from one client stay in order. + */ + async onClientFrame(client: ClientConn, raw: string): Promise { + const frame = parseFrame(raw); + if (!frame || typeof frame.t !== 'string') { + this.#toClient(client, { t: 'error', error: 'malformed frame' }); + return; + } + switch (frame.t) { + case 'pair': + case 'connect': + case 'connect2': { + if (typeof frame.hostId !== 'string') { + this.#toClient(client, { t: 'error', error: 'missing hostId' }); + return; + } + const host = this.#hosts.get(frame.hostId); + if (!host) { + this.#toClient(client, { t: 'error', error: `host ${frame.hostId} is offline` }); + return; + } + // Binding to a (new) host, or re-attempting `connect`, drops any prior + // established session — a client holds at most one at a time. + if (client.hostId !== null && client.hostId !== frame.hostId) { + const previousHost = this.#hosts.get(client.hostId); + if (previousHost) { + this.#toHost(previousHost, { t: 'client-gone', clientId: client.clientId }); + } + } + if (client.hostId !== frame.hostId || frame.t === 'connect') { + client.established = false; + } + client.hostId = frame.hostId; + + if (frame.t === 'connect') { + this.#toHost(host, { t: 'connect', clientId: client.clientId }); + return; + } + if (frame.t === 'pair') { + // Only relay a pairing request the authenticated session could have + // made: the owner account, a registered credential, a matching key + // hash. A forged request is answered locally and never reaches the Host. + const check = await this.#gate.checkPair(frame.request); + if (!this.#isCurrentClientHost(client, frame.hostId, host)) return; + if (!check.ok) { + this.#toClient(client, { t: 'pair-result', approved: false, error: check.error }); + return; + } + this.#toHost(host, { t: 'pair', clientId: client.clientId, request: frame.request }); + return; + } + // connect2: the server verifies the assertion (against the STORED key) + // and challenge freshness before forwarding. On failure the client gets + // a denial and the Host's challenge stays unburned. + const check = await this.#gate.checkConnect2(client.clientId, frame.hostId, frame.request); + if (!this.#isCurrentClientHost(client, frame.hostId, host)) return; + if (!check.ok) { + this.#toClient(client, { t: 'decision', allowed: false, failures: check.failures }); + return; + } + this.#toHost(host, { t: 'connect2', clientId: client.clientId, request: frame.request }); + return; + } + case 'msg': + // Blocked until the session is established; silently dropped otherwise. + if (client.established && client.hostId !== null) { + const host = this.#hosts.get(client.hostId); + if (host) this.#toHost(host, { t: 'msg', clientId: client.clientId, data: frame.data }); + } + return; + default: + this.#toClient(client, { t: 'error', error: 'unknown frame type' }); + return; + } + } + + /** Tear down a Client socket: tell its Host `client-gone`, then forget it. */ + unregisterClient(client: ClientConn): void { + this.#clients.delete(client.clientId); + this.#gate.forgetClient(client.clientId); + if (client.hostId !== null) { + const host = this.#hosts.get(client.hostId); + if (host) this.#toHost(host, { t: 'client-gone', clientId: client.clientId }); + } + } + + // --- Sending -------------------------------------------------------------- + + #toClient(client: ClientConn, frame: ServerToClientFrame): void { + safeSend(client.socket, frame); + } + + #toHost(host: HostConn, frame: ServerToHostFrame): void { + safeSend(host.socket, frame); + } + + #isCurrentClientHost(client: ClientConn, hostId: string, host: HostConn): boolean { + return ( + this.#clients.get(client.clientId) === client && + client.hostId === hostId && + this.#hosts.get(hostId) === host + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers + +/** Parse a raw WS text frame; `null` if it is not a JSON object. */ +function parseFrame(raw: string): (T & { t?: unknown; clientId?: unknown; hostId?: unknown }) | null { + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return null; + return parsed as T & { t?: unknown }; + } catch { + return null; + } +} + +/** Serialize and send, swallowing errors from a socket that is mid-close. */ +function safeSend(socket: RelaySocket, frame: unknown): void { + try { + socket.send(JSON.stringify(frame)); + } catch { + // The peer vanished between our map lookup and this send — nothing to do. + } +} + +function safeClose(socket: RelaySocket, code: number, reason: string): void { + try { + socket.close(code, reason); + } catch { + // Already closing/closed. + } +} diff --git a/server/src/state.ts b/server/src/state.ts new file mode 100644 index 00000000..dfd6c3b7 --- /dev/null +++ b/server/src/state.ts @@ -0,0 +1,199 @@ +/** + * Persistent state for the selfhost POC (docs/specs/server.md, "State files"): + * + * $DORMOUSE_STATE_DIR/account.json + * { accountId: "owner", passkeys: [{ credentialId, publicKey, label, createdAt }] } + * $DORMOUSE_STATE_DIR/hosts.json + * [{ hostId, hostToken, label, enrolledAt }] + * + * Deliberately not a database: one account, a handful of passkeys and hosts, + * hand-editable for revocation. Writes go through a temp-file-plus-rename so a + * crash mid-write can never leave a half-written (and therefore unparseable) + * file, and mutations are serialized through a promise chain so two concurrent + * appends cannot clobber each other (read-modify-write races). + */ + +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { join } from 'node:path'; + +import { SELFHOST_ACCOUNT_ID, toBase64Url } from 'server-lib-common'; + +/** A registered passkey as stored on disk. `publicKey` is base64url SPKI. */ +export interface StoredPasskey { + readonly credentialId: string; + readonly publicKey: string; + readonly label: string; + readonly createdAt: number; +} + +/** The whole of `account.json`. */ +export interface Account { + readonly accountId: string; + readonly passkeys: StoredPasskey[]; +} + +/** Thrown by {@link AccountStore.appendPasskey} when the credential id is already registered. */ +export class DuplicateCredentialError extends Error { + constructor(credentialId: string) { + super(`credential ${credentialId} is already registered`); + this.name = 'DuplicateCredentialError'; + } +} + +/** + * A tiny JSON-file store: the whole file is one JSON value, written through a + * temp-file-plus-rename so a crash mid-write can never leave a half-written + * (unparseable) file, with mutations serialized through a promise chain so two + * concurrent read-modify-writes cannot clobber each other. Subclasses layer + * their find/append logic on top. Deliberately not a database (see the module + * header). + */ +abstract class JsonFileStore { + readonly #stateDir: string; + readonly #path: string; + /** Wall clock, injectable for deterministic tests. */ + protected readonly now: () => number; + /** Serializes mutations so overlapping writes do not lose each other. */ + #tail: Promise = Promise.resolve(); + + constructor(stateDir: string, fileName: string, now: () => number) { + this.#stateDir = stateDir; + this.#path = join(stateDir, fileName); + this.now = now; + } + + /** Read and parse the file, or `fallback` if it does not exist yet. */ + protected async read(fallback: T): Promise { + let raw: string; + try { + raw = await readFile(this.#path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return fallback; + throw err; + } + return JSON.parse(raw) as T; + } + + /** Overwrite the whole file atomically (temp file + rename). */ + protected async writeAtomic(value: unknown): Promise { + await mkdir(this.#stateDir, { recursive: true }); + const tmp = `${this.#path}.${randomUUID()}.tmp`; + await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + await rename(tmp, this.#path); + } + + /** + * Run `mutate` under the mutex. It is chained onto the tail regardless of + * whether the previous op resolved or rejected, so one failure cannot wedge + * the queue. + */ + protected mutate(mutate: () => Promise): Promise { + const result = this.#tail.then(mutate, mutate); + this.#tail = result.catch(() => undefined); + return result; + } +} + +/** Fixed-length SHA-256 digest, so timing-safe compares never branch on length. */ +function sha256(text: string): Buffer { + return createHash('sha256').update(text, 'utf8').digest(); +} + +export class AccountStore extends JsonFileStore { + constructor(stateDir: string, now: () => number = () => Date.now()) { + super(stateDir, 'account.json', now); + } + + /** Read `account.json`, or `null` if the account has not been created yet. */ + load(): Promise { + return this.read(null); + } + + /** Look up a stored passkey by its base64url credential id. */ + async findPasskey(credentialId: string): Promise { + const account = await this.load(); + return account?.passkeys.find((p) => p.credentialId === credentialId); + } + + /** + * Append a passkey to the account, creating the account on first + * registration. Rejects with {@link DuplicateCredentialError} if the + * credential id already exists. Runs under the mutex. + */ + appendPasskey(passkey: Omit): Promise { + return this.mutate(async () => { + const account: Account = (await this.load()) ?? { + accountId: SELFHOST_ACCOUNT_ID, + passkeys: [], + }; + if (account.passkeys.some((p) => p.credentialId === passkey.credentialId)) { + throw new DuplicateCredentialError(passkey.credentialId); + } + account.passkeys.push({ ...passkey, createdAt: this.now() }); + await this.writeAtomic(account); + return account; + }); + } +} + +/** An enrolled Host as stored in `hosts.json`. `hostToken` is the WS bearer secret. */ +export interface StoredHost { + readonly hostId: string; + readonly hostToken: string; + readonly label: string; + readonly enrolledAt: number; +} + +/** + * Persistent host enrollment (`hosts.json`). Mirrors {@link AccountStore}: an + * append-only JSON array, atomic writes, and a mutex so concurrent enrollments + * cannot lose a write. Revocation is deleting a line by hand (POC guardrail). + */ +export class HostStore extends JsonFileStore { + constructor(stateDir: string, now: () => number = () => Date.now()) { + super(stateDir, 'hosts.json', now); + } + + /** Read `hosts.json`, or `[]` if no host has been enrolled yet. */ + list(): Promise { + return this.read([]); + } + + /** + * Look up an enrolled host by its bearer token (the `/ws/host` credential). + * The token is a secret, so it is compared with a constant-time digest + * compare (mirroring the setup-password path in app.ts) rather than `===`, + * whose early-exit leaks byte positions. Every host is checked without an + * early break so the work does not depend on which entry matches. + */ + async findByToken(hostToken: string): Promise { + const hosts = await this.list(); + const providedHash = sha256(hostToken); + let match: StoredHost | undefined; + for (const h of hosts) { + if (timingSafeEqual(sha256(h.hostToken), providedHash)) match = h; + } + return match; + } + + /** + * Enroll a new host: mint a random `hostId` (16 bytes) and `hostToken` + * (32 bytes), both base64url, append them, and return the record. Runs under + * the mutex. + */ + enroll(label: string): Promise { + return this.mutate(async () => { + const hosts = await this.list(); + const host: StoredHost = { + hostId: toBase64Url(randomBytes(16)), + hostToken: toBase64Url(randomBytes(32)), + label, + enrolledAt: this.now(), + }; + hosts.push(host); + await this.writeAtomic(hosts); + return host; + }); + } +} diff --git a/server/test/app.test.mjs b/server/test/app.test.mjs index a88d344e..3e75d67b 100644 --- a/server/test/app.test.mjs +++ b/server/test/app.test.mjs @@ -3,15 +3,21 @@ import assert from 'node:assert/strict'; import { HELLO_ROUTE } from 'server-lib-common'; -import { app } from '../dist/app.js'; +import { freshApp } from './helpers.mjs'; -test('GET / returns the Hono hello-world', async () => { +test('GET / serves the stub landing page when the Pocket app is not built', async () => { + // freshApp configures no `pocketDir`, so this is always the stub (slice 5 + // serves the real Pocket build here when `pocketDir` points at one). + const { app } = await freshApp(); const res = await app.request('/'); assert.equal(res.status, 200); - assert.equal(await res.text(), 'Hello from Hono!'); + const body = await res.text(); + assert.match(body, /^Dormouse selfhost server/); + assert.match(body, /build:pocket/); }); test(`GET ${HELLO_ROUTE} returns the shared greeting`, async () => { + const { app } = await freshApp(); const res = await app.request(HELLO_ROUTE); assert.equal(res.status, 200); assert.deepEqual(await res.json(), { message: 'Hello, world!' }); diff --git a/server/test/cors.test.mjs b/server/test/cors.test.mjs new file mode 100644 index 00000000..ee1e2934 --- /dev/null +++ b/server/test/cors.test.mjs @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { API_ROUTES } from 'server-lib-common'; +import { freshApp } from './helpers.mjs'; + +// The standalone webview (Host) enrolls from a different origin than the +// server, so the JSON POST triggers a CORS preflight. Without an OPTIONS +// handler the preflight 404s and the fetch never happens. +test('preflight for host enrollment succeeds cross-origin', async () => { + const { app } = await freshApp(); + const res = await app.request(API_ROUTES.hostEnroll, { + method: 'OPTIONS', + headers: { + origin: 'http://localhost:1420', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type', + }, + }); + assert.equal(res.status, 204); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); + assert.match(res.headers.get('access-control-allow-headers') ?? '', /content-type/i); +}); + +test('cross-origin API responses carry the allow-origin header', async () => { + const { app } = await freshApp(); + const res = await app.request(API_ROUTES.signinBegin, { + method: 'POST', + headers: { origin: 'http://localhost:1420', 'content-type': 'application/json' }, + body: '{}', + }); + assert.equal(res.status, 200); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); +}); + +test('bearer-authed routes allow the Authorization header in preflight', async () => { + const { app } = await freshApp(); + const res = await app.request(API_ROUTES.hosts, { + method: 'OPTIONS', + headers: { + origin: 'http://localhost:1420', + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'authorization', + }, + }); + assert.equal(res.status, 204); + assert.match(res.headers.get('access-control-allow-headers') ?? '', /authorization/i); +}); diff --git a/server/test/handshake.test.mjs b/server/test/handshake.test.mjs new file mode 100644 index 00000000..0e0b84a2 --- /dev/null +++ b/server/test/handshake.test.mjs @@ -0,0 +1,689 @@ +/** + * Slice-3 security handshake, end to end through a real listening server + * (docs/specs/server.md "Relay"; docs/specs/remote-security-model.md). The + * "phone" is a real WebAuthn authenticator (`SimAuthenticator`) plus a real + * device keypair (`generateDeviceKeyPair` / `signDeviceChallenge`); the "laptop" + * is the reusable `FakeHost` wiring the actual security primitives. Nothing is + * stubbed but the transport is loopback. + * + * The deny cases prove the two independent guarantees of the model: the server + * refuses to relay anything it cannot verify (a forged request never even burns + * a Host challenge), and the Host is the final authority for everything the + * server cannot see (a registered passkey on an unpaired device is denied by + * the Host after the server forwards it). + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DEFAULT_CHALLENGE_TTL_MS, + REMOTE_EVENTS, + REMOTE_METHODS, + SELFHOST_ACCOUNT_ID, + WS_ROUTES, + WS_TOKEN_PARAM, + fromBase64Url, + generateDeviceKeyPair, + hashPasskeyPublicKey, + signDeviceChallenge, + toBase64Url, + utf8Decode, + utf8Encode, +} from 'server-lib-common'; + +import { + ORIGIN, + RP_ID, + enrollHost, + freshApp, + makeClock, + newAuthenticator, + ownerSession, + sleep, + startServer, + wsConnect, +} from './helpers.mjs'; +import { FakeHost } from './harness/fake-host.mjs'; +import { Handshake } from '../dist/handshake.js'; + +// --- Fixtures -------------------------------------------------------------- + +/** Boot a server + one enrolled `FakeHost`, ready to accept clients. */ +async function boot({ autoApprove = true, requireUserVerification } = {}) { + const created = await freshApp({ requireUserVerification }); + const server = await startServer(created); + const { body: host } = await enrollHost(created.app, { label: 'Laptop' }); + const fakeHost = await startFakeHost(server, host, { autoApprove }); + const extraHosts = []; + const close = async () => { + fakeHost.close(); + for (const h of extraHosts) h.close(); + await server.close(); + }; + return { app: created.app, server, host, fakeHost, extraHosts, close }; +} + +/** Connect a `FakeHost` for an already-enrolled host record. */ +async function startFakeHost(server, host, { autoApprove = true } = {}) { + const fakeHost = new FakeHost({ + serverUrl: server.wsUrl, + hostToken: host.hostToken, + hostId: host.hostId, + origin: host.origin, + rpId: host.rpId, + autoApprove, + }); + await fakeHost.ready; + return fakeHost; +} + +/** + * A "phone": a registered passkey (the authenticator), a device keypair, and a + * live client socket authenticated with the session token from the same sign-in. + */ +async function phone(app, server) { + const { authenticator, sessionToken } = await ownerSession(app); + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=${sessionToken}`); + await socket.ready; + const deviceKey = await generateDeviceKeyPair(); + return { authenticator, sessionToken, socket, deviceKey }; +} + +/** Collect the named FakeHost events into one array, in arrival order. */ +function collect(fakeHost, ...names) { + const events = []; + for (const name of names) fakeHost.on(name, (payload) => events.push({ name, ...payload })); + return events; +} + +// --- Request builders (with tamper knobs for the deny cases) --------------- + +async function buildPairingRequest(p, overrides = {}) { + return { + accountId: SELFHOST_ACCOUNT_ID, + passkeyCredentialId: p.authenticator.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(p.authenticator.publicKey), + devicePublicKey: p.deviceKey.devicePublicKey, + requestedLabel: 'iPhone Safari', + ...overrides, + }; +} + +async function buildConnectionRequest(p, challenge, hostId, tamper = {}) { + const authenticator = tamper.assertWith ?? p.authenticator; + const assertion = await authenticator.assert({ + challenge: tamper.assertChallenge ?? challenge, + origin: ORIGIN, + rpId: RP_ID, + tamper: tamper.assertion ?? {}, + }); + const deviceSignature = await signDeviceChallenge(p.deviceKey.privateKey, { + hostId: tamper.signForHostId ?? hostId, + challenge, + devicePublicKey: p.deviceKey.devicePublicKey, + }); + return { + accountId: tamper.accountId ?? SELFHOST_ACCOUNT_ID, + devicePublicKey: p.deviceKey.devicePublicKey, + challenge: tamper.requestChallenge ?? challenge, + deviceSignature, + passkey: { + publicKey: tamper.passkeyPublicKey ?? authenticator.publicKey, + assertion, + }, + }; +} + +// --- Flow helpers ---------------------------------------------------------- + +async function pair(p, hostId, overrides) { + p.socket.send({ t: 'pair', hostId, request: await buildPairingRequest(p, overrides) }); + return p.socket.take(); +} + +async function connect(p, hostId) { + p.socket.send({ t: 'connect', hostId }); + return p.socket.take(); // the relayed `challenge` frame +} + +async function connect2(p, hostId, challenge, tamper) { + const request = await buildConnectionRequest(p, challenge, hostId, tamper); + p.socket.send({ t: 'connect2', hostId, request }); + return { request, decision: await p.socket.take() }; +} + +function helloRequest(requestId = 'r1') { + return { requestId, method: 'hello', params: { protocolVersion: 1, viewer: 'phone' } }; +} + +/** A fresh, well-formed base64url value that is not equal to a real challenge. */ +function otherChallenge() { + return toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); +} + +// --- The full flow --------------------------------------------------------- + +test('pair (autoApprove) → approved with an ACL record for this phone', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + const result = await pair(p, host.hostId); + + assert.equal(result.t, 'pair-result'); + assert.equal(result.approved, true); + assert.equal(result.clientId, undefined, 'the clientId secret never leaks to the phone'); + assert.equal(result.record.hostId, host.hostId); + assert.equal(result.record.accountId, SELFHOST_ACCOUNT_ID); + assert.equal(result.record.passkeyCredentialId, p.authenticator.credentialId); + assert.equal(result.record.devicePublicKey, p.deviceKey.devicePublicKey); + assert.equal(result.record.label, 'iPhone Safari'); + // The Host actually wrote it. + assert.equal(fakeHost.acl.activeRecords().length, 1); + } finally { + await close(); + } +}); + +test('connect → challenge → connect2 allowed → hello round-trips, unknown method refused', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + + const challengeFrame = await connect(p, host.hostId); + assert.equal(challengeFrame.t, 'challenge'); + assert.equal(challengeFrame.hostId, host.hostId); + assert.equal(typeof challengeFrame.challenge, 'string'); + + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge); + assert.deepEqual(decision, { t: 'decision', allowed: true }); + + // hello over the now-opaque `msg` relay. + p.socket.send({ t: 'msg', data: helloRequest('r1') }); + const hello = await p.socket.take(); + assert.equal(hello.t, 'msg'); + assert.equal(hello.data.requestId, 'r1'); + assert.equal(hello.data.ok, true); + assert.equal(hello.data.result.protocolVersion, 1); + assert.equal(hello.data.result.hostId, host.hostId); + + // Unknown methods echo ok:false. + p.socket.send({ t: 'msg', data: { requestId: 'r2', method: 'frobnicate' } }); + const echoed = await p.socket.take(); + assert.equal(echoed.data.requestId, 'r2'); + assert.equal(echoed.data.ok, false); + assert.match(echoed.data.error, /unknown method/); + + assert.equal(fakeHost.established.size, 1); + } finally { + await close(); + } +}); + +// --- Host is the final authority ------------------------------------------- + +test('unpaired device: server forwards, Host denies (device-not-paired), msg stays blocked', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + // Deliberately skip pairing: the passkey is registered to the account, so + // every server-side check passes and the request is forwarded — but the + // Host has no ACL record for this device key. + const decisions = collect(fakeHost, 'decision'); + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge); + + assert.equal(decision.t, 'decision'); + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('device-not-paired'), JSON.stringify(decision.failures)); + assert.equal(decisions.length, 1, 'the Host actually saw the connect2 (server forwarded it)'); + + // A denied decision never establishes the session: msg is dropped both ways. + const msgs = collect(fakeHost, 'msg'); + p.socket.send({ t: 'msg', data: helloRequest('blocked') }); + assert.ok(await p.socket.quiet(), 'no msg response after a denied decision'); + assert.equal(msgs.length, 0, 'the Host never receives msg for an unestablished client'); + } finally { + await close(); + } +}); + +// --- Server refuses to relay what it cannot verify ------------------------- + +test('server rejects a pair for a credential not on the account, without forwarding', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + const pairs = collect(fakeHost, 'pair'); + const stranger = await newAuthenticator(); // never registered + + const result = await pair(p, host.hostId, { + passkeyCredentialId: stranger.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(stranger.publicKey), + }); + + assert.equal(result.t, 'pair-result'); + assert.equal(result.approved, false); + assert.match(result.error, /not registered/); + assert.equal(pairs.length, 0, 'the Host never saw the forged pair request'); + } finally { + await close(); + } +}); + +test('server rejects a malformed pair shape before forwarding', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + const pairs = collect(fakeHost, 'pair'); + const request = await buildPairingRequest(p); + delete request.devicePublicKey; + request.requestedLabel = 42; + + p.socket.send({ t: 'pair', hostId: host.hostId, request }); + const result = await p.socket.take(); + + assert.equal(result.t, 'pair-result'); + assert.equal(result.approved, false); + assert.match(result.error, /malformed pairing request/); + assert.equal(pairs.length, 0, 'the Host never saw the malformed pair request'); + } finally { + await close(); + } +}); + +test('server rejects connect2 when the assertion is bound to a different challenge', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + assertChallenge: otherChallenge(), // assertion signs a stale/other challenge + }); + + assert.equal(decision.allowed, false); + assert.ok( + decision.failures.includes('passkey-assertion-invalid'), + JSON.stringify(decision.failures), + ); + assert.equal(decisions.length, 0, 'rejected before forwarding — Host challenge stays unburned'); + } finally { + await close(); + } +}); + +test('server rejects connect2 for an unknown (unregistered) credential', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + const stranger = await newAuthenticator(); // valid assertions, but not registered + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + assertWith: stranger, + }); + + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('passkey-not-paired'), JSON.stringify(decision.failures)); + assert.equal(decisions.length, 0, 'rejected before forwarding'); + } finally { + await close(); + } +}); + +test('server rejects connect2 when the passkey publicKey is substituted', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + // A compromised server could swap the presented key; verification against the + // STORED key must still catch it. + const substitute = await newAuthenticator(); + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + passkeyPublicKey: substitute.publicKey, + }); + + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('passkey-key-mismatch'), JSON.stringify(decision.failures)); + assert.equal(decisions.length, 0, 'rejected before forwarding'); + } finally { + await close(); + } +}); + +test('server rejects a replayed connect2 (same challenge twice) before forwarding', async () => { + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + const challengeFrame = await connect(p, host.hostId); + const { request, decision } = await connect2(p, host.hostId, challengeFrame.challenge); + assert.equal(decision.allowed, true); + assert.equal(decisions.length, 1); + + // Resend the exact same connect2. The server's relayed challenge is single-use. + p.socket.send({ t: 'connect2', hostId: host.hostId, request }); + const replay = await p.socket.take(); + assert.equal(replay.allowed, false); + assert.ok(replay.failures.includes('challenge-invalid'), JSON.stringify(replay.failures)); + assert.equal(decisions.length, 1, 'the Host never saw the replay'); + } finally { + await close(); + } +}); + +test('server derives relayed challenge expiry from its own observation clock', async () => { + const clock = makeClock(); + const authenticator = await newAuthenticator(); + const gate = new Handshake( + { + findPasskey: async (credentialId) => + credentialId === authenticator.credentialId + ? { + credentialId, + publicKey: authenticator.publicKey, + label: 'Test Passkey', + createdAt: clock.now(), + } + : undefined, + }, + { origin: ORIGIN, rpId: RP_ID, now: clock.now }, + ); + const challenge = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); + + gate.observeChallenge( + 'client-1', + 'host-1', + challenge, + clock.now() - DEFAULT_CHALLENGE_TTL_MS, + ); + clock.advance(DEFAULT_CHALLENGE_TTL_MS - 1); + + const assertion = await authenticator.assert({ challenge, origin: ORIGIN, rpId: RP_ID }); + const result = await gate.checkConnect2('client-1', 'host-1', { + accountId: SELFHOST_ACCOUNT_ID, + devicePublicKey: 'device-public-key', + challenge, + deviceSignature: 'device-signature', + passkey: { + publicKey: authenticator.publicKey, + assertion, + }, + }); + + assert.deepEqual(result, { ok: true }); +}); + +test('server rejects a connect2 that answers a different Host challenge', async () => { + const clock = makeClock(); + const authenticator = await newAuthenticator(); + const gate = new Handshake( + { + findPasskey: async (credentialId) => + credentialId === authenticator.credentialId + ? { + credentialId, + publicKey: authenticator.publicKey, + label: 'Test Passkey', + createdAt: clock.now(), + } + : undefined, + }, + { origin: ORIGIN, rpId: RP_ID, now: clock.now }, + ); + const challenge = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); + gate.observeChallenge('client-1', 'host-a', challenge, clock.now() + DEFAULT_CHALLENGE_TTL_MS); + + const assertion = await authenticator.assert({ challenge, origin: ORIGIN, rpId: RP_ID }); + const result = await gate.checkConnect2('client-1', 'host-b', { + accountId: SELFHOST_ACCOUNT_ID, + devicePublicKey: 'device-public-key', + challenge, + deviceSignature: 'device-signature', + passkey: { + publicKey: authenticator.publicKey, + assertion, + }, + }); + + assert.deepEqual(result, { ok: false, failures: ['challenge-invalid'] }); +}); + +// --- requireUserVerification: Server mirrors the Host's UV demand ----------- + +test('requireUserVerification: server rejects a UV-absent connect2 before forwarding (mirrors the Host)', async () => { + // The Host enforces UV via ConnectionPolicy.requireUserVerification; the server + // must demand the same, or the two verifiers silently disagree the moment UV + // is turned on. With UV required, a presence-only assertion is rejected here — + // before the Host is ever consulted. + const { app, server, host, fakeHost, close } = await boot({ requireUserVerification: true }); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const decisions = collect(fakeHost, 'decision'); + + const challengeFrame = await connect(p, host.hostId); + // A well-formed assertion whose ONLY defect is the missing user-verification + // flag (user present, but no biometric/PIN). + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + assertion: { userVerified: false }, + }); + + assert.equal(decision.allowed, false); + assert.ok( + decision.failures.includes('passkey-assertion-invalid'), + JSON.stringify(decision.failures), + ); + assert.equal(decisions.length, 0, 'rejected before forwarding — Host challenge stays unburned'); + } finally { + await close(); + } +}); + +test('default (UV not configured): a UV-absent connect2 is still allowed (unchanged behavior)', async () => { + // The knob defaults to off: exactly the same UV-absent assertion the strict + // server rejects above is accepted end to end, proving the fix adds no + // behavior change for existing deployments. + const { app, server, host, fakeHost, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + + const challengeFrame = await connect(p, host.hostId); + const { decision } = await connect2(p, host.hostId, challengeFrame.challenge, { + assertion: { userVerified: false }, + }); + + assert.deepEqual(decision, { t: 'decision', allowed: true }); + assert.equal(fakeHost.established.size, 1, 'the Host established the session'); + } finally { + await close(); + } +}); + +// --- Stale-session invalidation on Host restart/replacement ----------------- + +test('a Host restart invalidates an established session (host-gone, then msg blocked)', async () => { + const { app, server, host, fakeHost, extraHosts, close } = await boot(); + try { + const p = await phone(app, server); + assert.equal((await pair(p, host.hostId)).approved, true); + const challengeFrame = await connect(p, host.hostId); + assert.equal((await connect2(p, host.hostId, challengeFrame.challenge)).decision.allowed, true); + + // Prove the session works first. + p.socket.send({ t: 'msg', data: helloRequest('r1') }); + assert.equal((await p.socket.take()).data.ok, true); + + // The Host "restarts": a new socket for the same host displaces the old one. + // Its ACL is empty again, so the old established session must be invalidated. + const restarted = await startFakeHost(server, host); + extraHosts.push(restarted); + + const gone = await p.socket.take(); + assert.deepEqual(gone, { t: 'host-gone' }); + + const msgs = collect(restarted, 'msg'); + p.socket.send({ t: 'msg', data: helloRequest('r2') }); + assert.ok(await p.socket.quiet(), 'the old session no longer relays msg'); + assert.equal(msgs.length, 0, 'the restarted Host receives nothing from the stale client'); + } finally { + await close(); + } +}); + +// --- Synthetic directory + echo terminal over the real wire ----------------- + +/** Pair + connect an established phone session, ready for `msg` traffic. */ +async function establish(p, hostId) { + assert.equal((await pair(p, hostId)).approved, true); + const challengeFrame = await connect(p, hostId); + assert.equal((await connect2(p, hostId, challengeFrame.challenge)).decision.allowed, true); +} + +/** Send a remote-api request over a `msg` frame. */ +function remote(p, requestId, method, params) { + p.socket.send({ t: 'msg', data: { requestId, method, params } }); +} + +/** Decode a `terminal.data` event frame's base64url utf8 PTY bytes to a string. */ +function eventText(frame) { + return utf8Decode(fromBase64Url(frame.data.data.bytes)); +} + +test('remote terminal: directory snapshot, attach banner, echo write, resize, detach blocks input', async () => { + const { app, server, host, close } = await boot(); + try { + const p = await phone(app, server); + await establish(p, host.hostId); + + // directory.watch → ack (subId === requestId) then a snapshot event. + remote(p, 'dw', REMOTE_METHODS.directoryWatch, {}); + const watchAck = await p.socket.take(); + assert.equal(watchAck.data.requestId, 'dw'); + assert.equal(watchAck.data.ok, true); + assert.equal(watchAck.data.result.subId, 'dw'); + + const snapshot = await p.socket.take(); + assert.equal(snapshot.data.subId, 'dw'); + assert.equal(snapshot.data.event, REMOTE_EVENTS.directorySnapshot); + const entries = snapshot.data.data.entries; + assert.equal(entries.length, 2); + assert.equal(entries[0].type, 'terminal'); + const surfaceId = entries[0].surfaceId; + + // surface.attach → authoritative size + a banner terminal.data event. + remote(p, 'at', REMOTE_METHODS.surfaceAttach, { surfaceId, cols: 100, rows: 40 }); + const attachAck = await p.socket.take(); + assert.equal(attachAck.data.requestId, 'at'); + assert.equal(attachAck.data.ok, true); + assert.deepEqual(attachAck.data.result, { cols: 100, rows: 40 }); + + const banner = await p.socket.take(); + assert.equal(banner.data.subId, 'at'); + assert.equal(banner.data.event, REMOTE_EVENTS.terminalData); + const bannerText = eventText(banner); + assert.match(bannerText, /attached/); + assert.match(bannerText, /100x40/); + + // terminal.write → ack + the bytes echoed back (with a redrawn prompt). + remote(p, 'wr', REMOTE_METHODS.terminalWrite, { + surfaceId, + bytes: toBase64Url(utf8Encode('echo hi\r')), + }); + assert.equal((await p.socket.take()).data.ok, true); + const echo = await p.socket.take(); + assert.equal(echo.data.event, REMOTE_EVENTS.terminalData); + const echoText = eventText(echo); + assert.match(echoText, /echo hi/); + assert.ok(echoText.endsWith('$ '), 'the \\r redraws a fake prompt'); + + // terminal.resize → ack + a size-note terminal.data event. + remote(p, 'rs', REMOTE_METHODS.terminalResize, { surfaceId, cols: 120, rows: 50 }); + const resizeAck = await p.socket.take(); + assert.equal(resizeAck.data.ok, true); + assert.deepEqual(resizeAck.data.result, { cols: 120, rows: 50 }); + assert.match(eventText(await p.socket.take()), /resized to 120x50/); + + // surface.detach → ack, then stale input is rejected and produces no data. + remote(p, 'dt', REMOTE_METHODS.surfaceDetach, { surfaceId }); + assert.equal((await p.socket.take()).data.ok, true); + + remote(p, 'wr2', REMOTE_METHODS.terminalWrite, { + surfaceId, + bytes: toBase64Url(utf8Encode('ping\r')), + }); + const writeAck2 = await p.socket.take(); + assert.equal(writeAck2.data.requestId, 'wr2'); + assert.equal(writeAck2.data.ok, false); + assert.match(writeAck2.data.error, /not attached/); + assert.ok(await p.socket.quiet(), 'detach blocks input: no terminal.data after detach'); + } finally { + await close(); + } +}); + +test('remote terminal: a stale detach for the previous surface does not kill the new attachment', async () => { + const { app, server, host, close } = await boot(); + try { + const p = await phone(app, server); + await establish(p, host.hostId); + + remote(p, 'dw', REMOTE_METHODS.directoryWatch, {}); + await p.socket.take(); // watch ack + const snapshot = await p.socket.take(); + const [a, b] = snapshot.data.data.entries; + + // Rapid pane switch: attach A, then attach B (replacing A). + remote(p, 'atA', REMOTE_METHODS.surfaceAttach, { surfaceId: a.surfaceId, cols: 80, rows: 24 }); + await p.socket.take(); // attach A ack + await p.socket.take(); // banner A + remote(p, 'atB', REMOTE_METHODS.surfaceAttach, { surfaceId: b.surfaceId, cols: 80, rows: 24 }); + await p.socket.take(); // attach B ack + await p.socket.take(); // banner B + + // A stale detach naming the OLD surface must be an idempotent no-op. + remote(p, 'dtA', REMOTE_METHODS.surfaceDetach, { surfaceId: a.surfaceId }); + assert.equal((await p.socket.take()).data.ok, true); + + // B's stream is still live: a write still echoes. + remote(p, 'wr', REMOTE_METHODS.terminalWrite, { + surfaceId: b.surfaceId, + bytes: toBase64Url(utf8Encode('still here\r')), + }); + assert.equal((await p.socket.take()).data.ok, true); + const echo = await p.socket.take(); + assert.equal(echo.data.event, REMOTE_EVENTS.terminalData); + assert.match(eventText(echo), /still here/); + + // Detaching the CURRENT surface silences it. + remote(p, 'dtB', REMOTE_METHODS.surfaceDetach, { surfaceId: b.surfaceId }); + assert.equal((await p.socket.take()).data.ok, true); + remote(p, 'wr2', REMOTE_METHODS.terminalWrite, { + surfaceId: b.surfaceId, + bytes: toBase64Url(utf8Encode('gone\r')), + }); + const detachedWrite = await p.socket.take(); + assert.equal(detachedWrite.data.ok, false); + assert.match(detachedWrite.data.error, /not attached/); + assert.ok(await p.socket.quiet(), 'no terminal.data after a matching detach'); + } finally { + await close(); + } +}); + +// A short settle so no test leaves an in-flight frame racing teardown. +test.after?.(async () => { + await sleep(10); +}); diff --git a/server/test/harness/fake-host.mjs b/server/test/harness/fake-host.mjs new file mode 100644 index 00000000..310ee979 --- /dev/null +++ b/server/test/harness/fake-host.mjs @@ -0,0 +1,307 @@ +/** + * A headless Node Host (Dormouse Terminal) for exercising the relay end to end. + * + * It speaks the Host side of the wire contract (server-lib-common `wire.ts`) + * over a real `/ws/host` socket, wiring the exact security primitives the real + * standalone Host will use in slice 4 — `HostAcl`, `HostChallengeIssuer`, + * `PairingCeremony`, and `authorizeConnection`. Everything is in memory, so a + * fresh instance (reconnecting with the same token) models a Host restart: its + * ACL starts empty again. + * + * Constructor: `{ serverUrl, hostToken, hostId, origin, rpId, autoApprove }`. + * `serverUrl` may be `http(s)://…` or `ws(s)://…`. When `autoApprove` is true a + * `pair` is approved the moment it arrives; otherwise call `approve(clientId)` / + * `deny(clientId)` from the pairing-approval hook. Subscribe to events for logs + * and assertions: `open`, `close`, `pair`, `paired`, `denied`, `connect`, + * `decision`, `msg`, `client-gone`. + * + * Slice 5's smoke test and manual `scripts/fake-host.mjs` reuse this class. + */ + +import { EventEmitter } from 'node:events'; + +import { + HostAcl, + HostChallengeIssuer, + PairingCeremony, + REMOTE_EVENTS, + REMOTE_METHODS, + WS_ROUTES, + WS_TOKEN_PARAM, + authorizeConnection, + clampTerminalDimension, + fromBase64Url, + toBase64Url, + utf8Decode, + utf8Encode, +} from 'server-lib-common'; + +export class FakeHost extends EventEmitter { + constructor({ serverUrl, hostToken, hostId, origin, rpId, autoApprove = true }) { + super(); + this.hostId = hostId; + this.autoApprove = autoApprove; + this.policy = { rpId, origin }; + this.acl = new HostAcl(hostId); + this.challenges = new HostChallengeIssuer(); + this.ceremony = new PairingCeremony(this.acl); + /** clientIds whose connection the Host allowed — the `msg` gate on this side. */ + this.established = new Set(); + /** clientId → pairingId awaiting a manual approve/deny (autoApprove off). */ + this.pending = new Map(); + /** + * A tiny synthetic terminal directory so the remote adapter is testable + * without a real Host: two in-memory "echo shells" addressable by surfaceId. + */ + this.surfaces = [ + { surfaceId: 'srf-zsh', paneRef: 'pane-zsh', title: 'zsh', cols: 80, rows: 24 }, + { surfaceId: 'srf-vim', paneRef: 'pane-vim', title: 'vim', cols: 80, rows: 24 }, + ]; + /** clientId → directory-watch subId (the request id it was opened with). */ + this.directorySubs = new Map(); + /** clientId → { surfaceId, subId } for the one attached surface, if any. */ + this.attachments = new Map(); + + const wsBase = serverUrl.replace(/^http/, 'ws'); + this.ws = new WebSocket(`${wsBase}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${hostToken}`); + this.ready = new Promise((resolve, reject) => { + this.ws.addEventListener('open', () => { + this.emit('open'); + resolve(); + }); + this.ws.addEventListener('error', (ev) => reject(ev.error ?? new Error('host ws error'))); + this.ws.addEventListener('close', (ev) => reject(new Error(`closed before open (${ev.code})`))); + }); + this.closed = new Promise((resolve) => this.ws.addEventListener('close', (ev) => resolve(ev))); + this.ws.addEventListener('close', (ev) => this.emit('close', ev)); + this.ws.addEventListener('message', (ev) => { + void this.#onFrame(ev.data); + }); + } + + #send(frame) { + try { + this.ws.send(JSON.stringify(frame)); + } catch { + /* socket mid-close */ + } + } + + async #onFrame(raw) { + let frame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : ''); + } catch { + return; + } + if (!frame || typeof frame.t !== 'string' || typeof frame.clientId !== 'string') return; + const { clientId } = frame; + switch (frame.t) { + case 'pair': { + this.emit('pair', { clientId, request: frame.request }); + const ticket = this.ceremony.begin(frame.request); + this.pending.set(clientId, ticket.pairingId); + if (this.autoApprove) this.approve(clientId); + return; + } + case 'connect': { + const { challenge, expiresAt } = this.challenges.issue(); + this.emit('connect', { clientId, challenge }); + this.#send({ t: 'challenge', clientId, challenge, expiresAt }); + return; + } + case 'connect2': { + const decision = await authorizeConnection( + { hostId: this.hostId, acl: this.acl, challenges: this.challenges, policy: this.policy }, + frame.request, + ); + if (decision.allowed) this.established.add(clientId); + this.emit('decision', { clientId, allowed: decision.allowed, failures: decision.failures }); + // `failures` is optional on the wire; omit it on an allowed decision. + this.#send({ + t: 'decision', + clientId, + allowed: decision.allowed, + ...(decision.allowed ? {} : { failures: decision.failures }), + }); + return; + } + case 'msg': { + if (!this.established.has(clientId)) return; // gate: never before an allowed decision + this.#handleRemoteApi(clientId, frame.data); + return; + } + case 'client-gone': { + this.established.delete(clientId); + this.pending.delete(clientId); + this.directorySubs.delete(clientId); + this.attachments.delete(clientId); + this.emit('client-gone', { clientId }); + return; + } + default: + return; + } + } + + /** + * Remote-api v1 with a synthetic directory + echo terminal. `hello` answers + * capabilities; `directory.watch` snapshots the fake surfaces; `surface.attach` + * streams a size banner; `terminal.write` echoes bytes back (treating `\r` as a + * newline and re-drawing a prompt); `terminal.resize` notes the new size. Input + * and resize only apply to the currently attached surface. Unknown methods echo + * ok:false. + */ + #handleRemoteApi(clientId, data) { + const request = data; + if (!request || typeof request.requestId !== 'string' || typeof request.method !== 'string') { + return; + } + const { requestId, method, params } = request; + + const respond = (response) => { + this.emit('msg', { clientId, request, response }); + this.#send({ t: 'msg', clientId, data: response }); + }; + const ok = (result = {}) => respond({ requestId, ok: true, result }); + const fail = (error) => respond({ requestId, ok: false, error }); + + switch (method) { + case REMOTE_METHODS.hello: + ok({ protocolVersion: 1, hostId: this.hostId, grants: { input: true, layout: true } }); + return; + + case REMOTE_METHODS.directoryWatch: { + // Host convention: the subscription id is the request's own requestId. + this.directorySubs.set(clientId, requestId); + ok({ subId: requestId }); + this.#event(clientId, requestId, REMOTE_EVENTS.directorySnapshot, { + entries: this.#directoryEntries(), + }); + return; + } + + case REMOTE_METHODS.surfaceAttach: { + const surface = this.#surface(params?.surfaceId); + if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); + surface.cols = clampTerminalDimension(params.cols, surface.cols); + surface.rows = clampTerminalDimension(params.rows, surface.rows); + this.attachments.set(clientId, { surfaceId: surface.surfaceId, subId: requestId }); + ok({ cols: surface.cols, rows: surface.rows }); + this.#emitData( + clientId, + requestId, + `\r\n[fake-host] attached ${surface.title} (${surface.cols}x${surface.rows})\r\n$ `, + ); + return; + } + + case REMOTE_METHODS.terminalWrite: { + const surface = this.#surface(params?.surfaceId); + if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); + const attachment = this.attachments.get(clientId); + if (!attachment || attachment.surfaceId !== surface.surfaceId) { + return fail(`surface is not attached: ${surface.surfaceId}`); + } + ok(); + const input = utf8Decode(fromBase64Url(params.bytes)); + const echoed = input.includes('\r') ? `${input.replace(/\r/g, '\r\n')}$ ` : input; + this.#emitData(clientId, attachment.subId, echoed); + return; + } + + case REMOTE_METHODS.terminalResize: { + const surface = this.#surface(params?.surfaceId); + if (!surface) return fail(`no such surface: ${params?.surfaceId ?? '(none)'}`); + const attachment = this.attachments.get(clientId); + if (!attachment || attachment.surfaceId !== surface.surfaceId) { + return fail(`surface is not attached: ${surface.surfaceId}`); + } + surface.cols = clampTerminalDimension(params.cols, surface.cols); + surface.rows = clampTerminalDimension(params.rows, surface.rows); + ok({ cols: surface.cols, rows: surface.rows }); + this.#emitData( + clientId, + attachment.subId, + `\r\n[fake-host] resized to ${surface.cols}x${surface.rows}\r\n`, + ); + return; + } + + case REMOTE_METHODS.surfaceDetach: { + // Detach names its surface: a stale detach for a pane the client + // already switched away from must not kill the newer attachment. + const attachment = this.attachments.get(clientId); + if (attachment && attachment.surfaceId === params?.surfaceId) { + this.attachments.delete(clientId); // stops any further terminal.data + } + ok(); + return; + } + + default: + fail(`unknown method: ${method}`); + return; + } + } + + /** A directory snapshot of the synthetic surfaces. */ + #directoryEntries() { + return this.surfaces.map((surface, index) => ({ + paneRef: surface.paneRef, + surfaceId: surface.surfaceId, + type: 'terminal', + title: surface.title, + focused: index === 0, + activity: 'prompt', + alive: true, + ringing: false, + hasTODO: false, + })); + } + + #surface(surfaceId) { + return this.surfaces.find((surface) => surface.surfaceId === surfaceId); + } + + /** Send a remote-api event to a client, wrapped in a `msg` relay frame. */ + #event(clientId, subId, event, eventData) { + this.#send({ t: 'msg', clientId, data: { subId, event, data: eventData } }); + } + + /** Emit a `terminal.data` event with `text` as base64url utf8 PTY bytes. */ + #emitData(clientId, subId, text) { + this.#event(clientId, subId, REMOTE_EVENTS.terminalData, { + bytes: toBase64Url(utf8Encode(text)), + }); + } + + /** Local approval on the Host: the only path that writes to the ACL. */ + approve(clientId, { approvedBy = 'host-user', label } = {}) { + const pairingId = this.pending.get(clientId); + if (!pairingId) return undefined; + this.pending.delete(clientId); + const record = this.ceremony.approve(pairingId, { approvedBy, label }); + this.emit('paired', { clientId, record }); + this.#send({ t: 'pair-result', clientId, approved: true, record }); + return record; + } + + /** Local denial on the Host: the ACL is untouched. */ + deny(clientId, { error = 'pairing denied by host' } = {}) { + const pairingId = this.pending.get(clientId); + if (!pairingId) return; + this.pending.delete(clientId); + this.ceremony.deny(pairingId); + this.emit('denied', { clientId }); + this.#send({ t: 'pair-result', clientId, approved: false, error }); + } + + close() { + try { + this.ws.close(); + } catch { + /* already closing */ + } + } +} diff --git a/server/test/helpers.mjs b/server/test/helpers.mjs new file mode 100644 index 00000000..f6064fb7 --- /dev/null +++ b/server/test/helpers.mjs @@ -0,0 +1,235 @@ +/** + * Shared scaffolding for the slice-1 server tests. Each test gets a fresh temp + * state dir and its own `createApp`, so cases never share account.json, + * challenge stores, or sessions. Real WebAuthn is produced by `SimAuthenticator` + * from the server-lib-common harness — no browser required. + */ + +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { serve } from '@hono/node-server'; +import { API_ROUTES, WS_ROUTES, WS_TOKEN_PARAM, toBase64Url, utf8Encode } from 'server-lib-common'; + +import { createApp } from '../dist/app.js'; +import { SimAuthenticator } from '../../server-lib-common/test/harness/actors.mjs'; + +export const ORIGIN = 'http://localhost:3000'; +export const RP_ID = 'localhost'; +export const PASSWORD = 'correct horse battery staple'; + +/** A manually-advanced clock for TTL/expiry tests. */ +export function makeClock(startMs = 1_700_000_000_000) { + let ms = startMs; + return { + now: () => ms, + advance(delta) { + ms += delta; + }, + }; +} + +export async function freshApp({ + password = PASSWORD, + origin = ORIGIN, + now, + requireUserVerification, +} = {}) { + const stateDir = await mkdtemp(join(tmpdir(), 'dormouse-server-')); + const created = createApp({ + setupPassword: password, + origin, + stateDir, + now, + requireUserVerification, + }); + return { ...created, stateDir, origin, rpId: new URL(origin).hostname }; +} + +export function post(app, path, body) { + return app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body ?? {}), + }); +} + +export async function readAccount(stateDir) { + return JSON.parse(await readFile(join(stateDir, 'account.json'), 'utf8')); +} + +export function newAuthenticator() { + return SimAuthenticator.create({ rpId: RP_ID }); +} + +/** Build registration clientDataJSON exactly as a browser would (webauthn.create). */ +export function registrationClientData({ challenge, origin = ORIGIN, type = 'webauthn.create' }) { + return toBase64Url(utf8Encode(JSON.stringify({ type, challenge, origin, crossOrigin: false }))); +} + +/** Serialize an unpadded base64url challenge the way some browsers do in clientDataJSON. */ +export function padBase64Url(text) { + const rem = text.length % 4; + return rem === 0 ? text : `${text}${'='.repeat(4 - rem)}`; +} + +/** begin → finish registration for `authenticator`; returns the finish Response. */ +export async function register( + app, + authenticator, + { password = PASSWORD, origin = ORIGIN, label = 'Test Passkey' } = {}, +) { + const begin = await post(app, API_ROUTES.setupBegin, { password }); + const { challenge } = await begin.json(); + const clientDataJSON = registrationClientData({ challenge, origin }); + return post(app, API_ROUTES.setupFinish, { + password, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON, + label, + }); +} + +/** begin → assert → finish sign-in for `authenticator`; returns the finish Response. */ +export async function signin(app, authenticator, { origin = ORIGIN, rpId = RP_ID, tamper } = {}) { + const begin = await post(app, API_ROUTES.signinBegin, {}); + const { challenge } = await begin.json(); + const assertion = await authenticator.assert({ challenge, origin, rpId, tamper }); + const res = await post(app, API_ROUTES.signinFinish, { assertion }); + return { res, assertion }; +} + +// --- Slice 2: live server + WebSocket relay scaffolding -------------------- + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Poll `fn` until it returns truthy, or throw after `timeout`ms. */ +export async function until(fn, { timeout = 1000, interval = 5 } = {}) { + const deadline = Date.now() + timeout; + for (;;) { + if (await fn()) return; + if (Date.now() > deadline) throw new Error('condition not met in time'); + await sleep(interval); + } +} + +/** + * Boot a real listening server for a `createApp` result (WS needs a socket, not + * `app.request`). Binds port 0 and reports the OS-assigned port; the returned + * `wsUrl` is ready for `/ws/host` / `/ws/client`. + */ +/** Every {@link wsConnect} socket, so a server teardown can force them shut. */ +const OPEN_SOCKETS = new Set(); + +export function startServer(created) { + return new Promise((resolve) => { + const server = serve({ fetch: created.app.fetch, port: 0 }, (info) => { + created.injectWebSocket(server); + resolve({ + server, + port: info.port, + wsUrl: `ws://localhost:${info.port}`, + // An http server waits on its live connections, and an *upgraded* WS + // socket is no longer one it tracks — so close the client ends we know + // about and resolve on the drain callback OR a short fallback, never + // hanging teardown. + close: () => + new Promise((res) => { + for (const ws of OPEN_SOCKETS) { + try { + ws.close(); + } catch { + /* already closing */ + } + } + let done = false; + const finish = () => { + if (!done) { + done = true; + res(); + } + }; + server.close(finish); + server.closeAllConnections?.(); + setTimeout(finish, 300).unref(); + }), + }); + }); + }); +} + +/** + * Open a WebSocket and wrap it in a tiny test harness: `ready` resolves on open + * (rejects on a failed upgrade), `take()` yields received frames in order with + * an internal cursor, and `quiet()` asserts no frame arrived in a window. + */ +export function wsConnect(url) { + const ws = new WebSocket(url); + OPEN_SOCKETS.add(ws); + ws.addEventListener('close', () => OPEN_SOCKETS.delete(ws)); + const messages = []; + let cursor = 0; + ws.addEventListener('message', (ev) => { + messages.push(JSON.parse(typeof ev.data === 'string' ? ev.data : '')); + }); + const ready = new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', (ev) => reject(ev.error ?? new Error('ws error'))); + ws.addEventListener('close', (ev) => reject(new Error(`closed before open (${ev.code})`))); + }); + const closed = new Promise((resolve) => ws.addEventListener('close', (ev) => resolve(ev))); + return { + ws, + ready, + closed, + messages, + send: (frame) => ws.send(JSON.stringify(frame)), + close: () => ws.close(), + /** Next unconsumed frame, waiting up to `timeout`ms for it to arrive. */ + async take(timeout = 1000) { + await until(() => messages.length > cursor, { timeout }); + return messages[cursor++]; + }, + /** True if no new frame arrives within `ms` (i.e. the pipe stayed blocked). */ + async quiet(ms = 60) { + const before = messages.length; + await sleep(ms); + return messages.length === before; + }, + }; +} + +/** POST /api/host/enroll with the setup password; returns the JSON body. */ +export async function enrollHost(app, { label = 'Laptop', password = PASSWORD } = {}) { + const res = await post(app, API_ROUTES.hostEnroll, { password, label }); + return { res, body: await res.json() }; +} + +/** Register a fresh passkey and sign in; returns the live session token. */ +export async function ownerSession(app) { + const authenticator = await newAuthenticator(); + await register(app, authenticator); + const { res } = await signin(app, authenticator); + const { sessionToken } = await res.json(); + return { authenticator, sessionToken }; +} + +/** Enroll a host and open its `/ws/host` socket (awaiting the upgrade). */ +export async function connectHost(app, server, opts) { + const { body } = await enrollHost(app, opts); + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${body.hostToken}`); + await socket.ready; + return { host: body, socket }; +} + +/** Register+sign-in an owner and open a `/ws/client` socket (awaiting the upgrade). */ +export async function connectClient(app, server) { + const { sessionToken, authenticator } = await ownerSession(app); + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=${sessionToken}`); + await socket.ready; + return { sessionToken, authenticator, socket }; +} diff --git a/server/test/hosts.test.mjs b/server/test/hosts.test.mjs new file mode 100644 index 00000000..112549cb --- /dev/null +++ b/server/test/hosts.test.mjs @@ -0,0 +1,125 @@ +/** + * Slice-2 host enrollment and presence (docs/specs/server.md, "Relay & host + * enrollment"): the password-gated `POST /api/host/enroll`, the session-gated + * `GET /api/hosts` presence flag, and WS token rejection on both relay routes. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { API_ROUTES, WS_ROUTES, WS_TOKEN_PARAM } from 'server-lib-common'; + +import { + RP_ID, + connectHost, + enrollHost, + freshApp, + ownerSession, + post, + startServer, + until, + wsConnect, +} from './helpers.mjs'; + +/** GET /api/hosts as the owner; returns the parsed body. */ +async function listHosts(app, sessionToken) { + const res = await app.request(API_ROUTES.hosts, { + headers: { Authorization: `Bearer ${sessionToken}` }, + }); + return { res, body: await res.json() }; +} + +test('enroll happy path returns host credentials and policy', async () => { + const { app, origin } = await freshApp(); + const { res, body } = await enrollHost(app, { label: 'MacBook' }); + assert.equal(res.status, 200); + assert.equal(typeof body.hostId, 'string'); + assert.equal(typeof body.hostToken, 'string'); + assert.notEqual(body.hostId, body.hostToken); + assert.equal(body.origin, origin); + assert.equal(body.rpId, RP_ID); +}); + +test('enroll rejects a wrong password', async () => { + const { app } = await freshApp(); + const res = await post(app, API_ROUTES.hostEnroll, { password: 'wrong', label: 'x' }); + assert.equal(res.status, 401); +}); + +test('a second enrollment appends and gets distinct credentials', async () => { + const { app } = await freshApp(); + const { body: a } = await enrollHost(app); + const { body: b } = await enrollHost(app); + assert.notEqual(a.hostId, b.hostId); + assert.notEqual(a.hostToken, b.hostToken); + + const { sessionToken } = await ownerSession(app); + const { body } = await listHosts(app, sessionToken); + assert.equal(body.hosts.length, 2); +}); + +test('GET /api/hosts requires a session', async () => { + const { app } = await freshApp(); + assert.equal((await app.request(API_ROUTES.hosts)).status, 401); +}); + +test('GET /api/hosts online flag flips with the host socket', async () => { + const created = await freshApp(); + const { app } = created; + const server = await startServer(created); + try { + const { sessionToken } = await ownerSession(app); + + const enrolled = await enrollHost(app, { label: 'Laptop' }); + const hostId = enrolled.body.hostId; + + let listed = (await listHosts(app, sessionToken)).body.hosts; + assert.deepEqual(listed, [{ hostId, label: 'Laptop', online: false }]); + + const socket = wsConnect( + `${server.wsUrl}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${enrolled.body.hostToken}`, + ); + await socket.ready; + await until(async () => (await listHosts(app, sessionToken)).body.hosts[0].online === true); + + socket.close(); + await socket.closed; + await until(async () => (await listHosts(app, sessionToken)).body.hosts[0].online === false); + } finally { + await server.close(); + } +}); + +test('/ws/host rejects a bad token', async () => { + const created = await freshApp(); + const server = await startServer(created); + try { + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=bogus`); + await assert.rejects(socket.ready); + } finally { + await server.close(); + } +}); + +test('/ws/client rejects a bad token', async () => { + const created = await freshApp(); + const server = await startServer(created); + try { + const socket = wsConnect(`${server.wsUrl}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=bogus`); + await assert.rejects(socket.ready); + } finally { + await server.close(); + } +}); + +test('a host socket opens with a real enrollment token', async () => { + const created = await freshApp(); + const server = await startServer(created); + try { + const { socket } = await connectHost(created.app, server); + socket.close(); + await socket.closed; + } finally { + await server.close(); + } +}); diff --git a/server/test/relay-displaced.test.mjs b/server/test/relay-displaced.test.mjs new file mode 100644 index 00000000..ca4c1934 --- /dev/null +++ b/server/test/relay-displaced.test.mjs @@ -0,0 +1,255 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { RelayHub } from '../dist/relay.js'; + +/** + * Unit tests for the displaced-host-socket guard, driving RelayHub directly + * with fake sockets: a socket replaced by a host reconnect may still deliver + * queued frames, and those must never re-establish or feed sessions the + * replacement invalidated. + */ + +/** A HandshakeGate that approves everything — routing is what's under test. */ +const openGate = { + observeChallenge() {}, + forgetClient() {}, + checkPair: async () => ({ ok: true }), + checkConnect2: async () => ({ ok: true }), +}; + +function fakeSocket() { + return { + sent: [], + closed: false, + send(data) { + this.sent.push(JSON.parse(data)); + }, + close() { + this.closed = true; + }, + }; +} + +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** Register a client, bind it to `hostId`, and establish via a host decision. */ +async function establishedClient(hub, hostConn, hostId) { + const socket = fakeSocket(); + const client = hub.registerClient(socket); + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId })); + hub.onHostFrame(hostConn, JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true })); + assert.equal(client.established, true, 'precondition: session established'); + return { socket, client }; +} + +test('frames from a displaced host socket are ignored', async () => { + const hub = new RelayHub(openGate); + const oldSocket = fakeSocket(); + const oldConn = hub.registerHost('h1', oldSocket); + const { socket: clientSocket, client } = await establishedClient(hub, oldConn, 'h1'); + + // The host reconnects: the old socket is displaced and the session dropped. + const newSocket = fakeSocket(); + const newConn = hub.registerHost('h1', newSocket); + assert.equal(oldSocket.closed, true); + assert.equal(client.established, false); + assert.ok(clientSocket.sent.some((f) => f.t === 'host-gone')); + + const sentBefore = clientSocket.sent.length; + + // A late decision from the displaced socket must not resurrect the session. + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true }), + ); + assert.equal(client.established, false, 'displaced decision must not establish'); + + // Late msg/challenge/pair-result from the displaced socket must not reach the client. + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'msg', clientId: client.clientId, data: { stale: true } }), + ); + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'challenge', clientId: client.clientId, challenge: 'x', expiresAt: 9e15 }), + ); + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'pair-result', clientId: client.clientId, approved: true }), + ); + assert.equal(clientSocket.sent.length, sentBefore, 'no frames routed from the displaced socket'); + + // The replacement socket still works end to end. + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId: 'h1' })); + assert.ok(newSocket.sent.some((f) => f.t === 'connect')); + hub.onHostFrame( + newConn, + JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true }), + ); + assert.equal(client.established, true); + hub.onHostFrame( + newConn, + JSON.stringify({ t: 'msg', clientId: client.clientId, data: { live: true } }), + ); + assert.ok(clientSocket.sent.some((f) => f.t === 'msg' && f.data?.live === true)); +}); + +test('a displaced socket is also ignored after the replacement disconnects', async () => { + const hub = new RelayHub(openGate); + const oldConn = hub.registerHost('h1', fakeSocket()); + const { client } = await establishedClient(hub, oldConn, 'h1'); + + const newConn = hub.registerHost('h1', fakeSocket()); + hub.unregisterHost(newConn); // host fully offline now + + hub.onHostFrame( + oldConn, + JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true }), + ); + assert.equal(client.established, false, 'stale socket cannot speak for an offline host'); +}); + +test('late replies from a host the client left are ignored', async () => { + const hub = new RelayHub(openGate); + const hostA = hub.registerHost('h1', fakeSocket()); + const hostB = hub.registerHost('h2', fakeSocket()); + const clientSocket = fakeSocket(); + const client = hub.registerClient(clientSocket); + + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId: 'h1' })); + assert.equal(hostA.socket.sent.at(-1)?.t, 'connect'); + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId: 'h2' })); + assert.equal(client.hostId, 'h2'); + assert.equal(hostB.socket.sent.at(-1)?.t, 'connect'); + + hub.onHostFrame( + hostA, + JSON.stringify({ t: 'challenge', clientId: client.clientId, challenge: 'stale', expiresAt: 9e15 }), + ); + hub.onHostFrame( + hostA, + JSON.stringify({ t: 'pair-result', clientId: client.clientId, approved: true }), + ); + hub.onHostFrame( + hostA, + JSON.stringify({ t: 'decision', clientId: client.clientId, allowed: true }), + ); + + assert.deepEqual(clientSocket.sent, [], 'stale host replies must not reach the client'); + assert.equal(client.hostId, 'h2'); + assert.equal(client.established, false, 'stale host decision must not establish'); +}); + +test('rebinding a client tells the previous host client-gone', async () => { + const hub = new RelayHub(openGate); + const hostA = hub.registerHost('h1', fakeSocket()); + const hostB = hub.registerHost('h2', fakeSocket()); + const { client } = await establishedClient(hub, hostA, 'h1'); + + await hub.onClientFrame(client, JSON.stringify({ t: 'connect', hostId: 'h2' })); + + assert.deepEqual(hostA.socket.sent.at(-1), { t: 'client-gone', clientId: client.clientId }); + assert.equal(hostB.socket.sent.at(-1)?.t, 'connect'); + assert.equal(hostB.socket.sent.at(-1)?.clientId, client.clientId); + assert.equal(client.hostId, 'h2'); + assert.equal(client.established, false); +}); + +test('pair is not forwarded after async validation if the host was replaced', async () => { + const pairCheck = deferred(); + let started; + const pairStarted = new Promise((resolve) => { + started = resolve; + }); + const gate = { + ...openGate, + checkPair: async () => { + started(); + return pairCheck.promise; + }, + }; + const hub = new RelayHub(gate); + const oldHost = hub.registerHost('h1', fakeSocket()); + const clientSocket = fakeSocket(); + const client = hub.registerClient(clientSocket); + + const routing = hub.onClientFrame(client, JSON.stringify({ t: 'pair', hostId: 'h1', request: {} })); + await pairStarted; + + const newHost = hub.registerHost('h1', fakeSocket()); + pairCheck.resolve({ ok: true }); + await routing; + + assert.equal(oldHost.socket.closed, true); + assert.ok(clientSocket.sent.some((f) => f.t === 'host-gone')); + assert.equal(oldHost.socket.sent.some((f) => f.t === 'pair'), false); + assert.equal(newHost.socket.sent.some((f) => f.t === 'pair'), false); +}); + +test('pair is not forwarded after async validation if the client disconnected', async () => { + const pairCheck = deferred(); + let started; + const pairStarted = new Promise((resolve) => { + started = resolve; + }); + const gate = { + ...openGate, + checkPair: async () => { + started(); + return pairCheck.promise; + }, + }; + const hub = new RelayHub(gate); + const host = hub.registerHost('h1', fakeSocket()); + const client = hub.registerClient(fakeSocket()); + + const routing = hub.onClientFrame(client, JSON.stringify({ t: 'pair', hostId: 'h1', request: {} })); + await pairStarted; + + hub.unregisterClient(client); + pairCheck.resolve({ ok: true }); + await routing; + + assert.deepEqual(host.socket.sent, [{ t: 'client-gone', clientId: client.clientId }]); +}); + +test('connect2 is not forwarded after async validation if the host was replaced', async () => { + const connectCheck = deferred(); + let started; + const connectStarted = new Promise((resolve) => { + started = resolve; + }); + const gate = { + ...openGate, + checkConnect2: async () => { + started(); + return connectCheck.promise; + }, + }; + const hub = new RelayHub(gate); + const oldHost = hub.registerHost('h1', fakeSocket()); + const clientSocket = fakeSocket(); + const client = hub.registerClient(clientSocket); + + const routing = hub.onClientFrame( + client, + JSON.stringify({ t: 'connect2', hostId: 'h1', request: {} }), + ); + await connectStarted; + + const newHost = hub.registerHost('h1', fakeSocket()); + connectCheck.resolve({ ok: true }); + await routing; + + assert.equal(oldHost.socket.closed, true); + assert.ok(clientSocket.sent.some((f) => f.t === 'host-gone')); + assert.equal(oldHost.socket.sent.some((f) => f.t === 'connect2'), false); + assert.equal(newHost.socket.sent.some((f) => f.t === 'connect2'), false); +}); diff --git a/server/test/relay.test.mjs b/server/test/relay.test.mjs new file mode 100644 index 00000000..8b841be1 --- /dev/null +++ b/server/test/relay.test.mjs @@ -0,0 +1,266 @@ +/** + * Slice-2 relay routing (docs/specs/server.md, "Relay"): two real in-process + * WebSockets echoing through the hub. Covers the handshake allowlist (`pair` / + * `connect` / `connect2` up, `pair-result` / `challenge` / `decision` down), the + * `msg` gate that only opens on an allowed Host decision, presence teardown + * (`client-gone` / `host-gone`), and the malformed/unknown-frame paths. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { WS_ROUTES, WS_TOKEN_PARAM, hashPasskeyPublicKey } from 'server-lib-common'; + +import { connectClient, connectHost, freshApp, startServer, wsConnect } from './helpers.mjs'; + +/** A boot-a-real-server fixture; every test tears its server down in `finally`. */ +async function relay() { + const created = await freshApp(); + const server = await startServer(created); + return { app: created.app, server, close: () => server.close() }; +} + +test('pair round-trips client→host with a stamped clientId, pair-result routes back', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs, authenticator } = await connectClient(app, server); + + // Slice 3 verifies pair frames server-side, so the request must reference + // the session's real (registered) passkey rather than a synthetic one. + const pairingRequest = { + accountId: 'owner', + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(authenticator.publicKey), + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', + }; + clientWs.send({ t: 'pair', hostId: host.hostId, request: pairingRequest }); + const forwarded = await hostWs.take(); + assert.equal(forwarded.t, 'pair'); + assert.equal(typeof forwarded.clientId, 'string'); + assert.deepEqual(forwarded.request, pairingRequest); + + const record = { hostId: host.hostId, accountId: 'owner' }; + hostWs.send({ t: 'pair-result', clientId: forwarded.clientId, approved: true, record }); + const result = await clientWs.take(); + assert.equal(result.t, 'pair-result'); + assert.equal(result.approved, true); + assert.deepEqual(result.record, record); + assert.equal(result.clientId, undefined); // the clientId secret never leaks to the client + } finally { + await close(); + } +}); + +test('connect round-trips and challenge routes back with the originating hostId', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + + clientWs.send({ t: 'connect', hostId: host.hostId }); + const connFrame = await hostWs.take(); + assert.equal(connFrame.t, 'connect'); + assert.equal(typeof connFrame.clientId, 'string'); + + hostWs.send({ t: 'challenge', clientId: connFrame.clientId, challenge: 'chal-abc', expiresAt: 999 }); + const challenge = await clientWs.take(); + assert.deepEqual(challenge, { + t: 'challenge', + hostId: host.hostId, + challenge: 'chal-abc', + expiresAt: 999, + }); + } finally { + await close(); + } +}); + +test('msg is blocked until an allowed decision, then flows both ways', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + + clientWs.send({ t: 'connect', hostId: host.hostId }); + const connFrame = await hostWs.take(); + const clientId = connFrame.clientId; + + // Blocked before the decision. + clientWs.send({ t: 'msg', data: { attempted: true } }); + assert.ok(await hostWs.quiet(), 'host must not receive msg before the decision'); + + // The Host's allowed decision establishes the session. + hostWs.send({ t: 'decision', clientId, allowed: true }); + const decision = await clientWs.take(); + assert.deepEqual(decision, { t: 'decision', allowed: true }); + + // Client → host. + clientWs.send({ t: 'msg', data: { up: 1 } }); + const up = await hostWs.take(); + assert.equal(up.t, 'msg'); + assert.equal(up.clientId, clientId); + assert.deepEqual(up.data, { up: 1 }); + + // Host → client (clientId stripped). + hostWs.send({ t: 'msg', clientId, data: { down: 2 } }); + const down = await clientWs.take(); + assert.equal(down.t, 'msg'); + assert.deepEqual(down.data, { down: 2 }); + assert.equal(down.clientId, undefined); + } finally { + await close(); + } +}); + +test('msg stays blocked for a second, un-established client', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: host.hostId }); + const first = await hostWs.take(); + hostWs.send({ t: 'decision', clientId: first.clientId, allowed: true }); + await clientWs.take(); + + // A second client connects but is never approved. + const { socket: client2Ws } = await connectClient(app, server); + client2Ws.send({ t: 'connect', hostId: host.hostId }); + const second = await hostWs.take(); + + hostWs.send({ t: 'msg', clientId: second.clientId, data: { x: 1 } }); + assert.ok(await client2Ws.quiet(), 'un-established client must not receive host msg'); + client2Ws.send({ t: 'msg', data: { y: 1 } }); + assert.ok(await hostWs.quiet(), 'un-established client msg must not reach the host'); + } finally { + await close(); + } +}); + +test('connect to an unknown/offline host returns an error and nothing else', async () => { + const { app, server, close } = await relay(); + try { + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: 'no-such-host' }); + const err = await clientWs.take(); + assert.equal(err.t, 'error'); + assert.match(err.error, /offline/); + assert.ok(await clientWs.quiet(), 'no further frames for an offline connect'); + } finally { + await close(); + } +}); + +test('malformed JSON and unknown client frames get an error; host garbage is ignored', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + + clientWs.ws.send('this is not json{'); + assert.equal((await clientWs.take()).t, 'error'); + + clientWs.send({ t: 'nonsense-type' }); + assert.equal((await clientWs.take()).t, 'error'); + + // Garbage from the host is dropped without a reply or a crash — the relay + // still routes a following valid frame. + hostWs.ws.send('garbage{'); + hostWs.send({ t: 'unknown-host-frame', clientId: 'whatever' }); + assert.ok(await hostWs.quiet()); + + clientWs.send({ t: 'connect', hostId: host.hostId }); + assert.equal((await hostWs.take()).t, 'connect'); + } finally { + await close(); + } +}); + +test('client disconnect delivers client-gone to its host', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: host.hostId }); + const connFrame = await hostWs.take(); + + clientWs.close(); + await clientWs.closed; + + const gone = await hostWs.take(); + assert.deepEqual(gone, { t: 'client-gone', clientId: connFrame.clientId }); + } finally { + await close(); + } +}); + +test('host disconnect delivers host-gone to all its clients', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const clientA = await connectClient(app, server); + const clientB = await connectClient(app, server); + clientA.socket.send({ t: 'connect', hostId: host.hostId }); + await hostWs.take(); + clientB.socket.send({ t: 'connect', hostId: host.hostId }); + await hostWs.take(); + + hostWs.close(); + await hostWs.closed; + + assert.deepEqual(await clientA.socket.take(), { t: 'host-gone' }); + assert.deepEqual(await clientB.socket.take(), { t: 'host-gone' }); + } finally { + await close(); + } +}); + +test('a msg to a vanished client is dropped and the server keeps routing', async () => { + const { app, server, close } = await relay(); + try { + const { host, socket: hostWs } = await connectHost(app, server); + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: host.hostId }); + const connFrame = await hostWs.take(); + hostWs.send({ t: 'decision', clientId: connFrame.clientId, allowed: true }); + await clientWs.take(); + + clientWs.close(); + await clientWs.closed; + await hostWs.take(); // client-gone + + // The counterpart is gone; this must not throw or crash the process. + hostWs.send({ t: 'msg', clientId: connFrame.clientId, data: { orphan: true } }); + + // Prove the relay is still alive: a fresh client still round-trips. + const client2 = await connectClient(app, server); + client2.socket.send({ t: 'connect', hostId: host.hostId }); + assert.equal((await hostWs.take()).t, 'connect'); + } finally { + await close(); + } +}); + +test('a new host socket replaces the old one for the same hostId', async () => { + const { app, server, close } = await relay(); + try { + const first = await connectHost(app, server, { label: 'Laptop' }); + // Re-open /ws/host with the SAME token → same hostId, displaces the first. + const second = wsConnect( + `${server.wsUrl}${WS_ROUTES.host}?${WS_TOKEN_PARAM}=${first.host.hostToken}`, + ); + await second.ready; + + // The displaced socket is closed by the hub. + await first.socket.closed; + + // The new socket serves the same hostId: a client connect reaches it. + const { socket: clientWs } = await connectClient(app, server); + clientWs.send({ t: 'connect', hostId: first.host.hostId }); + assert.equal((await second.take()).t, 'connect'); + second.close(); + } finally { + await close(); + } +}); diff --git a/server/test/setup.test.mjs b/server/test/setup.test.mjs new file mode 100644 index 00000000..0a08f452 --- /dev/null +++ b/server/test/setup.test.mjs @@ -0,0 +1,212 @@ +/** + * Slice-1 setup/registration coverage (docs/specs/server.md, "Accounts & + * passkeys"): the password gate, the clientDataJSON sanity checks, single-use + * challenges, and the account.json that lands on disk. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { API_ROUTES } from 'server-lib-common'; + +import { + ORIGIN, + PASSWORD, + RP_ID, + freshApp, + enrollHost, + newAuthenticator, + padBase64Url, + post, + readAccount, + register, + registrationClientData, +} from './helpers.mjs'; + +test('register happy path writes account.json', async () => { + const { app, stateDir } = await freshApp(); + const authenticator = await newAuthenticator(); + + const res = await register(app, authenticator, { label: 'iPhone Safari' }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + accountId: 'owner', + credentialId: authenticator.credentialId, + }); + + const account = await readAccount(stateDir); + assert.equal(account.accountId, 'owner'); + assert.equal(account.passkeys.length, 1); + const [passkey] = account.passkeys; + assert.equal(passkey.credentialId, authenticator.credentialId); + assert.equal(passkey.publicKey, authenticator.publicKey); + assert.equal(passkey.label, 'iPhone Safari'); + assert.equal(typeof passkey.createdAt, 'number'); +}); + +test('a second passkey can be added by re-presenting the password', async () => { + const { app, stateDir } = await freshApp(); + assert.equal((await register(app, await newAuthenticator())).status, 200); + assert.equal((await register(app, await newAuthenticator())).status, 200); + const account = await readAccount(stateDir); + assert.equal(account.passkeys.length, 2); +}); + +test('setup/begin rejects a wrong password', async () => { + const { app } = await freshApp(); + const res = await post(app, API_ROUTES.setupBegin, { password: 'wrong' }); + assert.equal(res.status, 401); +}); + +test('setup/finish rejects a wrong password', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + // Get a valid challenge with the correct password, then finish with a wrong one. + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + const res = await post(app, API_ROUTES.setupFinish, { + password: 'wrong', + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge }), + label: 'x', + }); + assert.equal(res.status, 401); +}); + +test('setup/finish rejects a replayed challenge', async () => { + const { app } = await freshApp(); + const first = await newAuthenticator(); + + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + const clientDataJSON = registrationClientData({ challenge }); + + const ok = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: first.credentialId, + publicKey: first.publicKey, + clientDataJSON, + label: 'first', + }); + assert.equal(ok.status, 200); + + // Reuse the (now consumed) challenge with a different credential. + const second = await newAuthenticator(); + const replay = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: second.credentialId, + publicKey: second.publicKey, + clientDataJSON, + label: 'second', + }); + assert.equal(replay.status, 400); + assert.match((await replay.json()).error, /challenge/); +}); + +test('setup/finish accepts a padded base64url clientData challenge', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + + const res = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge: padBase64Url(challenge) }), + label: 'x', + }); + assert.equal(res.status, 200); +}); + +test('setup/finish rejects a mismatched origin in clientDataJSON', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + + const res = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge, origin: 'http://evil.example' }), + label: 'x', + }); + assert.equal(res.status, 400); + assert.match((await res.json()).error, /origin/); +}); + +test('setup/finish rejects the wrong clientData type', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + + const res = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge, type: 'webauthn.get' }), + label: 'x', + }); + assert.equal(res.status, 400); +}); + +test('setup/finish rejects a duplicate credentialId', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const res = await register(app, authenticator); + assert.equal(res.status, 409); +}); + +test('setup/finish rejects an unimportable public key', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge } = await begin.json(); + + const res = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: 'bm90LWEta2V5', // "not-a-key", valid base64url but not SPKI + clientDataJSON: registrationClientData({ challenge }), + label: 'x', + }); + assert.equal(res.status, 400); + assert.match((await res.json()).error, /public key/); +}); + +test('origin/rpId derive from config', async () => { + const { app } = await freshApp(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const body = await begin.json(); + assert.equal(body.rpId, RP_ID); + assert.equal(body.accountId, 'owner'); + assert.equal(typeof body.challenge, 'string'); + assert.equal(new URL(ORIGIN).hostname, RP_ID); +}); + +test('configured origin is normalized for setup and Host policy', async () => { + const { app } = await freshApp({ origin: 'https://Example.COM/' }); + const authenticator = await newAuthenticator(); + const begin = await post(app, API_ROUTES.setupBegin, { password: PASSWORD }); + const { challenge, rpId } = await begin.json(); + assert.equal(rpId, 'example.com'); + + const finish = await post(app, API_ROUTES.setupFinish, { + password: PASSWORD, + credentialId: authenticator.credentialId, + publicKey: authenticator.publicKey, + clientDataJSON: registrationClientData({ challenge, origin: 'https://example.com' }), + label: 'x', + }); + assert.equal(finish.status, 200); + + const { res, body } = await enrollHost(app); + assert.equal(res.status, 200); + assert.equal(body.origin, 'https://example.com'); + assert.equal(body.rpId, 'example.com'); +}); diff --git a/server/test/signin.test.mjs b/server/test/signin.test.mjs new file mode 100644 index 00000000..2348bb75 --- /dev/null +++ b/server/test/signin.test.mjs @@ -0,0 +1,160 @@ +/** + * Slice-1 sign-in coverage (docs/specs/server.md): passkey assertions minted by + * the harness `SimAuthenticator`, single-use sign-in challenges, session minting + * and expiry, and the `requireSession` gate slice 2 will hang `/api/hosts` off. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Hono } from 'hono'; +import { API_ROUTES } from 'server-lib-common'; +import { SimAuthenticator } from '../../server-lib-common/test/harness/actors.mjs'; + +import { + ORIGIN, + RP_ID, + freshApp, + makeClock, + newAuthenticator, + padBase64Url, + post, + register, + signin, +} from './helpers.mjs'; + +test('sign-in happy path mints a session the store accepts', async () => { + const { app, sessions } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const { res } = await signin(app, authenticator); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.accountId, 'owner'); + assert.equal(typeof body.sessionToken, 'string'); + assert.equal(typeof body.expiresAt, 'number'); + + const session = sessions.validate(body.sessionToken); + assert.ok(session); + assert.equal(session.accountId, 'owner'); + assert.equal(sessions.validate('not-a-real-token'), null); +}); + +test('sign-in rejects an unknown credential', async () => { + const { app } = await freshApp(); + assert.equal((await register(app, await newAuthenticator())).status, 200); + + // A different, never-registered authenticator. + const stranger = await newAuthenticator(); + const { res } = await signin(app, stranger); + assert.equal(res.status, 404); +}); + +test('sign-in rejects a replayed challenge/assertion', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const { res, assertion } = await signin(app, authenticator); + assert.equal(res.status, 200); + + // Same assertion again — its challenge was consumed on the first finish. + const replay = await post(app, API_ROUTES.signinFinish, { assertion }); + assert.equal(replay.status, 400); + assert.match((await replay.json()).error, /challenge/); +}); + +test('sign-in accepts a padded base64url clientData challenge', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const begin = await post(app, API_ROUTES.signinBegin, {}); + const { challenge } = await begin.json(); + const assertion = await authenticator.assert({ + challenge, + origin: ORIGIN, + rpId: RP_ID, + tamper: { challenge: padBase64Url(challenge) }, + }); + const res = await post(app, API_ROUTES.signinFinish, { assertion }); + assert.equal(res.status, 200); +}); + +test('sign-in rejects a tampered signature', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + // Sign the assertion with a foreign key: valid shape, invalid signature. + const signWith = await SimAuthenticator.foreignSigningKey(); + const { res } = await signin(app, authenticator, { tamper: { signWith } }); + assert.equal(res.status, 401); + assert.match((await res.json()).error, /signature/); +}); + +test('sign-in rejects an assertion for a foreign origin', async () => { + const { app } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const { res } = await signin(app, authenticator, { tamper: { origin: 'http://evil.example' } }); + assert.equal(res.status, 401); + assert.match((await res.json()).error, /origin/); +}); + +test('sign-in accepts the browser-normalized origin for a normalized config', async () => { + const { app } = await freshApp({ origin: 'https://Example.COM/' }); + const authenticator = await SimAuthenticator.create({ rpId: 'example.com' }); + assert.equal( + (await register(app, authenticator, { origin: 'https://example.com' })).status, + 200, + ); + + const { res } = await signin(app, authenticator, { + origin: 'https://example.com', + rpId: 'example.com', + }); + assert.equal(res.status, 200); +}); + +test('an expired session token no longer validates', async () => { + const clock = makeClock(); + const { app, sessions } = await freshApp({ now: clock.now }); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + + const { res } = await signin(app, authenticator); + const { sessionToken } = await res.json(); + assert.ok(sessions.validate(sessionToken)); + + clock.advance(12 * 60 * 60 * 1000 + 1); // past the 12h TTL + assert.equal(sessions.validate(sessionToken), null); +}); + +test('requireSession gates a route on the Bearer token', async () => { + const { app, sessions, requireSession } = await freshApp(); + const authenticator = await newAuthenticator(); + assert.equal((await register(app, authenticator)).status, 200); + const { res } = await signin(app, authenticator); + const { sessionToken } = await res.json(); + + // Mount the exported middleware on a throwaway route to exercise it directly. + const probe = new Hono(); + probe.get('/probe', requireSession, (c) => c.json({ accountId: c.get('session').accountId })); + + const withToken = await probe.request('/probe', { + headers: { Authorization: `Bearer ${sessionToken}` }, + }); + assert.equal(withToken.status, 200); + assert.deepEqual(await withToken.json(), { accountId: 'owner' }); + + assert.equal((await probe.request('/probe')).status, 401); + assert.equal( + (await probe.request('/probe', { headers: { Authorization: 'Bearer nope' } })).status, + 401, + ); + // Sanity: the store still recognizes the live token. + assert.ok(sessions.validate(sessionToken)); +}); diff --git a/server/test/static.test.mjs b/server/test/static.test.mjs new file mode 100644 index 00000000..07dd49fe --- /dev/null +++ b/server/test/static.test.mjs @@ -0,0 +1,65 @@ +/** + * Slice 5: the server serves the built Pocket app statically at `/*`, while the + * API and `/ws` routes keep precedence. The build itself is not needed here — + * a temp dir with an `index.html` stands in for `lib/dist-pocket`. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createApp } from '../dist/app.js'; + +const PASSWORD = 'correct horse battery staple'; +const ORIGIN = 'http://localhost:3000'; + +async function makePocketDir() { + const dir = await mkdtemp(join(tmpdir(), 'dormouse-pocket-')); + await writeFile(join(dir, 'index.html'), 'Pocket
'); + await writeFile(join(dir, 'app.js'), 'console.log("pocket");'); + return dir; +} + +function app(config = {}) { + return createApp({ setupPassword: PASSWORD, origin: ORIGIN, stateDir: config.stateDir ?? '.', ...config }); +} + +test('serves index.html at / when the Pocket build is present', async () => { + const { app: hono } = app({ pocketDir: await makePocketDir() }); + const res = await hono.request('/'); + assert.equal(res.status, 200); + const body = await res.text(); + assert.match(body, /pocket-root/); +}); + +test('serves built asset files', async () => { + const { app: hono } = app({ pocketDir: await makePocketDir() }); + const res = await hono.request('/app.js'); + assert.equal(res.status, 200); + assert.match(await res.text(), /console\.log/); +}); + +test('SPA fallback returns index.html for an unknown non-file path', async () => { + const { app: hono } = app({ pocketDir: await makePocketDir() }); + const res = await hono.request('/some/deep/link'); + assert.equal(res.status, 200); + assert.match(await res.text(), /pocket-root/); +}); + +test('API routes still win over static serving', async () => { + const { app: hono } = app({ pocketDir: await makePocketDir() }); + // No bearer token → the session-gated API route answers, not the static app. + const res = await hono.request('/api/hosts'); + assert.equal(res.status, 401); + const body = await res.json(); + assert.equal(body.error, 'unauthorized'); +}); + +test('falls back to the build-instructions stub when no Pocket build exists', async () => { + const { app: hono } = app({ pocketDir: join(tmpdir(), 'dormouse-nonexistent-pocket-dir') }); + const res = await hono.request('/'); + assert.equal(res.status, 200); + assert.match(await res.text(), /build:pocket/); +}); diff --git a/standalone/package.json b/standalone/package.json index 362fd9de..db67d1a6 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -7,12 +7,13 @@ "scripts": { "dev": "vite", "dev:agent-browser": "pnpm run stage && node scripts/dev-agent-browser.mjs", + "prebuild": "pnpm --filter server-lib-common build", "build": "pnpm run stage && tsc -b && vite build", "stage": "pnpm run stage:dor-cli && pnpm run stage:sidecar-proxy", "stage:dor-cli": "pnpm --filter dor build && node scripts/stage-dor-cli.mjs", "stage:sidecar-proxy": "node scripts/build-sidecar-proxy.mjs", - "tauri": "pnpm run stage && tauri", - "test": "vitest run" + "tauri": "pnpm run stage && node scripts/tauri.mjs", + "test": "vitest run && node --test scripts/*.test.mjs" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", diff --git a/standalone/scripts/csp.mjs b/standalone/scripts/csp.mjs new file mode 100644 index 00000000..8815ffff --- /dev/null +++ b/standalone/scripts/csp.mjs @@ -0,0 +1,31 @@ +// Build-time CSP `connect-src` policy for the standalone binary. +// +// The shipped binary everyone downloads is scoped to the SaaS origin only: +// remote-control Hosts talk to `*.dormouse.sh` over https/wss, and nothing +// else. That covers the ~99% of users (no remote at all, or SaaS) with the +// tightest connect-src, so a compromised webview can't exfiltrate to an +// arbitrary host. Self-hosters (who reach a server on their own domain or a +// tailnet) widen it for their own custom build via DORMOUSE_REMOTE_CONNECT_SRC +// — see docs/specs/server.md. The default lives in src-tauri/tauri.conf.json; +// this module is the single place that knows how to retarget it. + +/** The remote-server `connect-src` sources baked into the shipped binary. */ +export const DEFAULT_REMOTE_CONNECT_SRC = 'https://*.dormouse.sh wss://*.dormouse.sh'; + +/** + * Return `baseCsp` with its default remote-server sources replaced by + * `remoteSrc` (a space-separated CSP source list, e.g. + * `https://dormouse.example.com wss://dormouse.example.com`). Throws if the + * default sources aren't present, so a drifted base CSP fails the build loudly + * instead of silently shipping an unintended policy. + */ +export function withRemoteConnectSrc(baseCsp, remoteSrc) { + if (!baseCsp.includes(DEFAULT_REMOTE_CONNECT_SRC)) { + throw new Error( + `CSP override: expected default remote sources ${JSON.stringify(DEFAULT_REMOTE_CONNECT_SRC)} ` + + 'in the base CSP, but they were not found. tauri.conf.json changed — ' + + 'update DEFAULT_REMOTE_CONNECT_SRC in standalone/scripts/csp.mjs to match.', + ); + } + return baseCsp.replaceAll(DEFAULT_REMOTE_CONNECT_SRC, remoteSrc.trim()); +} diff --git a/standalone/scripts/csp.test.mjs b/standalone/scripts/csp.test.mjs new file mode 100644 index 00000000..f242f027 --- /dev/null +++ b/standalone/scripts/csp.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { DEFAULT_REMOTE_CONNECT_SRC, withRemoteConnectSrc } from './csp.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const conf = JSON.parse(readFileSync(join(here, '..', 'src-tauri', 'tauri.conf.json'), 'utf8')); +const csp = conf.app.security.csp; + +test('the shipped default CSP is scoped to the SaaS origin', () => { + assert.ok(csp.includes(DEFAULT_REMOTE_CONNECT_SRC), 'default remote sources present'); + // Secure by default: no scheme-wide `https:`/`wss:` in connect-src that + // would let the webview reach an arbitrary internet host. + assert.ok(!csp.includes(' https: '), 'no bare https: source'); + assert.ok(!csp.includes(' wss:;') && !csp.includes(' wss: '), 'no bare wss: source'); + // Localhost stays allowed (dev + local self-host server). + assert.ok(csp.includes('http://localhost:*') && csp.includes('ws://localhost:*')); +}); + +test('withRemoteConnectSrc retargets the remote sources', () => { + const out = withRemoteConnectSrc(csp, 'https://dormouse.example.com wss://dormouse.example.com'); + assert.ok(out.includes('https://dormouse.example.com wss://dormouse.example.com')); + assert.ok(!out.includes('dormouse.sh'), 'default SaaS sources replaced'); + // Everything else (localhost, ipc, directives) is untouched. + assert.ok(out.includes('http://localhost:*') && out.startsWith("default-src 'self'")); +}); + +test('withRemoteConnectSrc trims whitespace from the env value', () => { + const out = withRemoteConnectSrc(csp, ' https://a wss://a\n'); + assert.ok(out.includes('https://a wss://a')); + assert.ok(!out.includes('https://a wss://a\n')); +}); + +test('withRemoteConnectSrc throws when the base CSP has drifted', () => { + assert.throws( + () => withRemoteConnectSrc("connect-src 'self' https:;", 'https://x'), + /tauri\.conf\.json changed/, + ); +}); diff --git a/standalone/scripts/tauri.mjs b/standalone/scripts/tauri.mjs new file mode 100644 index 00000000..6eeb3e95 --- /dev/null +++ b/standalone/scripts/tauri.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Wraps the Tauri CLI so a custom (self-host) build can retarget the CSP's +// remote-server `connect-src` without editing the checked-in default. +// +// Unset (the shipped binary): the tight default in tauri.conf.json applies +// (SaaS origin `*.dormouse.sh` only). +// Set DORMOUSE_REMOTE_CONNECT_SRC="https://my.host wss://my.host": the default +// remote sources are replaced with that value via a `--config` override, so +// the checked-in config stays clean and the shipped default stays secure. +// +// cross-spawn (matches the other scripts here): resolves the local `tauri` +// bin and behaves on Windows where a bare spawn('pnpm', …) can't. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import spawn from 'cross-spawn'; +import { withRemoteConnectSrc } from './csp.mjs'; + +const args = process.argv.slice(2); +const remoteSrc = process.env.DORMOUSE_REMOTE_CONNECT_SRC?.trim(); + +if (remoteSrc) { + const here = dirname(fileURLToPath(import.meta.url)); + const conf = JSON.parse(readFileSync(join(here, '..', 'src-tauri', 'tauri.conf.json'), 'utf8')); + const csp = withRemoteConnectSrc(conf.app.security.csp, remoteSrc); + args.push('--config', JSON.stringify({ app: { security: { csp } } })); + console.error(`[tauri] connect-src remote sources overridden via DORMOUSE_REMOTE_CONNECT_SRC=${remoteSrc}`); +} + +const child = spawn('pnpm', ['exec', 'tauri', ...args], { stdio: 'inherit' }); +child.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exit(code ?? 1); +}); diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index 4ad438b5..a26541bb 100644 --- a/standalone/src-tauri/tauri.conf.json +++ b/standalone/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost ws://127.0.0.1:* ws://localhost:*; frame-src http://127.0.0.1:* http://localhost:*" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* https://*.dormouse.sh wss://*.dormouse.sh; frame-src http://127.0.0.1:* http://localhost:*" } }, "bundle": { diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index a1fd5573..b7fc89a5 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -104,6 +104,7 @@ async function bootstrap() { restoredLayout={result.layout} initialDoors={result.doors} baseboardNotice={} + enableRemoteHost /> , ); diff --git a/standalone/tsconfig.json b/standalone/tsconfig.json index 0d3bef15..29485e94 100644 --- a/standalone/tsconfig.json +++ b/standalone/tsconfig.json @@ -17,7 +17,8 @@ "noFallthroughCasesInSwitch": true, "paths": { "dormouse-lib/*": ["../lib/src/*"], - "dor/*": ["../dor/src/*"] + "dor/*": ["../dor/src/*"], + "server-lib-common": ["../server-lib-common/src/index.ts"] } }, "include": ["src"] diff --git a/standalone/vite.config.ts b/standalone/vite.config.ts index fd790d6e..e6b77655 100644 --- a/standalone/vite.config.ts +++ b/standalone/vite.config.ts @@ -5,6 +5,7 @@ import path from "path"; const libDir = path.resolve(__dirname, "../lib"); const dorDir = path.resolve(__dirname, "../dor"); +const serverLibCommonDir = path.resolve(__dirname, "../server-lib-common"); // https://v2.tauri.app/start/frontend/vite/ const host = process.env.TAURI_DEV_HOST; @@ -20,6 +21,11 @@ export default defineConfig({ // path; Vite governs lib files by lib's (paths-less) tsconfig, and `dor` // has no package exports, so resolve it explicitly the same way as lib. dor: path.resolve(dorDir, "src"), + // lib source imports the remote modules, which import `server-lib-common`; + // its package exports point at dist, which a clean standalone checkout + // build has not necessarily produced yet. Match the Pocket and website + // builds by resolving it directly to source. + "server-lib-common": path.resolve(serverLibCommonDir, "src"), }, }, // Tauri expects a fixed port; fail if that port is not available @@ -29,8 +35,8 @@ export default defineConfig({ strictPort: true, hmr: host ? { protocol: "ws", host, port: 1421 } : undefined, fs: { - // Allow serving files from the lib and dor workspace packages - allow: [libDir, dorDir, "."], + // Allow serving files from the source-aliased workspace packages. + allow: [libDir, dorDir, serverLibCommonDir, "."], }, }, // Tauri CLI reads this env var to know where the dev server is diff --git a/website/vite.config.ts b/website/vite.config.ts index 94d92fc0..3a9ddcd3 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -11,6 +11,11 @@ export default defineConfig(({ mode }) => ({ resolve: { alias: { "dormouse-lib": path.resolve(__dirname, "../lib/src"), + // The desktop playground bundles `Wall`, which pulls in the remote host + // modules (`RemotePairingModalHost` → remote/host/*); those import + // `server-lib-common`, whose package `exports` resolve to a `dist` this + // build never compiles. Alias it to source, exactly like `dormouse-lib`. + "server-lib-common": path.resolve(__dirname, "../server-lib-common/src"), "ascii-splash-internal": path.resolve( __dirname, "node_modules/ascii-splash/dist",