# P6 — Browser / Frontend (`relay-web/`) — Implementation Plan

> **Charter**: [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) §5 "P6 — FRONTEND". Owns the browser
> bundle served from the tenant **subdomain**: login (Passkey), connecting through the relay
> (same-origin, scheme-following `wss:`, M6), **browser-side E2E by consuming the frozen §4.4 primitives from P4 `relay-e2e`
> (`buildClientHandshake` → `E2ESession`, `sealFrame`/`openFrame` over an opaque `AeadKey` / direction-split
> `DirectionalKeys`, `@noble/ciphers`) — NOT a single `sessionKey` via raw SubtleCrypto AES-GCM**,
> **CLIENT-SIDE preview rendering** (server previews die under E2E — the authorized, key-holding
> browser decrypts + renders a read-only xterm), and pairing/onboarding + dashboard (host `●online`
> status, add-machine flow). **The core xterm byte path in `public/` stays byte-for-byte untouched.**
> Source of intent: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 (LOCKED, CLOSED),
> §4c, §4e, §5. Conventions per [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md):
> stable task IDs → dependency waves, disjoint `Owns:`, function-signature contracts, **TDD (tests
> FIRST)**, explicit test cases (incl. security/negative), per-task security notes. `PROGRESS_LOG.md`
> is **orchestrator-only** (subagents return a ready-to-paste entry, G1).

---

## 0. Scope fence — in / out

**In P6 (this plan):** everything the *browser* runs — the `relay-web/` bundle served from
`alice.term.<domain>`: the login page(s), the terminal view that mounts xterm and pumps it through
an E2E-wrapped WebSocket, the dashboard (host status + add-machine/onboarding), and the client-side
preview grid. P6 is a **consumer** of the frozen contracts and of P3/P4/P5 endpoints — it defines
**no** wire format, **no** crypto primitive, **no** account record.

**Out (other plans — do NOT touch):** the mux frame format & data plane (P1 §4.1), the host-agent
(P2), account/host registries + pairing issuance + routing (P3 §4.2/§4.5), the E2E crypto core
(P4 `relay-e2e/` §4.4 — P6 *imports* `sealFrame`/`openFrame` and the handshake driver, never
re-implements them), and server-side auth/authz/capability-token *issuance & verification* (P5 §4.3).

**Untouched base app:** `public/**`, `src/**`, `protocol.ts`, `vitest.config.ts` (base) — **zero
edits**. `relay-web/` is a **new top-level package** with its own `package.json`, esbuild config, and
test suite. It may `import` xterm.js + `@xterm/addon-fit` as fresh dependencies (same libraries the
base uses) — it does **not** import or mutate `public/`. The base app's `ALLOWED_ORIGINS` config
touch-point (INDEX §0) is owned by the install flow, not by P6.

---

## 1. Security invariants enforced (from INDEX §3)

P6 **owns/enforces** and ships a test per:

| INV | How P6 enforces it (client-side) | Owning tasks |
|---|---|---|
| **INV3** | The browser **NEVER** sends `account_id`/`tenant_id` as a source of truth. Every API call derives the account server-side from the passkey/cookie session; requests carry only the passkey assertion or the signed cookie. Client-side authz decisions do not exist — the UI reflects server responses. | T2, T3, T7 |
| **INV15** | **From v0.9 on**, every terminal WS upgrade carries a **capability token** (§4.3) obtained from P5; the client presents the raw token via the `Sec-WebSocket-Protocol` upgrade header (never a query string — T4/T8 §), never self-authorizes. **In v0.8 there is no token (INDEX §1: "no capability tokens yet"); T4's passthrough rides the T3 signed cookie only** — the INV15 client-half is a v0.9/v0.10 deliverable that lands with P5 issuance. Origin stays same-origin (M6) so the base-app CSWSH check is *retained AND augmented*. Step-up auth (T7) runs **before opening a session**, not just at login. | T4 (v0.9+), T7, T8 |

P6 **consumes (relies on, and adds defensive client-side checks for)**:

| INV | Client-side reliance / defensive check | Tasks |
|---|---|---|
| **INV2** | The browser is the *only* place plaintext exists end-to-end; it hands ciphertext to the socket. P6 asserts it never logs plaintext and never persists it. | T8, T9 |
| **INV13** | `openFrame` (P4) rejects replayed/reordered/tampered frames; P6 wires a **strictly monotonic `expectedSeq`** per direction and **tears the socket down** on any `openFrame` throw (AEAD-tag / seq failure) rather than swallowing it. P6 also ships a **reflection/direction-confusion** test (a captured frame from one direction re-injected as the other MUST be rejected). **Now frozen (FIX 2): §4.4 is direction-bound via `DirectionalKeys{c2h,h2c}` — the client seals with `c2h`/opens with `h2c` and `aad = directionLabel‖seq`, so a reflected frame cannot verify (§8 Q#4 RESOLVED).** | T8 |
| **INV4/§4.4 TOFU** | The **root of trust for the host fingerprint is the authenticated control-plane record** — `HostRecord.enrollFpr` (§4.2), fetched over the P5-authenticated HTTPS API (`api.getHost(hostId)`, T2), a channel **out-of-band of the relay**. The browser verifies the WS-negotiated `host_hello.sig`-derived fingerprint against **that** API-sourced value and **aborts on any disagreement, including the very first connection** — a malicious relay that forges `host_hello` on a device's first connect is caught because its fingerprint ≠ the API-sourced `enrollFpr`. `HostPinStore` (`localStorage`) is a **cache/audit trail of the API-sourced fingerprint**, NOT an independent trust root; it never blind-`trustOnFirstUse`s a relay-forwarded value. | T8 |

---

## 2. Files & ownership (all NEW under `relay-web/`)

| File | Task | Phase | Role |
|------|------|-------|------|
| `relay-web/package.json`, `relay-web/build.mjs`, `relay-web/vitest.config.ts`, `relay-web/tsconfig.json` | T1 | v0.8 | Package scaffold, esbuild bundling, jsdom test config, coverage.include |
| `relay-web/src/config.ts` | T1 | v0.8 | Runtime config from `location` (subdomain, same-origin API base, scheme-following `ws:`/`wss:`) |
| `relay-web/src/api-client.ts` | T2 | v0.8 | Zod-validated control-plane HTTP client (login, token issuance, host list, pairing) |
| `relay-web/src/api-schemas.ts` | T2 | v0.8 | Zod request/response schemas (re-export §4.2/§4.3/§4.5 shapes from `relay-contracts`, never redefine) |
| `relay-web/src/login-password.ts` | T3 | v0.8 | v0.8 shared-`clientToken` password gate → signed cookie |
| `relay-web/src/terminal-view.ts` | T4 | v0.8 | Mount xterm + FitAddon; drive it through the socket adapter; base attach/input/resize/output/exit semantics |
| `relay-web/src/ws-transport.ts` | T4 | v0.8 | `TerminalTransport` interface + `PassthroughTransport` (v0.8, no E2E); scheme-following same-origin URL |
| `relay-web/src/dashboard.ts` | T5 | v0.9 | Host list with `●online/offline/draining/revoked` status, live refresh |
| `relay-web/src/add-machine.ts` | T6 | v0.9 | Add-machine onboarding: request pairing code, show `npx …agent pair <code>`, poll until online |
| `relay-web/src/login-passkey.ts` | T7 | v0.10 | Passkey/WebAuthn register + authenticate ceremonies; step-up-before-session |
| `relay-web/src/webauthn.ts` | T7 | v0.10 | Thin `navigator.credentials` wrapper (base64url ↔ ArrayBuffer, error mapping) |
| `relay-web/src/e2e-socket.ts` | T8 | v0.10 | `E2ETransport`: drive the §4.4 `buildClientHandshake` → `ClientHandshake` over WS, pin `enrollFpr`, wrap send/recv through the `E2ESession` (`seal`/`open` over `sealFrame`/`openFrame` + direction-split `DirectionalKeys`) — imported from `relay-contracts`/`relay-e2e`, never redefined |
| `relay-web/src/host-pin-store.ts` | T8 | v0.10 | Cache/audit of the API-sourced `enroll_fpr` per `host_id` (NOT a trust root) + drift-vs-cache detection |
| `relay-web/src/preview-client.ts` | T9 | v0.10 | Decrypt a ciphertext replay + render a **read-only** xterm (headless dims) |
| `relay-web/src/preview-grid.ts` | T9 | v0.10 | Grid of `preview-client` cards (the manage-page moved client-side) |
| `relay-web/public/index.html`, `dashboard.html`, `pair.html` | T1/T5/T6 | — | Static entry pages (CSP headers documented, no inline script) |
| `relay-web/test/**` | each | — | Co-located tests, one file per module |

Disjoint by construction: transport (T4/T8), auth (T3/T7), dashboard/onboarding (T5/T6), preview
(T9) never edit each other's files. `api-schemas.ts` (T2) is the frozen client-contract surface — a
new field is added *there* (a coordination point mirroring `relay-contracts`), never redeclared
locally, per the `src/types.ts` discipline.

---

## 3. Dependency waves & cross-plan ordering

```
W0  T1 scaffold ─┬─▶ T2 api-client
                 │
W1  T3 password login ─┐   T4 terminal-view + passthrough transport
                       │        (both need T1 config; v0.8 T4 needs NO token — cookie only.
                       │         T2's issueCapabilityToken is wired into T4 in v0.9, not v0.8)
W2  T5 dashboard ──▶ T6 add-machine          (need T2 + a login session)
W3  T7 passkey login       T8 e2e-socket     (T8 needs P4 relay-e2e + P5 token issuance)
W4  T9 client-side preview   (needs §4.4 replay surface deriveContentKey/openReplayCiphertext (P4 T10)
                              + hostContentSecret via P5 + T5 host list; NOT the ephemeral live key)
```

**Cross-plan deps (reference their task IDs at build time):**
- **T2 → P3**: host-list + pairing-status endpoints (P3 registries §4.2 / pairing §4.5 ISSUE).
- **T2/T4/T7 → P5**: WebAuthn challenge/verify + **capability-token issuance** (§4.3) + step-up.
- **T4/T8 → P1**: the terminal WS upgrade path routes by authenticated session on the subdomain
  (INV1). In **v0.8** the upgrade is authenticated by the T3 cookie (no token yet); from **v0.9** it
  **requires the capability token** (INV15), which P6 attaches via `Sec-WebSocket-Protocol` and P1/P5
  verify.
- **T8 → P4**: imports the frozen §4.4 driver verbatim — `buildClientHandshake` (P4 T11) →
  `ClientHandshake.start`/`.onHostHello` (P4 T8) → `createE2ESession('client', result)` (P4 T9), plus
  `sealFrame`/`openFrame`/`E2ESession`/`DirectionalKeys`/`AeadKey`; the agent side runs the
  `host_hello`/`sealFrame` counterpart (P2). P6 owns **only** the browser glue + fingerprint pin.
- **T9 → P4 + P5/P2**: imports the frozen §4.4 replay surface `deriveContentKey`/`openReplayCiphertext`
  (P4 T10); consumes the §4.5 host-scoped `hostContentSecret` delivered via P5 (minted+wrapped by P3 at
  BIND, FIX 3); the agent (P2) seals replay-bound output with `sealReplayFrame` under the same `K_content`.
- **T6 → P2**: the displayed `npx web-terminal-agent pair <code>` command is redeemed by P2 (§4.5
  REDEEM); P6 only issues the code (via P3) and polls host status.

**Ordering rule (INDEX §2.5): P4 + P5 before P6's E2E/auth tasks.** v0.8 tasks (T1–T4) ship against
the frp scaffold + password gate with **no** E2E and **no** passkey — the transport is a
`PassthroughTransport` behind the same `TerminalTransport` interface T8 later swaps in, so E2E "drops
in without reshaping" (INDEX §1 v0.8 bullet).

---

## 4. Tasks

### T1 — Package scaffold + runtime config  *(v0.8, W0)*

**Owns:** `relay-web/package.json`, `relay-web/build.mjs`, `relay-web/tsconfig.json`,
`relay-web/vitest.config.ts`, `relay-web/src/config.ts`, `relay-web/public/index.html`,
`relay-web/test/config.test.ts`.

**Contract:**
```ts
export interface RelayWebConfig {
  readonly subdomain: string       // 'alice' parsed from location.hostname (left-most label)
  readonly wsUrl: (path: string) => string   // scheme-following: wss: on https:, else ws:; SAME-ORIGIN (M6)
  readonly apiBase: string         // same-origin '' ; all control-plane calls are relative
}
export function readConfig(loc: Pick<Location, 'protocol' | 'host' | 'hostname'>): RelayWebConfig
```

**TDD (RED first):**
1. **RED** `config.test.ts` — `readConfig` derives subdomain + scheme-following URL → fails (module absent).
2. **GREEN** implement `config.ts`; esbuild `build.mjs` bundles `index.html` entry.

**Test cases (incl. negative):**
- `https://alice.term.example.com` → `subdomain==='alice'`, `wsUrl('/term')==='wss://alice.term.example.com/term'`.
- `http://alice.term.localhost:3000` → `ws://…` (scheme follows page protocol, M6 — no mixed-content on TLS).
- **Negative:** bare host with no subdomain label → `readConfig` throws a typed error (fail-fast at boundary), never defaults to another tenant's subdomain.
- **Security:** `wsUrl` is always same-origin — a passed `path` cannot inject a foreign host (`wsUrl('//evil.com')` stays on `location.host`).

**Security notes:** same-origin + scheme-following is the M6 guarantee; no IP/host config knob exists
in the client, so the browser can never be pointed at another tenant's origin (supports INV1 upstream).

---

### T2 — Zod-validated control-plane API client  *(v0.8, W0)*

**Owns:** `relay-web/src/api-client.ts`, `relay-web/src/api-schemas.ts`, `relay-web/test/api-client.test.ts`.

**Contract:** `api-schemas.ts` **re-exports** `relay-contracts` types verbatim (`HostRecord`,
`HostStatus`, `CapabilityRight`) and adds only *response envelopes*; it **never** declares an
`account_id` *request* field (INV3).
```ts
// v0.8 surface (ships against the flat SQLite table + cookie session):
export interface ApiClientV08 {
  listHosts(): Promise<readonly HostRecord[]>                 // account derived server-side (INV3)
  getHost(hostId: string): Promise<HostRecord>               // authoritative HostRecord incl. enrollFpr (§4.2), for T8 TOFU root-of-trust
  requestPairingCode(): Promise<{ readonly code: string; readonly expiresAt: string }>       // §4.5 ISSUE
  hostStatus(hostId: string): Promise<HostStatus>
}
// v0.9 GROWS the surface once P5 capability-token issuance exists (INDEX §1 v0.9). NOT present in v0.8:
export interface ApiClient extends ApiClientV08 {
  issueCapabilityToken(hostId: string, rights: readonly CapabilityRight[]): Promise<string> // raw §4.3 token — v0.9+
}
export function createApiClient(cfg: RelayWebConfig, fetchImpl?: typeof fetch): ApiClient
```
`getHost(hostId)` returns the full authoritative `HostRecord` (§4.2) — its `enrollFpr` is the
**out-of-band-of-the-relay trust root** T8 verifies the E2E handshake against (see T8 / §1 INV4 row);
this method exists from v0.8 so the record is reachable before E2E lands. `issueCapabilityToken` is
**phased in at v0.9** alongside P5 issuance (INDEX §1 v0.8: "no capability tokens yet"; "P4/P5 write
their contracts but ship no runtime") — a v0.8 build MUST NOT call it and MUST NOT require a token.
Every response is parsed through a Zod schema before return (validate-at-boundary, `coding-style.md`);
a parse failure throws a typed `ApiError`, never returns a partial object.

**TDD:**
1. **RED** `api-client.test.ts` with a mocked `fetch` — asserts request bodies contain **no**
   `account_id`/`tenant_id`, responses are Zod-validated → fails.
2. **GREEN** implement client + schemas.

**Test cases (incl. security/negative):**
- `listHosts()` sends **no** account/tenant field in URL, body, or query (INV3 static assertion in test).
- Malformed host in response (missing `subdomain`) → `ApiError` thrown, not a torn object.
- `getHost('h1')` returns a Zod-validated `HostRecord` whose `enrollFpr` is a non-empty string; missing/empty `enrollFpr` → `ApiError` (T8 must never fall back to a relay-forwarded fingerprint when the trust root is unavailable).
- **v0.9+** `issueCapabilityToken('h1', ['attach'])` returns the opaque raw token; client does **not** decode-to-trust it (verification is server-side). **v0.8 phasing:** a structural test asserts the v0.8 build path never calls `issueCapabilityToken` (the method is absent from `ApiClientV08`).
- **Negative:** 401 response → typed `ApiError('unauthenticated')`, surfaces a re-login prompt (no silent swallow).
- **Security:** forging `hostId` the account doesn't own → server returns 403; client renders the error, never assumes success (INV1 enforced upstream, respected here).

**Security notes:** INV3 is enforced structurally — the request builders have no parameter for
account/tenant. All identity is the cookie/passkey session the browser already holds.

---

### T3 — v0.8 password gate login  *(v0.8, W1)*

**Owns:** `relay-web/src/login-password.ts`, `relay-web/public/index.html` (login markup),
`relay-web/test/login-password.test.ts`.

**Contract:**
```ts
export interface PasswordLogin { submit(clientToken: string): Promise<'ok' | 'rejected'> }
export function mountPasswordLogin(root: HTMLElement, cfg: RelayWebConfig): PasswordLogin
```
Posts `clientToken` to the relay edge (P5/relay), which sets the **signed cookie**; on `'ok'` the app
routes to the terminal view. This is the base-app-never-had auth (EXPLORE §5) and is **replaced** by
T7 in v0.10 (kept behind the same route so the swap is contained).

**TDD:** RED test (mounts into jsdom, mocked fetch) → GREEN.

**Test cases:** correct token → `'ok'` + cookie request issued; wrong token → `'rejected'`, no route
change, no token echoed to the DOM; XSS: an error string is set via `textContent` only (never
`innerHTML`); empty input → disabled submit (fail-fast).

**Security notes:** password is sent once over TLS to the relay, never stored in `localStorage`;
error UI is `textContent`-only; no `console.log` of the token (INV9 spirit — no secret logged).

---

### T4 — Terminal view + passthrough transport  *(v0.8, W1)*

**Owns:** `relay-web/src/terminal-view.ts`, `relay-web/src/ws-transport.ts`,
`relay-web/test/terminal-view.test.ts`, `relay-web/test/ws-transport.test.ts`.

**Contract — the abstraction seam that lets E2E "drop in" (INDEX §1):**
```ts
export interface TerminalTransport {
  open(): Promise<void>
  send(bytes: Uint8Array): void            // plaintext in the browser; encoded by the impl
  onMessage(cb: (bytes: Uint8Array) => void): void
  onClose(cb: (reason: string) => void): void
  close(): void
}
// v0.8: no crypto, NO capability token (INDEX §1) — auth is the T3 signed cookie sent
// automatically with the same-origin upgrade. `capabilityToken` is OPTIONAL and absent in v0.8;
// from v0.9 it is supplied and attached via the Sec-WebSocket-Protocol header (see below).
export interface PassthroughOpts { readonly capabilityToken?: string }  // v0.9+ populates this
export function createPassthroughTransport(cfg: RelayWebConfig, opts?: PassthroughOpts): TerminalTransport
// Mounts xterm.js + FitAddon; wires keypress→onData→transport.send and transport.onMessage→xterm.write.
export function mountTerminalView(root: HTMLElement, transport: TerminalTransport): { dispose(): void }
```
`mountTerminalView` reproduces the base byte semantics (Enter → `\r` 0x0D, `resize` as its own frame
triggering `TIOCSWINSZ`, `fit()` only after the container has real dimensions — Gotchas) but **against
the abstract `TerminalTransport`**, so `public/**` is never touched.

**Token-attachment mechanism (v0.9+, avoids bearer-credential leakage) — cite §4.3 verbatim, no local
format:** the capability token (§4.3) is **NEVER** placed in the WS upgrade **URL/query string** (query
strings leak into proxy/access logs, browser history, and `Referer`; query-string transport is
**forbidden** by §4.3). It is attached as a **`Sec-WebSocket-Protocol` subprotocol value** on the upgrade
using the **frozen §4.3 wire format** — the client opens **verbatim**:
```ts
// §4.3 frozen constants: APP_SUBPROTOCOL='term.relay.v1', TOKEN_SUBPROTOCOL_PREFIX='term.token.'
new WebSocket(url, ['term.relay.v1', 'term.token.' + base64url(capabilityToken)])
// == new WebSocket(url, [APP_SUBPROTOCOL, encodeTokenSubprotocol(capabilityToken)])
```
i.e. the app subprotocol **first**, then the token entry = `'term.token.'` + base64url(token) (P6
imports `encodeTokenSubprotocol` from `relay-contracts` rather than hand-rolling the prefix/encoding).
The relay edge (P5) reads the token from the `term.token.<b64u>` entry via `extractTokenFromSubprotocols`,
strips it before verify (§4.3), and — per the frozen **echo rule** — the accepted/echoed
`Sec-WebSocket-Protocol` **MUST be `term.relay.v1` (the app subprotocol), NEVER the token entry**.
**After the socket opens, the client asserts `ws.protocol === 'term.relay.v1'` and tears the socket down
otherwise** (a relay that echoes the token entry, or no/foreign subprotocol, is rejected). **In v0.8 no
token is attached at all** — the same-origin signed cookie (T3) authenticates the upgrade, and the
base-app Origin/CSWSH check (M6) is retained. Origin stays same-origin regardless.

**TDD:**
1. **RED** `ws-transport.test.ts` — passthrough opens same-origin `wss:` (v0.8: relies on the cookie,
   no token), forwards bytes round-trip (mocked `WebSocket`) → fails.
2. **RED** `terminal-view.test.ts` (jsdom + mocked xterm) — keypress → `transport.send`; incoming
   bytes → `xterm.write`; `resize` emits a resize frame → fails.
3. **GREEN** implement both.

**Test cases (incl. security):**
- **v0.8:** upgrade URL is same-origin scheme-following and carries **no** query-string token; the transport opens on the cookie alone (INDEX §1 "no capability tokens yet").
- **v0.9+ token attachment (§4.3 frozen format):** when `capabilityToken` is supplied the mocked `WebSocket` protocols arg is asserted to equal **`['term.relay.v1', 'term.token.' + base64url(capabilityToken)]`** verbatim (app subprotocol first, token entry = `encodeTokenSubprotocol(token)`); it appears **only** in that `Sec-WebSocket-Protocol` upgrade arg, **never** in the URL/query (regression guard against bearer-credential leakage into logs/history/`Referer`); the mocked upgrade URL is asserted token-free.
- **v0.9+ echo rule (§4.3):** after `open`, if the mocked `ws.protocol === 'term.relay.v1'` the transport proceeds; if it is the token entry, empty, or any foreign value → the transport **tears the socket down** (`onClose`), never proceeds — a structural assertion the client accepts **only** the app subprotocol back.
- Enter emits `\r` not `\n`; resize is a distinct frame (base gotcha).
- `fit()` is not called while the container is `display:none` (guards NaN dims).
- **Security/negative:** a server `exit`/close reason surfaces via `onClose` (no silent swallow); attaching to a `host_id` the account can't reach → server 403 on upgrade → `onClose('forbidden')` rendered (INV1/INV6 respected, tested as a client tripwire mirror of P5's server tripwire).

**Security notes:** In **v0.8** the transport authenticates with the T3 signed cookie only — there is
no capability token yet (INDEX §1), so INV15's client half is **not** an owned assertion here; it
lands in **v0.9** when P5 issuance exists and the token is attached via `Sec-WebSocket-Protocol` (never
the URL, so it stays out of access logs/history/`Referer`). INV2 is likewise not active in v0.8
(plaintext passthrough is the acknowledged v0.8 shortcut). The seam guarantees T8 replaces the
transport with zero change to `terminal-view.ts`.

---

### T5 — Dashboard: host status  *(v0.9, W2)*

**Owns:** `relay-web/src/dashboard.ts`, `relay-web/public/dashboard.html`,
`relay-web/test/dashboard.test.ts`.

**Contract:**
```ts
export interface Dashboard { refresh(): Promise<void>; dispose(): void }
export function mountDashboard(root: HTMLElement, api: ApiClient, opts?: { pollMs?: number }): Dashboard
```
Renders `api.listHosts()` as cards showing `subdomain`, `●` status dot colored by `HostStatus`
(`'online'|'offline'|'draining'|'revoked'` — §4.2 string-literal union), `last_seen`, and an "open"
action that routes to the terminal view for that `host_id`. Polls every `pollMs` (default 5000) for
live `●online` flips.

**TDD:** RED (jsdom, mocked `ApiClient`) → GREEN.

**Test cases (incl. negative):** three hosts render three cards with correct status classes;
`'revoked'` host shows a disabled/blocked state and **no** open action (INV12 respected in UI);
`listHosts` rejection → an error banner (`textContent`), poll continues; an unknown status string
(schema drift) → Zod parse error surfaces, never renders an undefined dot; no `account_id` appears in
any request the dashboard issues (INV3).

**Security notes:** the UI reflects server-authorized hosts only; it cannot enumerate or request a
foreign `host_id` (no such input path). A `revoked` host is non-interactive client-side, but the real
gate is server-side (INV12) — the UI is defense-in-depth, not the enforcement.

---

### T6 — Add-machine onboarding (pairing)  *(v0.9, W2)*

**Owns:** `relay-web/src/add-machine.ts`, `relay-web/public/pair.html`,
`relay-web/test/add-machine.test.ts`.

**Contract:**
```ts
export interface AddMachine { start(): Promise<void>; dispose(): void }
export function mountAddMachine(
  root: HTMLElement, api: ApiClient,
  opts?: { pollMs?: number; onPaired?: (hostId: string) => void },
): AddMachine
```
Flow (§4.5 from the browser's side): `start()` calls `api.requestPairingCode()` (P3 ISSUE), displays
the copy-paste command **`npx web-terminal-agent pair <code>`** and a countdown to `expiresAt`, then
polls `api.hostStatus` / `api.listHosts` until the new host flips `●online` → calls `onPaired`.
**KPI: first-shell-in-under-2-minutes** (EXPLORE §6) — the UI is the make-or-break onboarding.

**TDD:** RED (jsdom, mocked `ApiClient` returning a code then an online host) → GREEN.

**Test cases (incl. security/negative):** happy path — code shown, poll detects online → `onPaired`
fires once; **the raw pairing code is single-use and short-TTL** — after `expiresAt` the UI shows
"expired, generate a new code" and stops polling (mirrors §4.5 single-use / short-TTL; the client
never re-displays a redeemed code); code render is `textContent`-only (no injection); a `requestPairingCode`
failure surfaces an error, no infinite poll.

**Security notes:** the pairing code is a **single-use, short-TTL** capability (§4.5, INV5 — raw code
never stored server-side); the browser treats it as transient display state, never persists it. The
Ed25519 keypair is generated **on the host** by P2 (INV4) — the browser never sees a private key.

---

### T7 — Passkey / WebAuthn login + step-up  *(v0.10, W3)*

**Owns:** `relay-web/src/login-passkey.ts`, `relay-web/src/webauthn.ts`,
`relay-web/test/login-passkey.test.ts`, `relay-web/test/webauthn.test.ts`.

**Contract:**
```ts
export interface WebAuthnClient {
  register(challenge: PublicKeyCredentialCreationOptions): Promise<RegistrationResult>
  authenticate(challenge: PublicKeyCredentialRequestOptions): Promise<AuthenticationResult>
}
export function createWebAuthnClient(creds?: CredentialsContainer): WebAuthnClient  // navigator.credentials

export interface PasskeyLogin {
  login(): Promise<'ok' | 'rejected'>
  stepUp(hostId: string): Promise<'ok' | 'rejected'>   // BEFORE opening a session (EXPLORE §4e / §0-5)
}
export function mountPasskeyLogin(root: HTMLElement, api: ApiClient, wa: WebAuthnClient): PasskeyLogin
```
`login()`: `api` fetches a challenge from P5 → `wa.authenticate` → posts the assertion to P5 (which
sets the session). `stepUp(hostId)` runs a **fresh** WebAuthn assertion immediately before
`issueCapabilityToken` — step-up before opening a session, not just at login. The base64url ↔
ArrayBuffer plumbing and error mapping live in `webauthn.ts` (kept <200 lines, pure-ish).

**TDD:** RED (`webauthn.test.ts` with a mocked `CredentialsContainer`; `login-passkey.test.ts` with a
mocked `ApiClient` + `WebAuthnClient`) → GREEN.

**Test cases (incl. security/negative):**
- Successful authenticate → assertion posted → `'ok'`; user cancels the passkey prompt (`NotAllowedError`) → `'rejected'`, no session, no throw leak.
- **No SMS/OTP path exists** in the module surface (INDEX/EXPLORE §0-5: never SMS) — a structural test asserts no phone/SMS field.
- `stepUp` runs a **new** ceremony each time (test asserts `authenticate` is called again, not cached) — a stolen cookie alone cannot open a session (INV15 step-up).
- base64url round-trip (challenge encode/decode) is lossless; a malformed server challenge → typed error, not a crashed ceremony.
- **Security:** the assertion is posted to a same-origin P5 endpoint; the WebAuthn `rpId` is the **stable account-level domain resolved per §8 Open Question #5 (NOT the per-host subdomain)** so one passkey authenticates across all of an account's host subdomains — a structural test asserts `rpId` is read from that resolved value, never hard-wired to `location.hostname`. No credential material is logged (INV9).

**Security notes:** Passkey is primary (phishing-resistant); the account is established entirely
server-side from the assertion (INV3). Step-up-before-session is the EXPLORE §4e "HIGH" control moved
into the client flow. **BLOCKED until §8 Open Question #5 (`rpId` scope) is resolved with P3/P5** —
because `hosts.subdomain` is UNIQUE **per host** (§4.2) and one account owns many host subdomains, an
`rpId` set to a per-host hostname would make a passkey registered on host A's subdomain fail
`navigator.credentials.get()` on host B's subdomain. T7 MUST NOT be implemented against a per-host
`rpId`; it consumes the resolved account-level `rpId` (and the matching T3 cookie domain scope) instead.

---

### T8 — Browser-side E2E transport (consumes frozen §4.4 `E2ESession`/`DirectionalKeys`)  *(v0.10, W3)*

**Owns:** `relay-web/src/e2e-socket.ts`, `relay-web/src/host-pin-store.ts`,
`relay-web/test/e2e-socket.test.ts`, `relay-web/test/host-pin-store.test.ts`.

**Contract — implements the SAME `TerminalTransport` (T4) so `terminal-view.ts` is unchanged:**
```ts
// Imports the FROZEN §4.4 surface from relay-contracts / P4 relay-e2e — cited VERBATIM, NEVER
// re-implemented here, and NOT a single sessionKey via raw SubtleCrypto AES-GCM:
//   buildClientHandshake(ctx: AuthorizedDeviceContext, hostId, aeadOffer) -> ClientHandshake
//     ClientHandshake.start(): Promise<ClientHello>
//     ClientHandshake.onHostHello(msg, agentPubkey): Promise<HandshakeResult>   // { keys: DirectionalKeys, aead, transcript }
//   createE2ESession(role: 'client', result: HandshakeResult) -> E2ESession     // holds BOTH c2h/h2c subkeys + seq guards
//     E2ESession.seal(plaintext) / .open(dataPayload) / .rederive(next)         // wrap sealFrame/openFrame w/ direction subkey
//   AeadKey (opaque, NOT CryptoKey); DirectionalKeys { c2h, h2c }; sealFrame/openFrame are SYNCHRONOUS.
// P6 supplies the AuthorizedDeviceContext { deviceAuthProofProvider (P5), pinStore (P6), hostContentSecret (§4.5) }
// and drives the handshake THROUGH the relay as opaque DATA; it derives no key material itself.

// HostPinStore is a CACHE/AUDIT TRAIL of the API-sourced fingerprint — NOT an independent trust root.
// It never establishes trust from a relay-forwarded handshake; `record` only ever stores the value the
// caller already verified against the authenticated HostRecord.enrollFpr.
export interface HostPinStore {
  get(hostId: string): string | null                 // last cached API-sourced enroll_fpr (§4.2) or null
  record(hostId: string, apiSourcedFpr: string): void // cache the API-verified fpr (audit/drift detection)
}
export function createHostPinStore(storage?: Storage): HostPinStore

export function createE2ETransport(
  cfg: RelayWebConfig,
  capabilityToken: string,          // §4.3; attached via Sec-WebSocket-Protocol, never the URL (see T4)
  hostId: string,
  expectedFpr: string,              // AUTHORITATIVE HostRecord.enrollFpr from api.getHost(hostId) (T2) — the trust root
  pins: HostPinStore,               // cache/audit only
  onPinMismatch: (expected: string, offered: string, source: 'api' | 'cache') => Promise<'trust' | 'abort'>,
): TerminalTransport
```
**Trust root (fixes the first-connect TOFU gap):** `expectedFpr` is sourced by the caller from
`api.getHost(hostId).enrollFpr` (T2) — the **P5-authenticated HTTPS control-plane record**, a channel
**out-of-band of the relay**. It is established server-side at pairing time (§4.5 BIND, TLS direct to
the control plane) and is present on `HostRecord` (§4.2). The relay-forwarded `host_hello` is **never**
allowed to bootstrap trust on its own.

**Flow:** open same-origin `wss:` (capability token via `Sec-WebSocket-Protocol`, INV15) → run the §4.4
handshake **through the relay as opaque DATA** (`client_hello` → `host_hello`) → **derive the
fingerprint of `host_hello.sig`'s signing key and compare it against `expectedFpr` (the API-sourced
value) — on ANY disagreement, INCLUDING THE VERY FIRST CONNECTION, fire `onPinMismatch(expected,
offered, 'api')` whose default is `'abort'`**; the handshake never completes and no session key material is
derived. This catches a malicious/compromised relay that forges a signed `host_hello` on a device's
**first** connect (the default path for every newly paired host and every newly added device) — its
fingerprint cannot match the API-sourced `enrollFpr` without the host's Ed25519 private key (INV4). On
match, `pins.record(hostId, expectedFpr)` caches it (audit/drift trail); if the cache later disagrees
with a fresh `expectedFpr`, `onPinMismatch(…, 'cache')` surfaces the rotation for review — but the
**API value, not the cache, is authoritative**. → `ClientHandshake.onHostHello(hostHello, agentPubkey)`
returns a `HandshakeResult { keys: DirectionalKeys{c2h,h2c}, aead, transcript }`; P6 builds
`session = createE2ESession('client', result)` → thereafter **`send` = `session.seal(plaintext)`** (writes
under `c2h`, next send `seq`, `sealFrame`) and **`onMessage` = `session.open(dataPayload)`** (reads under
`h2c`, `openFrame` with the session's **independent monotonic per-direction `expectedSeq`** via its
internal `SequenceGuard`). On refresh/reconnect P6 calls **`session.rederive(nextResult)`** (a fresh
ephemeral handshake — see §8 Q#2) to install new `DirectionalKeys` and reset the seq guards (forward
secret); the recoverable replay path (T9) is separate. **Any `open`/`openFrame` throw (AEAD-tag failure,
seq violation, or direction-confusion, INV13) tears the socket down** — never render or swallow a bad frame.

**Crypto engine (constraints to bake in) — owned by P4, consumed here:** the X25519 ECDH, the
`HKDF(sharedSecret, salt=clientNonce‖hostNonce, info="relay-e2e/v1")` master derivation, and the
**direction-split** `HKDF-Expand(master, "relay-e2e/v1/c2h" | "…/h2c")` into `DirectionalKeys{c2h,h2c}`
all live **inside P4's `buildClientHandshake`/`ClientHandshake` (§4.4)** — P6 does **not** call
`crypto.subtle.deriveBits` or hand-roll HKDF. The frozen keys are **`AeadKey`** opaque wrappers
(**NOT** `CryptoKey`) and `sealFrame`/`openFrame` are **synchronous** (`@noble/ciphers`, isomorphic —
this is why WebCrypto's lack of `xchacha20-poly1305` no longer forces the algorithm). P6's only crypto
input is the `aeadOffer: readonly AeadAlg[]` it passes to `buildClientHandshake`; it lists
**`aes-256-gcm` first** (fast native-adjacent path; final choice is the host's `aeadChoice`, per §8 Q#6).
Because `AeadKey`/`DirectionalKeys` are opaque module-held values that P6 never serializes, **no key or
plaintext is ever written to `localStorage`/`console`** (XSS cannot exfiltrate a value the module never
exposes) — the `extractable:false` `CryptoKey` guarantee of the old single-`sessionKey` shape is
preserved by construction.

**TDD:**
1. **RED** `host-pin-store.test.ts` — `record`/`get` cache round-trip + drift-vs-cache detection (the
   store never *creates* trust, only caches an already-verified value) → fails.
2. **RED** `e2e-socket.test.ts` — with a **loopback P4 pair** (browser handshake state ↔ a test agent
   stub using the same `relay-e2e` core): round-trip seal/open; tampered ciphertext → tear-down;
   replayed frame → tear-down; reordered `seq` → tear-down; **reflected/direction-confused frame →
   rejected**; **first-connect relay-forged `host_hello` whose fingerprint ≠ API `expectedFpr` →
   abort** → fails.
3. **GREEN** implement.

**Test cases (incl. security/negative — the crux):**
- **INV2:** a known plaintext marker typed into `send` never appears on the wire un-AEAD'd (the mocked socket sees only `E2EEnvelope` bytes).
- **INV13 replay:** capture an `E2EEnvelope`, re-inject → `openFrame` throws → transport closes; the marker is **not** re-rendered.
- **INV13 reorder:** deliver `seq=5` before `seq=4` → rejected.
- **INV13 tamper:** flip one ciphertext byte → AEAD tag fails → tear-down.
- **INV13 reflection / direction-confusion (crux):** capture a genuine browser→host envelope with `seq=N` and re-inject it into the browser's **incoming** path at the point its independent `expectedSeq` for host→browser also equals `N` → **rejected**. This confirms direction is cryptographically bound (per §8 Open Question #4 — direction-scoped HKDF `info` / AAD), not merely counted; a same-key same-AAD reflection MUST NOT verify.
- **First-connect MITM (crux — the trust-root test):** the relay presents a **validly Ed25519-signed** `host_hello` whose fingerprint ≠ the API-sourced `expectedFpr` (`api.getHost(hostId).enrollFpr`), with **no prior cache entry** → `onPinMismatch(…, 'api')` → default `'abort'` → **handshake never completes, no session key derived, nothing is ever recorded**. Asserts the browser NEVER blind-trusts a relay-forwarded first `host_hello`.
- **Happy path caches from API:** `host_hello` fingerprint == `expectedFpr` → handshake completes and `pins.record(hostId, expectedFpr)` stores the API-sourced value (audit trail), not a handshake-derived one.
- **Cache drift:** a later `expectedFpr` differing from the cached value → `onPinMismatch(…, 'cache')` surfaces the rotation; the API value remains authoritative (cache is advisory).
- **Key non-exposure:** the `DirectionalKeys`/`AeadKey` held by the `E2ESession` are opaque (`{ __aeadKey }`, NOT a `CryptoKey`) and never serialize to a readable form; a structural test asserts no key bytes reach `localStorage`/`console`/any outbound frame (the opaque-wrapper analog of the old `extractable:false` guarantee).
- **Same seam:** `terminal-view.ts` mounts identically on `E2ETransport` vs `PassthroughTransport` (proves the drop-in).

**Security notes:** this task is where INV2/INV13/§4.4-TOFU become real in the browser — the *only*
plaintext endpoint on the phone side. The relay/agent stay ciphertext-shuttles (INV2/INV11) because
the browser hands them only `E2EEnvelope`s. **The anti-MITM root of trust is the API-sourced
`HostRecord.enrollFpr` (§4.2) fetched over the P5-authenticated, out-of-band-of-the-relay HTTPS
channel — NOT the relay-forwarded `host_hello`.** The relay forwards the handshake but cannot forge
`sig` to match `enrollFpr` without the host's Ed25519 private key (INV4), so even a device's *first*
connect to a host is protected: there is no blind trust-on-first-use of a relay-supplied fingerprint.
`HostPinStore` is a cache/audit trail of that authenticated value. Direction-confusion resistance
(reflection test above) is now guaranteed by construction: §4.4 is **frozen direction-split** —
`DirectionalKeys{c2h,h2c}` with `aad = directionLabel‖seq`, so a c2h frame reflected as h2c uses a
different subkey and its AEAD tag cannot verify (FIX 2; §8 Q#4 RESOLVED, no longer a P6 blocker).

---

### T9 — Client-side preview rendering  *(v0.10, W4)*

**Owns:** `relay-web/src/preview-client.ts`, `relay-web/src/preview-grid.ts`,
`relay-web/public/manage.html`, `relay-web/test/preview-client.test.ts`,
`relay-web/test/preview-grid.test.ts`.

**Why it exists:** under E2E the relay **cannot render** a screen it cannot read (EXPLORE §0 accepted
consequence, §4c "biggest product hit"). Server-side preview thumbnails / manage-grid **die**; the
**authorized, key-holding browser** decrypts a **ciphertext** replay and renders a **read-only**
xterm. Ring-buffer replay survives a reload because the relay stores/forwards ciphertext (INDEX §1
v0.10) that the agent (P2) sealed with **`sealReplayFrame` under the recoverable content key
`K_content`** — **NOT** the ephemeral live `DirectionalKeys` (which are re-derived per handshake and
lost on reload). Per frozen §4.4 (FIX 3), the browser re-derives the **same** `K_content` via
**`deriveContentKey({ hostContentSecret, sessionId, alg })`** — where `hostContentSecret` is the §4.5
host-scoped secret obtained (post auth/step-up) via P5, the input to `ReplayKeyParams` — and decrypts
each stored payload with **`openReplayCiphertext(kContent, dataPayload)`**. This is why replay survives
where the ephemeral subkeys cannot (forward-secret live keys are gone after reload).

**Contract:**
```ts
// Imports the FROZEN §4.4 replay surface (FIX 3) from relay-contracts / P4 relay-e2e — cited verbatim:
//   deriveContentKey(p: ReplayKeyParams): AeadKey          // HKDF(hostContentSecret, salt=sessionId, info='relay-e2e/replay/v1')
//   openReplayCiphertext(k: AeadKey, dataPayload): Uint8Array   // decrypt ONE stored ciphertext payload
// K_content is the RECOVERABLE key (host-scoped hostContentSecret, §4.5, via P5) — NOT the ephemeral
// live DirectionalKeys and NOT E2ESession.open/openFrame (those are lost on reload).
export interface ReplaySource {                            // ciphertext ring-buffer replay for one host/session
  readonly sessionId: string
  readonly alg: AeadAlg                                    // negotiated aead (matches how P2 sealed the replay)
  readonly frames: readonly Uint8Array[]                   // stored DATA payloads, each a sealReplayFrame envelope
}
export interface PreviewClient { render(): Promise<void>; dispose(): void }
// Derives K_content = deriveContentKey({ hostContentSecret, sessionId, alg }) ONCE, then decrypts each
// frame with openReplayCiphertext(kContent, frame) and writes into a READ-ONLY headless xterm (no input path).
export function mountPreviewClient(
  card: HTMLElement,
  replay: ReplaySource,
  hostContentSecret: Uint8Array,                           // §4.5, obtained via P5 after auth/step-up; NEVER logged/persisted
  dims: { cols: number; rows: number },
): PreviewClient
export function mountPreviewGrid(
  root: HTMLElement, api: ApiClient,
  loadReplay: (hostId: string) => Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }>,
): { dispose(): void }
```

**TDD:** RED (jsdom + mocked read-only xterm + a `ReplaySource` of `sealReplayFrame`-sealed ciphertext
frames + a `hostContentSecret`, decrypted via `deriveContentKey`/`openReplayCiphertext`) → GREEN.

**Test cases (incl. security):**
- **Recoverable-key decrypt (FIX 3 crux):** frames sealed by a P2 stub with `sealReplayFrame(kContent, seq, plaintext)` are decrypted by `openReplayCiphertext(deriveContentKey({ hostContentSecret, sessionId, alg }), frame)` and written to the preview xterm; the rendered cells match the source screen. `K_content` is re-derived **fresh** from `hostContentSecret` (no in-memory ephemeral key), proving reload-survival.
- **Wrong / ephemeral key rejected:** a replay decrypt attempted with the ephemeral live `DirectionalKeys` (or a host-mismatched `hostContentSecret`) → `openReplayCiphertext` throws (AEAD tag fails) → the card shows "unavailable", never a torn/garbled screen — confirms replay is bound to `K_content`, not the live key (cross-host/cross-session isolation, INV1).
- **Read-only:** the preview xterm has **no** input wiring — a keypress on the card sends nothing (structural assertion: no `seal`/send path exists from a preview).
- **INV2:** the preview never receives plaintext from the server — it decrypts `K_content`-sealed ciphertext locally; the mocked source carries only `E2EEnvelope` payloads.
- A host the account can't reach → `loadReplay` rejects / `onClose('forbidden')` → the card shows "unavailable", never another tenant's screen (INV1).
- Grid disposes all preview clients on unmount (no leaked xterms/sockets — EXPLORE §6 preview-cost discipline; pause when unfocused is a P3/P1 concern but the client must not hold dead resources), and `hostContentSecret` is never written to `localStorage`/`console`.

**Security notes:** client-side preview is the *only* way previews exist post-E2E; it inherits INV2
(decrypt-locally) and INV1 (only authorized hosts). Decryption uses the **recoverable** `K_content`
(`deriveContentKey` over the §4.5 host-scoped `hostContentSecret`, obtained via P5 only after
auth/step-up), **never** the ephemeral live keys — so a reload re-derives the key and replay survives,
while a revoked device whose `hostContentSecret` wrap no longer unwraps (INV12) can derive no key and
sees nothing. `hostContentSecret`/`K_content` are transient in memory, never persisted or logged.
Read-only means the manage grid cannot become a covert input channel.

---

## 5. Security section (per-plan summary)

- **INV3 (owned):** the client has **no** code path that sends `account_id`/`tenant_id` as truth
  (T2 request builders lack the parameter; identity is the passkey/cookie session). Tested in
  `api-client.test.ts` as a structural assertion.
- **INV15 (owned, v0.9+):** from v0.9 every terminal WS upgrade carries a §4.3 capability token
  attached via the **`Sec-WebSocket-Protocol` header (never the URL/query string** — avoids leaking the
  bearer credential into access logs, browser history, and `Referer`); T4 (v0.9) and T8 (v0.10) present
  it. **v0.8 has no token (INDEX §1) — the T3 signed cookie authenticates the upgrade.** Origin stays
  same-origin so the base-app CSWSH check is **retained AND augmented** (M6). Step-up (T7) runs before
  opening a session, not only at login.
- **INV2 / INV13 (consumed, defended):** plaintext exists **only** inside the browser boundary; the
  socket carries `E2EEnvelope`s (T8). `openFrame` throws (tamper/replay/reorder) **tear the socket
  down** — no swallowed crypto error (`coding-style.md` error handling).
- **§4.4 TOFU / INV4 (defended):** the browser's trust root is the **API-sourced `HostRecord.enrollFpr`**
  (`api.getHost`, over the P5-authenticated channel out-of-band of the relay), **not** the relay-forwarded
  `host_hello`. It verifies the handshake fingerprint against that value and **aborts on any mismatch,
  including the first connection** — closing the first-connect MITM gap a malicious relay would otherwise
  exploit. `HostPinStore` is only a cache/audit trail of the authenticated value.
- **No secret at rest / no secret logged (INV5/INV9 spirit):** the E2E keys are opaque `AeadKey`/
  `DirectionalKeys` held only inside the `E2ESession` (never serialized — the opaque-wrapper analog of the
  old `extractable:false` `CryptoKey`); no key, `hostContentSecret`/`K_content`, passkey material, password,
  or plaintext is written to `localStorage`/`console`. Only the host `enrollFpr` (a public fingerprint) and
  non-sensitive UI prefs persist.
- **XSS discipline:** all dynamic text via `textContent`; no `innerHTML` with server/user data;
  static HTML pages document a strict CSP (no inline script) — the terminal payload is untrusted bytes
  rendered by xterm, never HTML.
- **No `console.log`, no mutation:** immutable snapshots for view state (config/records are `readonly`),
  early returns over deep nesting, functions <50 lines, files 200–400 (800 hard max).

---

## 6. Verification

```bash
# In relay-web/ (new package — base `npm test` and src/ are untouched)
npx vitest run config           # T1 subdomain + scheme-following URL (M6)
npx vitest run api-client       # T2 Zod validation + INV3 (no account_id sent)
npx vitest run login-password   # T3 v0.8 gate
npx vitest run ws-transport terminal-view   # T4 passthrough + xterm byte semantics; v0.8 cookie-only (no token), v0.9 token via Sec-WebSocket-Protocol (never URL)
npx vitest run dashboard        # T5 host ●status, revoked non-interactive (INV12 UI)
npx vitest run add-machine      # T6 pairing code single-use/short-TTL UX (§4.5)
npx vitest run login-passkey webauthn        # T7 passkey + step-up, no-SMS structural check
npx vitest run e2e-socket host-pin-store     # T8 INV2/INV13/TOFU: tamper/replay/reorder/reflection + first-connect fpr≠API → abort/tear-down
npx vitest run preview-client preview-grid   # T9 client-side replay decrypt via openReplayCiphertext/deriveContentKey (K_content, not live key) + read-only render (INV2/INV1)

npx vitest run --coverage       # 80%+ (statements/branches/functions/lines) per testing.md
npm run typecheck               # relay-web tsconfig; base src/types.ts NOT imported/edited
node build.mjs                  # esbuild bundles all entry pages
git -C .. diff --quiet -- public src protocol.ts   # PROVE base app untouched (must exit 0)
```

**Cross-plan integration gates (run once P1/P3/P4/P5 land):**
- Against a live P1 relay + P2 agent: open `alice.term.<domain>` → passkey login (P5) → step-up →
  E2E handshake completes, fingerprint pins, keystrokes round-trip, `top` redraws (resize frame).
- **Isolation tripwire (mirror of P5's server test):** authenticated as account A, drive the client to
  request a `host_id` owned by B → server 403 → client renders "forbidden", **never** a foreign screen
  (INV1). This is the browser-side witness of the permanent CI tripwire P5 owns.

---

## 7. Phasing recap (INDEX §1)

| Phase | P6 tasks | Deliverable |
|---|---|---|
| **v0.8 MVP** | T1, T2 (basic: `listHosts`/`getHost`/`requestPairingCode`/`hostStatus`, **no** `issueCapabilityToken`), T3, T4 | Password gate + subdomain connect + xterm view over a **passthrough** transport (no E2E, **no capability token — cookie auth only**, INDEX §1). Proves the café demo from the browser. |
| **v0.9 SaaS** | T5, T6 (+ T2 grows) | Dashboard host `●online`, add-machine onboarding (pairing-code UX), self-serve. |
| **v0.10 E2E-hardening** | T7, T8, T9 | Passkey + step-up; **browser-side E2E consuming the frozen §4.4 `buildClientHandshake`/`E2ESession`/`DirectionalKeys` (not a raw-SubtleCrypto single key)** (the transport swap, zero change to the view); **client-side preview rendering via `openReplayCiphertext`/`deriveContentKey`** (server previews die). |

---

## 8. Open questions (for the orchestrator to resolve — do NOT guess, G1 BLOCKED-style)

1. **[RESOLVED — FIX 2, INDEX §4.4]** ~~P4 handshake driver surface.~~ The frozen §4.4 now exports the
   driver verbatim: **`buildClientHandshake(ctx: AuthorizedDeviceContext, hostId, aeadOffer): ClientHandshake`**
   (P4 **T11**) whose `ClientHandshake` (P4 **T8** `createClientHandshake`) has
   **`start(): Promise<ClientHello>`** and **`onHostHello(msg, agentPubkey): Promise<HandshakeResult>`**,
   followed by **`createE2ESession('client', result): E2ESession`** (P4 **T9**). T8 imports these
   verbatim — there is **no** `CryptoKey`/`sessionKey` return and no local handshake code (the "no local
   crypto" fence holds by construction).
2. **[RESOLVED — FIX 3, INDEX §4.4/§4.5]** ~~Session-key recovery on refresh.~~ The answer is **both,
   split by path**: **live** frames use ephemeral `DirectionalKeys` that the browser re-derives with a
   **fresh handshake** on reload/reconnect via **`E2ESession.rederive(next)`** (P4 **T9**, forward-secret);
   **replay/preview** frames use a **recoverable, stable** content key **`K_content = deriveContentKey({
   hostContentSecret, sessionId, alg })`** (P4 **T10**) sealed by P2 with `sealReplayFrame` and opened by
   T9 with **`openReplayCiphertext`**. `hostContentSecret` is the §4.5 host-scoped secret delivered
   wrapped-to-agent (P3 at BIND) and to browser devices via P5 after auth/step-up — **not** an
   account-wide raw secret. T8 (live) and T9 (replay) now have unambiguous, disjoint key paths.
3. **Capability-token issuance trigger.** Does P5 issue the terminal token at login, or per-open
   (recommended, pairs with step-up T7)? T4 (v0.9+)/T8 assume **per-open** (`issueCapabilityToken(hostId,
   rights)` right before upgrade; **not present in v0.8**). Confirm with P5.
4. **[RESOLVED — FIX 2, INDEX §4.4]** ~~Direction-binding of `sessionKey`/nonce/AAD (BLOCKING for T8 —
   anti-reflection).~~ §4.4 is now **frozen direction-split**: the master secret is HKDF-Expanded into
   **two disjoint subkeys** with direction-scoped `info` — **`DirectionalKeys{ c2h, h2c }`** (`HKDF_INFO_C2H
   = 'relay-e2e/v1/c2h'`, `HKDF_INFO_H2C = 'relay-e2e/v1/h2c'`, P4 **T6** `deriveSessionKeys`) — and the
   `E2ESession` (P4 **T9**) seals with the write subkey / opens with the read subkey, with `aad =
   directionLabel‖seq`. A reflected c2h frame therefore verifies under `h2c` **only if** its tag matches a
   different key — it cannot. T8 still ships the reflection/direction-confusion test, now as a **regression
   witness** for a guarantee that is met by construction rather than a blocking upstream confirmation.
5. **WebAuthn `rpId` scope + cookie domain (BLOCKING for T7).** `hosts.subdomain` is UNIQUE **per host**
   (§4.2) and one account owns many host subdomains (`alice.term.…`, `alice2.term.…`). A per-host
   `rpId` would make a passkey registered on host A's subdomain fail `navigator.credentials.get()` on
   host B's subdomain (WebAuthn credentials are `rpId`-scoped) — forcing re-registration per host.
   **Resolve with P3/P5 before T7:** either (a) authentication happens on a dedicated **account-level
   domain** (`app.<domain>` or `<account>.<domain>`) with `rpId` set to that stable parent, and the
   terminal/dashboard subdomains only consume the resulting session/capability token; or (b) `rpId` is
   deliberately the **registrable parent domain** (`term.<domain>`) so one passkey spans all of an
   account's host subdomains — with the explicitly-accepted tradeoff that all tenant subdomains become a
   single WebAuthn security boundary. The choice also fixes the T3 session-cookie domain scope and the
   cross-subdomain CSRF/cookie posture. T7 MUST consume the resolved `rpId`, never `location.hostname`.
6. **`xchacha20-poly1305` in-browser.** Not native to WebCrypto; T8 offers `aes-256-gcm` first. Confirm
   P4/P2 accept AES-GCM as the browser-negotiated default (or that P4 ships a WASM XChaCha) — affects
   `aeadOffer` ordering only, not the envelope.
