Files
web-terminal/docs/PLAN_RELAY_FRONTEND.md
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
2026-07-02 06:10:16 +02:00

53 KiB
Raw Blame History

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

Charter: 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 (buildClientHandshakeE2ESession, 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 §0 (LOCKED, CLOSED), §4c, §4e, §5. Conventions per PLAN.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 recordHostRecord.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-trustOnFirstUses 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 buildClientHandshakeClientHandshake 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 (T1T4) 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:

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.tsreadConfig 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.comsubdomain==='alice', wsUrl('/term')==='wss://alice.term.example.com/term'.
  • http://alice.term.localhost:3000ws://… (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).

// 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 enrollFprApiError (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:

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):

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:

// §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:

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:

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:

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:

// 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_hellohost_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.tsrecord/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 Nrejected. 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 entryonPinMismatch(…, '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 E2EEnvelopes. 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-splitDirectionalKeys{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_contentNOT 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:

// 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 E2EEnvelopes (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 200400 (800 hard max).

6. Verification

# 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 infoDirectionalKeys{ 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.