# Rendezvous-Relay Service — Plan-of-Plans (INDEX) > **Product**: a multi-tenant rendezvous-relay SERVICE that bridges external devices to each > customer's self-hosted web-terminal — an "ngrok-for-Claude-Code" **with end-to-end encryption**. > **Source of intent**: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) — its **§0 LOCKED > DECISIONS are AUTHORITATIVE and CLOSED**; do not reopen them in any plan. > This INDEX is the **coordination point for the 6 implementation plans** — the analog of > [`src/types.ts`](../src/types.ts) for the base app. It fixes: overview & phasing (§1), the > cross-plan **dependency DAG** (§2), the numbered **security invariants** (§3), the **FROZEN > SHARED CONTRACTS** every plan must cite verbatim (§4), and a **per-plan charter** (§5). > **The 6 plans MUST reference §3 and §4 by name and MUST NOT redefine any contract locally.** > Conventions follow [`PLAN.md`](./PLAN.md): stable task IDs grouped into dependency waves, an > `Owns:` disjoint-file list per task, function-signature-level contracts, TDD (tests FIRST), > explicit test cases, per-task security notes. Progress is logged in > [`PROGRESS_LOG.md`](./PROGRESS_LOG.md) — **orchestrator-only writer** (subagents return a > ready-to-paste entry, G1). --- ## 0. Scope boundary — what does NOT change The existing web-terminal (`src/`) is a **byte-shuttle** and stays **byte-for-byte unchanged** except **one config touch-point**: `ALLOWED_ORIGINS` gains the assigned subdomain at agent-install time (EXPLORE §3 "one base-app touch-point"). No new code in `src/`, `public/`, `protocol.ts`, or the session model. **All new code lives in NEW top-level packages**: | Package | Owner plan | Role | |---|---|---| | `relay-contracts/` | **INDEX-frozen** (§4) — the coordination point, analog of `src/types.ts` | Shared Zod schemas + TS types for every §4 contract. Dependency-free (no `ws`/`pg`/DOM ambient) so agent, relay, control-plane, and browser can all import it. **Frozen here; changed only via this INDEX.** | | `term-relay/` | P1 (data plane) + P3 (control plane) | Relay nodes (stateless data plane) and the control plane (accounts, routing, pairing, metering). Physically split — EXPLORE §3 "control plane vs data plane". | | `agent/` | P2 | Host-agent: enrollment, mTLS dial-out, mux tunnel holder, loopback forwarder, E2E endpoint. | | `relay-e2e/` | P4 | Shared E2E crypto core (handshake state machine, AEAD envelope), imported by `agent/` and the browser bundle. | | `relay-web/` | P6 | Browser bundle served from the tenant subdomain: login, dashboard, client-side E2E + preview. | **The upgraded discipline: byte-shuttle → CIPHERTEXT-shuttle.** The relay and the agent move opaque, authenticated, per-tenant-routed blobs and parse **zero** terminal semantics (INV2, INV11). --- ## 1. Overview & phasing Three phases, mapped 1:1 to EXPLORE §7. Each plan's tasks are tagged with the phase they land in; a plan spans phases (e.g. TRANSPORT ships an frp scaffold in v0.8 and the native mux in v0.9). ### v0.8 — MVP single-node relay ("prove the café demo") The pitch *is* the demo (EXPLORE §5): open `alice.term.` on a phone, type a token, land in the laptop's shell — no router/VPN/static-IP configured. **Deliberate shortcuts, retired later:** - **frp scaffold** as the transport stepping-stone (P1) — `frps` on one VPS + `frpc`-wrapped agent (P2). - **Flat SQLite account table**, manual provisioning (P3): `{ accountId, subdomain, agentToken(hashed), clientToken(hashed) }`. - **Shared `clientToken` password gate + signed cookie at the relay edge** (P6) — the auth the base app never had. - **No E2E, no Passkey, no capability tokens yet** — but the frame path is designed so ciphertext drops in without reshaping it. - **Plans active**: P1 (frp scaffold + native-mux frame spec written but not yet the substrate), P2 (frpc-wrap + `npx …agent pair` skeleton), P3 (flat table), P6 (password gate). P4/P5 **write their contracts** (so v0.9/v0.10 don't reshape data) but ship no runtime. ### v0.9 — Multi-tenant SaaS (native mux, real accounts, dashboard, metering) Retire every v0.8 shortcut except plaintext: - **Native WS mux replaces frp** (P1) — the §4.1 frame format becomes the real substrate; per-stream flow control, heartbeat, reconnection. - **Ed25519 pairing + mTLS with short-lived rotating certs** (P2 + P3 + P5) — DB stores only public keys (INV4). - **Postgres control plane** (P3): immutable `accounts`/`hosts`/`sessions`, pairing-code issuance/redemption, per-tenant subdomain assignment, Redis routing table (`host_id → relay_node` heartbeat-TTL), metering hooks (paired hosts + concurrent viewers), graceful relay-node drain. - **Capability tokens on the WS upgrade + deny-by-default tenant authz + the CI cross-tenant tripwire** (P5). - **Self-serve dashboard**: host ●online status, add-machine flow, static-binary agent + service install (P6 + P2). ### v0.10 — E2E-hardening (the non-negotiable phase, done BEFORE scaling users) - **Full browser↔agent E2E** (P4): authenticated X25519 ECDH through the relay, host-key TOFU/pinning, AEAD frames with per-message nonce + monotonic sequence, session-key lifecycle, authenticated multi-device key distribution. - **Passkey/WebAuthn primary + step-up before opening a session + fast global/per-host revocation + immutable zero-payload audit log** (P5). - **CLIENT-SIDE preview rendering** (P6) — server previews die under E2E; the key-holding browser decrypts + renders a read-only xterm. - **Third-party pentest** targeting tenant-crossing and E2E-MITM-by-malicious-relay. --- ## 2. Dependency DAG across the 6 plans ``` relay-contracts/ (§4 FROZEN — this INDEX) │ every plan imports it read-only ▼ ┌──────── P1 TRANSPORT ────────┐ (mux frame + stream lifecycle = the substrate) │ │ ▼ ▼ P2 AGENT ◀────────── mux ──────────▶ P3 CONTROL PLANE (dial-out, mTLS, holds tunnel, (accounts, routing table, pairing forwards to :3000, E2E endpoint) issuance, mTLS cert authority, metering) │ │ └───────────┬──────────────────┘ ▼ ┌────────── P5 AUTH & ISOLATION ──────────┐ (capability tokens, deny-by-default │ enforces authz on P1 upgrade + P3 │ authz, mTLS verify, CI tripwire, │ connect/reattach; cert rotation │ revocation, audit) │ │ │ P4 E2E ──────────────────────┤ (rides P1 DATA frames as opaque │ (handshake through relay, AEAD │ ciphertext; keys bound to P2 │ envelope, key lifecycle) │ enrollment pubkey + P5 device auth) └───────────────────┬──────────────────────┘ ▼ P6 FRONTEND (login/passkey, dashboard, connect via subdomain, browser-side E2E via SubtleCrypto, CLIENT-SIDE preview render; core xterm byte path untouched) ``` **Ordering rules (what must exist before what):** 1. **`relay-contracts/` (§4) is W0 for the whole program** — frozen before any plan's W1. A plan needing a new shared field changes it *here* (a coordination point), never locally. 2. **P1 TRANSPORT before P2 AGENT & P3 CONTROL PLANE** — both consume the §4.1 mux frame + stream lifecycle. (In v0.8 the frp scaffold lets P2/P3 start against a stub; the native-mux swap in v0.9 is contract-compatible by construction.) 3. **P2 + P3 before P5 AUTH** — capability tokens sign over `account_id`/`host_id` from the P3 registries; mTLS verifies P2's enrolled pubkey. 4. **P2 + P3 before P4 E2E** — the E2E handshake pins the host's enrollment pubkey (P2) fetched from the host registry (P3); relay forwarding of handshake frames uses P1 DATA frames. 5. **P4 + P5 before P6 FRONTEND** — the browser needs the capability-token issuance path (P5) and the E2E core (P4) before it can connect + decrypt + render client-side previews. 6. **Cross-cutting**: every plan cites its enforced invariants (§3). No plan may weaken the base-app Origin check; it is *retained AND augmented* by a capability token (INV6/INV15). ### 2.1 W0 task — author `relay-contracts/` (INDEX-owned; FIX 1) Every plan imports `relay-contracts/` read-only, but **no plan owned authoring it** (FIX 1) — so the INDEX owns one W0 task, **`W0-CONTRACTS`**, that builds and freezes it **before any plan's W1 dispatches**. - **Owns (disjoint from all six plans):** `relay-contracts/package.json`, `relay-contracts/tsconfig.json`, `relay-contracts/src/**`, `relay-contracts/test/**`. **Dependency-free** — no `ws`/`pg`/DOM ambient — so agent (P2), relay (P1/P3), auth (P5), E2E core (P4), and browser (P6) can all import it. - **Authors the entire §4 surface as Zod schemas + inferred TS types** (string-literal unions, not enums): - **§4.1** `MuxFrameType`/`MuxFrameHeader`/`MuxOpen`; `encodeMuxFrame`/`decodeMuxFrame`/`decodeHeader`; CBOR payload codecs (`encodeOpen`/`decodeOpen`, `encodeWindowUpdate`/`decodeWindowUpdate`, `encodeGoaway`/`decodeGoaway`); `GoAwayReason` + `decodeGoAwayReason`. - **§4.2** `HostRecord`/`HostStatus`/`PlanTier`/`RouteEntry`; the FIX 4 teardown shapes (`RevocationScope`/`KillSignal`/`RevocationBus`, `RELAY_REVOCATIONS_CHANNEL`, `REVOCATION_PUSH_BUDGET_MS`); the **INV8 version-table DDL** (`host_versions`/`hosts_current`). - **§4.3** `CapabilityToken`/`CapabilityRight`/`verifyCapabilityToken`; the FIX 5 subprotocol constants (`APP_SUBPROTOCOL`, `TOKEN_SUBPROTOCOL_PREFIX`, `encodeTokenSubprotocol`, `extractTokenFromSubprotocols`). - **§4.4** the **FIX 2** evolved E2E surface (`AeadKey`, `DirectionalKeys`, sync `sealFrame`/`openFrame`, `E2ESession`/`createE2ESession`, `HandshakeResult`, `ClientHello`/`HostHello`/`E2EEnvelope`, `buildClientHandshake`/`AuthorizedDeviceContext`/`DeviceAuthProofProvider`, HKDF label constants) plus the **FIX 3** replay surface (`ReplayKeyParams`, `deriveContentKey`, `sealReplayFrame`, `openReplayCiphertext`). - **§4.5** `EnrollResult` (incl. `hostContentSecret`, FIX 3) + `PairingCodeRecord`. - **Verify:** `tsc --noEmit` strict + a KAT/round-trip test per codec (mux frame, CBOR payloads, envelope, subprotocol) so P1↔P2↔P4↔P6 agree byte-for-byte. **Freeze gate:** all §4 exports test-green before `W0-CONTRACTS` is done; thereafter changed **only** through this INDEX (§2 rule 1). Crypto *implementations* of the §4.4 functions live in P4's `relay-e2e/` (which re-exports them); `relay-contracts` owns the **shapes**. --- ## 3. Cross-cutting SECURITY INVARIANTS (numbered, testable) These are program-wide. **Each of the 6 plans MUST list which invariants it enforces and ship a test per invariant it owns.** Violating any INV blocks merge (CRITICAL, per `code-review.md`). | ID | Invariant | Testable assertion | Primary owners | |---|---|---|---| | **INV1** | **Cross-tenant isolation — A can never reach B.** No code path resolves a host by raw address/port/user-supplied hostname; the client names only a `host_id` it is authorized for. | **Permanent CI tripwire**: device authenticated as account A requesting `host_id` owned by B → **403**, on both first connect and reattach. Fuzz `host_id` guessing → 403. | P5 (owns tripwire), P1, P3 | | **INV2** | **Relay handles only ciphertext.** The data plane never holds plaintext terminal bytes; DATA-frame payloads are opaque AEAD envelopes (§4.4). | Inject a known plaintext marker through the tunnel; assert it never appears in relay memory dumps / buffers / logs. Relay code imports **no** xterm/ANSI parser. | P1, P4, P2 | | **INV3** | **`account_id` always derives from the authenticated principal — NEVER client-supplied.** No request/frame field named `account_id`/`tenant_id` is ever trusted as source of truth. | Send a forged `account_id` in body/query/frame → ignored; authz uses only the session/cert-derived principal. Static check: no read of client `account_id` in an authz decision. | P5, P3, P6 | | **INV4** | **No shared secrets — per-host asymmetric identity.** Agents authenticate with a per-host Ed25519 key + mTLS; the DB stores only **public** keys. | DB dump contains no private key / bearer secret capable of impersonating a host. Enrollment private key never leaves the host process. | P2, P3, P5 | | **INV5** | **No plaintext / secret at rest.** Ciphertext-only buffers with short TTL on the relay; control-plane stores hashes/pubkeys, never raw tokens or shell bytes. | Grep at-rest stores (Redis/PG/disk) for plaintext markers and secret material → none. Ring/replay buffers hold ciphertext. | P1, P3, P4 | | **INV6** | **Deny-by-default authz on connect AND reattach.** Every WS upgrade and every session reattach re-validates `session.account_id == host.account_id`; default is reject. | Drop the token → reject. Reattach with a `session_id` not owned by the principal → 403. No "allow if unspecified" branch. | P5, P1, P3 | | **INV7** | **Stateless data plane.** A relay node holds no durable tenant state; a crash loses nothing (agents reconnect + re-register). | Kill a node mid-session → agent reconnects elsewhere, PTY survives, no data loss; node process has no DB-of-record. | P1, P3 | | **INV8** | **Immutable records + atomic snapshot swap.** Account/host/routing records are never mutated in place; updates create a new record/snapshot swapped atomically. | Concurrent update test observes only whole old or whole new snapshot, never a torn read. No in-place field mutation in registries. | P3 | | **INV9** | **Secrets in env/secret-manager, validated at startup, never logged.** | Startup fails fast if a required secret is missing/invalid. Log scan asserts zero secret material emitted. | all | | **INV10** | **Immutable audit log with ZERO payload.** Every attach/manage/kill/enroll/revoke is logged; terminal payload is never logged; cross-tenant-crossing attempts raise an alert. | Audit entries contain metadata only (principal, host_id, action, ts) — never keystrokes/output. A → B attempt emits an alert event. | P5, P3 | | **INV11** | **No terminal parsing in relay/agent (ciphertext/byte-shuttle discipline).** Neither relay nor agent interprets ANSI/attach/input/resize semantics. | Relay/agent codebase has no dependency on terminal parsers; frames pass through opaque. | P1, P2 | | **INV12** | **Fast revocation kills live tunnels in seconds.** Global + per-host revocation stops cert renewal AND tears down the live tunnel. | Revoke a host → its live tunnel drops and reconnect is refused within seconds; capability tokens for it stop validating. | P5, P3, P2 | | **INV13** | **Anti-replay/injection: per-message nonce + strictly monotonic sequence.** AEAD frames carry a unique nonce and a monotonic `seq`; out-of-order/duplicate/tampered frames are rejected. | Replay a captured frame → rejected. Reorder `seq` → rejected. Flip a ciphertext bit → AEAD tag fails. | P4 | | **INV14** | **mTLS with SPIFFE-style short-lived, auto-rotating certs.** Agent↔relay is mutually authenticated; certs are short-TTL and rotate without downtime. | Expired cert → handshake refused. Rotation mid-tunnel is seamless. CA never signs a cert for a pubkey not in the host registry. | P5, P2 | | **INV15** | **Capability token required on the WS upgrade.** Origin/CSWSH check is retained AND a signed capability token (host + rights scope) is required; token scopes exactly one host and a rights subset. | Upgrade without a valid token → 401. Token scoped to `attach` cannot `kill`. Foreign-Origin still 401 (base-app behavior retained). | P5, P1, P6 | --- ## 4. FROZEN SHARED CONTRACTS These live in **`relay-contracts/`** as **Zod schemas + inferred TS types** (validation at every boundary is mandatory — `coding-style.md` "Input Validation"). Types are `PascalCase`, functions `camelCase`, unions are **string-literal unions, not enums**. **No plan may redefine any of these locally.** Byte-level fields are big-endian (network order) unless noted. Wire values are hex here. ### 4.1 Mux frame format & stream lifecycle (P1 owns; P2 consumes) **One physical outbound `wss://` per host carries N sessions × M mirrored clients + heartbeat.** Each inbound browser connection ⇒ one logical **stream** ⇒ one fresh `ws://127.0.0.1:3000` on the host. **Frame header — 15 bytes fixed, then `length` bytes of payload:** ``` offset size field meaning 0 1 version 0x01 1 1 type 0x01 OPEN · 0x02 DATA · 0x03 CLOSE · 0x04 PING · 0x05 PONG · 0x06 WINDOW_UPDATE · 0x07 GOAWAY 2 1 flags bit0 FIN (last DATA on stream) · bit1 RST (abnormal close) · others reserved=0 3 4 streamId uint32; 0 = connection-level control (PING/PONG/GOAWAY/WINDOW_UPDATE for whole link) 7 8 payloadLen uint64 byte length of payload (MUST be ≤ negotiated maxFrameBytes) 15 var payload opaque bytes (see per-type below) ``` **Per-type payload:** - **OPEN** (relay→agent): CBOR of `MuxOpen { streamId, subdomain, requestPath, originHeader, remoteAddrHash, capabilityTokenRef }`. Agent dials `ws://127.0.0.1:3000` replaying `Origin: originHeader`. **The relay has already authorized this stream (P5); the agent trusts the authenticated tunnel.** Carries **no** terminal bytes. - **DATA**: **opaque ciphertext** — the E2E encrypted envelope (§4.4) once E2E ships; a raw WS payload passthrough in v0.8 pre-E2E. **The relay never inspects it (INV2/INV11).** - **CLOSE**: empty; `flags.RST` distinguishes error close from graceful. Closing a stream on either side frees the paired localhost socket. - **PING/PONG** (streamId 0): 8-byte opaque token echoed back; heartbeat every **15s**, miss ⇒ tunnel dead ⇒ agent reconnects. - **WINDOW_UPDATE**: `uint32 credit` increment for `streamId` (or 0 for the whole link) — **credit-based per-stream flow control** so one heavy `vim`/`top` redraw can't starve another stream. Initial window is negotiated at tunnel setup; a sender blocks a stream when its credit hits 0. - **GOAWAY** (streamId 0): `uint32 lastStreamId` + `uint32 reason` — graceful drain (EXPLORE §3 relay-node drain): agent finishes in-flight streams then reconnects elsewhere. Reason codes are the frozen `GoAwayReason` map below (`1 operatorDrain · 2 revoked · 3 shutdown`) — the same codes P1 `drain.ts` `DRAIN_REASON` and P5 revocation (FIX 4) emit; `revoked` forces grace-to-0. **Stream lifecycle**: `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`. Illegal transitions (DATA before OPEN, DATA after CLOSE, unknown streamId) ⇒ RST that stream, never the tunnel. `streamId`s are allocated by the relay, monotonically increasing per tunnel, never reused within a tunnel's lifetime. ```ts export type MuxFrameType = 'open' | 'data' | 'close' | 'ping' | 'pong' | 'windowUpdate' | 'goaway' export interface MuxFrameHeader { readonly version: 1 readonly type: MuxFrameType readonly fin: boolean readonly rst: boolean readonly streamId: number // uint32; 0 = connection-level readonly payloadLen: number // uint64 (safe-int guarded ≤ maxFrameBytes) } export interface MuxOpen { readonly streamId: number readonly subdomain: string readonly requestPath: string // e.g. '/term?join=' — opaque passthrough readonly originHeader: string // real browser Origin, validated end-to-end readonly remoteAddrHash: string // salted hash of client IP (audit only, INV10) readonly capabilityTokenRef: string // jti of the token that authorized this stream (P5) } export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array } // Typed control-frame payload codecs (CBOR) — authored by W0-CONTRACTS (§2.1), consumed by P1/P2: export function encodeOpen(open: MuxOpen): Uint8Array export function decodeOpen(payload: Uint8Array): MuxOpen // Zod-guarded; throws on bad shape export function encodeWindowUpdate(credit: number): Uint8Array // uint32 export function decodeWindowUpdate(payload: Uint8Array): number export function encodeGoaway(lastStreamId: number, reason: GoAwayReason): Uint8Array export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: GoAwayReason } // GOAWAY reason as a string-literal union (NOT an enum); wire code ⇄ label via decodeGoAwayReason: export type GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown' export function decodeGoAwayReason(code: number): GoAwayReason // 1→operatorDrain · 2→revoked · 3→shutdown; else throws ``` ### 4.2 Account / host data model (P3 owns; Postgres = ownership source of truth, Redis = live location) **Immutable records (INV8): updates insert a new row / swap a snapshot; no in-place mutation.** ``` accounts account_id uuid PK -- unguessable, never recycled plan text -- 'free'|'personal'|'pro'|'team' (string-literal union) created_at timestamptz status text -- 'active'|'suspended' -- auth principals join via webauthn_credentials / oidc_identities (P5-owned tables) hosts host_id uuid PK -- unguessable UUIDv4, NEVER recycled (INV1) account_id uuid FK -> accounts -- ownership source of truth subdomain text UNIQUE -- 'alice' in alice.term.; stable across reconnects agent_pubkey bytea -- Ed25519 PUBLIC key only (INV4); private key never leaves host enroll_fpr text -- fingerprint of agent_pubkey, pinned by browser for E2E TOFU (§4.4) status text -- 'online'|'offline'|'draining'|'revoked' (string-literal union) last_seen timestamptz -- heartbeat freshness created_at timestamptz revoked_at timestamptz NULL -- set on revocation (INV12); status flips to 'revoked' sessions session_id uuid PK -- base-app sessionId, opaque to relay; unguessable host_id uuid FK -> hosts account_id uuid -- DENORMALIZED for O(1) deny-by-default authz (INV3/INV6) created_at timestamptz last_attach_at timestamptz pairing_codes -- §4.5 code_hash text PK -- hash of single-use code; raw code never stored (INV5) account_id uuid FK expires_at timestamptz -- short TTL redeemed_at timestamptz NULL -- single-use: non-null ⇒ spent -- Redis (ephemeral, heartbeat-TTL) — NOT source of truth: route:{host_id} -> { relayNodeId, updatedAt } EXPIRE = heartbeat_ttl -- live routing table (INV7) revoked:{jti} -> 1 EXPIRE = token_exp -- capability-token revocation (INV12) ``` ```ts export type HostStatus = 'online' | 'offline' | 'draining' | 'revoked' export type PlanTier = 'free' | 'personal' | 'pro' | 'team' export interface HostRecord { readonly hostId: string; readonly accountId: string; readonly subdomain: string readonly agentPubkey: Uint8Array; readonly enrollFpr: string readonly status: HostStatus; readonly lastSeen: string; readonly createdAt: string readonly revokedAt: string | null } export interface RouteEntry { readonly relayNodeId: string; readonly updatedAt: string } ``` **Control→data-plane revocation teardown (FIX 4 — INV12).** Fast revocation must tear down an **already-open** tunnel, not merely refuse the next connect. The teardown channel + signal shape are frozen **here** (promoted out of `relay-auth` so P1 does not import P5, keeping the DAG acyclic): - **Redis pub/sub channel: `relay:revocations`** — the one named control→data-plane bus. **P3/P5 PUBLISH** a `KillSignal` on it (P5 `revoke()` after marking `revoked:{jti}` / flipping `hosts.status='revoked'`); **every P1 relay node SUBSCRIBES** and, per affected stream, injects a §4.1 `CLOSE`+`flags.RST` (host/account scope) or a connection-level `GOAWAY` (global scope) using the existing frozen §4.1 wire — **no new frame type**. Teardown budget: `REVOCATION_PUSH_BUDGET_MS = 2000`. P3 OQ4's "control→node drain channel" reconciles to this exact channel; P5 stays socket-free (publishes only). ```ts // FROZEN in relay-contracts (§4.2). P5 publishes; P1 subscribes; P1's killsScope() predicate selects streams. export type RevocationScope = | { readonly kind: 'host'; readonly hostId: string } | { readonly kind: 'account'; readonly accountId: string } | { readonly kind: 'global' } export interface KillSignal { readonly scope: RevocationScope readonly at: number // epoch seconds the revocation was issued readonly reason: string // metadata only, ZERO payload (INV10) } export interface RevocationBus { publish(signal: KillSignal): Promise } // P3/P1 implement the `relay:revocations` transport export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const export const REVOCATION_PUSH_BUDGET_MS = 2000 as const ``` **INV8 version-table DDL (immutable records + atomic snapshot swap).** Mutable columns above (`hosts.status`/`last_seen`, `sessions.last_attach_at`) are never updated in place; a new version row is inserted and the current pointer is swapped atomically, so a concurrent read sees whole-old or whole-new: ``` host_versions version_id uuid PK host_id uuid FK -> hosts snapshot jsonb -- full immutable HostRecord snapshot created_at timestamptz supersedes uuid NULL -- prior version_id (audit chain) hosts_current host_id uuid PK version_id uuid FK -> host_versions -- CAS-swapped pointer to the live snapshot (atomic) ``` ### 4.3 Capability token (P5 owns; verified on every P1 upgrade and P3 connect/reattach) Signed, **stateless**, short-TTL (Ed25519-signed PASETO/JWS). `sub` and `host` come from the authenticated principal + registry (INV3) — **never** from client input. Revocable by `jti` (INV12). ```ts export type CapabilityRight = 'attach' | 'manage' | 'kill' export interface CapabilityToken { readonly sub: string // principal id (account/device) — from authenticated session, NOT client readonly aud: string // subdomain this token is valid at (Host-confusion guard, INV1) readonly host: string // exact host_id scope — single host, never wildcard readonly rights: readonly CapabilityRight[] // least-privilege subset; 'attach' ⊉ 'kill' readonly iat: number; readonly exp: number // short TTL readonly jti: string // unique id for revocation-list lookup // signature over the above by the control-plane signing key (verified at the relay edge) } export function verifyCapabilityToken(raw: string, expectedAud: string, now: number): CapabilityToken ``` Rule: a share-QR / device grant issues a token scoped to **one host, a rights subset, expiring** — granting one session read-or-write without granting the account (EXPLORE §4a.3). **WS-upgrade token transport — FROZEN wire format (FIX 5).** The browser's native `WebSocket` API cannot set request headers, so the token rides the **`Sec-WebSocket-Protocol`** subprotocol list (preferred) or a short-lived `HttpOnly; Secure; SameSite=Strict` cookie (fallback). **Never the query string** (leaks into proxy/CDN/frp logs, history, `Referer`). The subprotocol encoding is frozen so P1 (relay) and P6 (browser) agree exactly — they diverged locally (`relay.capability.v1` vs `term.relay.v1`) and this pins one: ```ts // FROZEN in relay-contracts (§4.3). The client opens: // new WebSocket(url, [APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX + base64url(rawToken)]) export const APP_SUBPROTOCOL = 'term.relay.v1' as const // the app subprotocol — the ONLY value ever accepted/echoed export const TOKEN_SUBPROTOCOL_PREFIX = 'term.token.' as const // the token entry = this prefix + base64url(token) export function encodeTokenSubprotocol(rawToken: string): string // => 'term.token.' + base64url(rawToken) export function extractTokenFromSubprotocols(values: readonly string[]): string | null // strips prefix, base64url-decodes; null if absent ``` **Echo rule (MUST):** the relay reads the token from the `term.token.` entry, strips it before verify, and the accepted/echoed `Sec-WebSocket-Protocol` in the handshake response **MUST be `term.relay.v1` (the app subprotocol), NEVER the token entry** — echoing the token would leak the bearer secret into logs and the handshake response. After open, the client asserts `ws.protocol === 'term.relay.v1'` and tears down otherwise. ### 4.4 E2E handshake sequence & encrypted frame envelope (P4 owns; relay forwards, never derives) **Authenticated X25519 ECDH THROUGH the relay** — the relay forwards these as opaque §4.1 DATA frames and **cannot derive the key** (INV2). The browser **pins the host's enrollment fingerprint** (`enroll_fpr` from §4.2, TOFU or pinned) so a malicious relay cannot MITM. ``` Browser (device, authenticated via P5) Host-agent (enrolled, §4.5) │ client_hello ─────────────────(relay DATA)──────────▶ │ │ { clientEphPub(X25519), clientNonce, aeadOffer, │ │ deviceAuthProof } // proof = account-derived, binds device to account (INV3) │ │ │ ◀────────────────(relay DATA)──────── host_hello ──────│ │ { hostEphPub(X25519), hostNonce, aeadChoice, │ │ enrollFpr, sig } // sig = Ed25519(agent_pubkey) over transcript │ │ │ verify sig with pinned enrollFpr (§4.2); mismatch ⇒ ABORT (no MITM) │ sharedSecret = X25519(clientEphPriv, hostEphPub) │ = X25519(hostEphPriv, clientEphPub) │ master = HKDF(sharedSecret, salt=clientNonce||hostNonce, info="relay-e2e/v1") │ keys = DirectionalKeys{ c2h, h2c } = HKDF-Expand(master, "relay-e2e/v1/c2h" | "…/h2c") │ ── both sides hold BOTH direction subkeys; relay never did ── │ ``` **Direction-split (FIX 2 — anti-reflection / anti-nonce-reuse):** the master secret is split into two disjoint AEAD subkeys — `c2h` (client→host) and `h2c` (host→client) — so a deterministic per-`seq` nonce never produces the same `(key, nonce)` in both directions and a relay cannot reflect a c2h frame back as h2c. Live frames use these ephemeral subkeys; replay/preview frames use the recoverable `K_content` below. **This block supersedes the original single-`sessionKey: CryptoKey` shape (FIX 2).** P4 converged on an **`AeadKey`** opaque wrapper (not `CryptoKey`) with **synchronous** `sealFrame`/`openFrame` (audited `@noble/ciphers`, isomorphic, and XChaCha which WebCrypto lacks), `aad = directionLabel‖seq`, and direction-split `DirectionalKeys`. P2 T15 and P6 T8/T9 cite THIS surface verbatim — no local redefinition. ```ts export type AeadAlg = 'aes-256-gcm' | 'xchacha20-poly1305' export type AeadKey = { readonly __aeadKey: unique symbol } // opaque wrapper over a 32-byte key (NOT CryptoKey) export type SessionRole = 'client' | 'host' export interface ClientHello { readonly clientEphPub: Uint8Array; readonly clientNonce: Uint8Array readonly aeadOffer: readonly AeadAlg[] readonly deviceAuthProof: string // per-handshake, bound to clientEphPub‖clientNonce (FIX 6b); P5 mints, P4 shape-validates } export interface HostHello { readonly hostEphPub: Uint8Array; readonly hostNonce: Uint8Array readonly aeadChoice: AeadAlg; readonly enrollFpr: string; readonly sig: Uint8Array } // Direction-split subkeys (FIX 2). Client seals with c2h & opens with h2c; host seals with h2c & opens with c2h. export interface DirectionalKeys { readonly c2h: AeadKey; readonly h2c: AeadKey } export const HKDF_INFO = 'relay-e2e/v1' as const export const HKDF_INFO_C2H = 'relay-e2e/v1/c2h' as const export const HKDF_INFO_H2C = 'relay-e2e/v1/h2c' as const // Encrypted frame envelope carried inside every §4.1 DATA payload once E2E is live: export interface E2EEnvelope { readonly seq: bigint // strictly monotonic per direction (anti-replay, INV13) readonly nonce: Uint8Array // DETERMINISTIC f(seq), 12B GCM / 24B XChaCha (no random branch — see P4 §T9) readonly ciphertext: Uint8Array // AEAD(subkey, plaintext, aad = directionLabel‖seq) readonly tag: Uint8Array // AEAD auth tag; failure ⇒ drop + tear down } // SYNCHRONOUS single-key primitives (key: AeadKey, NOT CryptoKey). aad = directionLabel‖seq (u64 BE). export function sealFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope export function openFrame(key: AeadKey, env: E2EEnvelope, expectedSeq: bigint): Uint8Array // Stateful directional wrapper the agent/browser use over a stream (holds BOTH subkeys + role + seq guards): export interface E2ESession { readonly role: SessionRole seal(plaintext: Uint8Array): Uint8Array // write subkey → next send seq → sealFrame → encoded DATA payload open(dataPayload: Uint8Array): Uint8Array // read subkey → decode → SequenceGuard.accept → openFrame rederive(next: HandshakeResult): void // re-key on refresh/reconnect; installs new DirectionalKeys; resets seq guards } export interface HandshakeResult { readonly keys: DirectionalKeys; readonly aead: AeadAlg; readonly transcript: Uint8Array } export interface ClientHandshake { start(): Promise onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise // agentPubkey from P3 registry, independent TLS channel } export function createE2ESession(role: SessionRole, result: HandshakeResult): E2ESession // Handshake factory P6 calls; ctx carries the P5 device-proof provider, TOFU pin store, and host-scoped secret (§4.5): export function buildClientHandshake(ctx: AuthorizedDeviceContext, hostId: string, aeadOffer: readonly AeadAlg[]): ClientHandshake export interface AuthorizedDeviceContext { readonly deviceAuthProofProvider: DeviceAuthProofProvider // P5 — mints per-handshake proof bound to clientEphPub‖clientNonce (FIX 6b) readonly pinStore: DevicePinStore // P6 — TOFU/pin over recomputed fingerprints readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged, NEVER sent to relay } export interface DeviceAuthProofProvider { proofFor(hostId: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }): Promise } // Recoverable replay content-key (FIX 3) — live frames use ephemeral DirectionalKeys; ring-buffer/preview // frames use K_content, recoverable after reload so "refresh and the session survives" holds under E2E: export interface ReplayKeyParams { readonly hostContentSecret: Uint8Array // host-scoped, delivered wrapped-to-agent at §4.5 enrollment (NOT a raw account secret) readonly sessionId: string // base-app sessionId — salt; K_content is per-session, per-host readonly alg: AeadAlg } export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const export function deriveContentKey(p: ReplayKeyParams): AeadKey // HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO) export function sealReplayFrame(k: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope // agent seals replay-bound output (P2) export function openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array // browser client-side replay/preview decrypt (P6) ``` **Session-key lifecycle**: ephemeral direction subkeys are derived on attach and **re-derived on refresh/reconnect** (`E2ESession.rederive`, forward-secret). Ring-buffer replay of *ciphertext* survives a reload because it is sealed under the **recoverable** `K_content` (`deriveContentKey`), not the ephemeral subkeys (EXPLORE §4c). **Multi-device**: each authorized device runs its own handshake gated by an account-derived `deviceAuthProof` and re-derives the identical `K_content` over an **authenticated channel — NOT a plaintext QR** (INV3). `DevicePinStore` / `SequenceGuard` are P4-internal helper types (not §4 wire). ### 4.5 Pairing protocol (P3 issues, P2 redeems) ``` 1. ISSUE (human authenticated, P5) → control plane mints single-use short-TTL pairing code; stores { code_hash, account_id, expires_at } (raw code never stored, INV5) 2. GENERATE (host, P2) `npx web-terminal-agent pair ABCD-1234` → agent generates Ed25519 keypair LOCALLY (private key stays, INV4) 3. REDEEM (host → control plane, TLS) POST /enroll { code, agentPubkey, csr } 4. BIND (control plane, P3) verify code unredeemed+unexpired → bind host_id ↔ account_id, store agent_pubkey + enroll_fpr, assign stable subdomain, sign short-lived mTLS cert (INV14), MINT + WRAP hostContentSecret (FIX 3), mark code redeemed (single-use) 5. RETURN EnrollResult { host_id, subdomain, cert, caChain, hostContentSecret } → agent installs as service; unwraps + stores hostContentSecret in its keystore ``` **Host-scoped content secret (FIX 3 — recoverable replay key delivery).** `EnrollResult` gains `hostContentSecret`, the input to §4.4 `deriveContentKey` (`ReplayKeyParams`). **Single owner: P3 mints and wraps it at BIND** — a per-host random secret **sealed/wrapped to the host's own enrolled Ed25519 identity** (`agent_pubkey`), so it is host-scoped (NOT a raw account-wide secret) and the control plane can invalidate **that specific wrap** on host/device revocation (INV12) without an account-wide rotation. P2 receives it in `EnrollResult`, unwraps with its enrollment private key (never leaves the host, INV4), and holds it for `deriveContentKey`. Authorized browser devices obtain the same `hostContentSecret` via P5 (unwrapped only after auth/step-up) → re-derive the identical `K_content` → decrypt replayed ciphertext after a reload. ```ts // FROZEN in relay-contracts (§4.5). hostContentSecret is wrapped-to-agent-identity bytes; P2 unwraps locally. export interface EnrollResult { readonly hostId: string; readonly subdomain: string readonly cert: string; readonly caChain: string readonly hostContentSecret: Uint8Array // FIX 3 — wrapped to agent_pubkey by P3 at BIND; feeds §4.4 deriveContentKey } export interface PairingCodeRecord { readonly codeHash: string; readonly accountId: string readonly expiresAt: string; readonly redeemedAt: string | null } ``` Redemption is **atomic + single-use**: a `redeemed_at` compare-and-set prevents double-spend. Steady state after pairing: agent dials `wss://relay/agent`, proves key possession via mTLS, relay writes `route:{host_id}` in Redis with heartbeat TTL (INV7). Reconnect uses the **same 1/2/4…cap-30s** backoff the base app already ships (EXPLORE §3). --- ## 5. Per-plan charters (scope fences — no overlap, no gaps) **P1 — [`PLAN_RELAY_TRANSPORT.md`](./PLAN_RELAY_TRANSPORT.md) · Tunnel & Mux Protocol + Relay Data Plane.** Owns the native WS multiplexing protocol (§4.1 frame format, stream open/close lifecycle, per-stream credit-based flow-control/backpressure, 15s heartbeat, reconnection) between host-agent and the stateless relay data plane; TLS termination; per-tenant **subdomain** routing (route by authenticated session, not Host header, INV1); **opaque CIPHERTEXT forwarding** (never parses terminal data, INV2/INV11); and the **v0.8 frp-based MVP scaffold as an explicit stepping-stone** toward the native mux. Enforces INV1, INV2, INV5, INV6, INV7, INV11, INV15. Does **not** own accounts, crypto keys, or UI. **P2 — [`PLAN_RELAY_AGENT.md`](./PLAN_RELAY_AGENT.md) · Host Agent (`agent/`).** Owns enrollment/pairing redemption (§4.5: locally-generated Ed25519 keypair + single-use pairing-code, private key never leaves host, INV4), outbound dial + mTLS with short-lived certs (INV14), registration, holding the §4.1 mux tunnel, forwarding logical streams to the **UNCHANGED** web-terminal at `127.0.0.1:3000`, the agent-side E2E endpoint (§4.4 `host_hello`/`sealFrame`), reconnection/backoff (reuse the 1/2/4…cap-30s policy), and distribution (`npx …-agent pair` → static binary; launchd/systemd service install). Enforces INV2, INV4, INV11, INV12, INV14. Does **not** own the frame spec (imports §4.1 from P1) or account issuance. **P3 — [`PLAN_RELAY_CONTROLPLANE.md`](./PLAN_RELAY_CONTROLPLANE.md) · Control Plane & Accounts.** Owns the account & host registries (§4.2 Postgres, **immutable** records, INV8), pairing-code issuance+redemption (§4.5), per-tenant subdomain assignment, the live routing table (Redis `route:{host_id}` heartbeat-TTL; Postgres = ownership source of truth, INV7), provisioning/deprovisioning APIs, billing-metering hooks (paired hosts + concurrent viewers), the mTLS cert authority (signs only pubkeys in the host registry, INV14), and graceful relay-node **drain** coordination (§4.1 GOAWAY). Enforces INV1, INV3, INV4, INV5, INV6, INV8, INV10, INV12. Does **not** own the byte path or crypto frame format. **P4 — [`PLAN_RELAY_E2E.md`](./PLAN_RELAY_E2E.md) · End-to-End Encryption (`relay-e2e/`).** Owns browser↔agent E2E: authenticated X25519 ECDH **through** the relay (§4.4; relay forwards, never derives), host-key pinning/TOFU bound at enrollment (browser verifies `enroll_fpr` from §4.2), the AEAD frame envelope (§4.4 `E2EEnvelope`, AES-256-GCM / XChaCha20-Poly1305, **per-message nonce + monotonic sequence** for anti-replay/injection, INV13), session-key lifecycle + re-derivation on refresh, and multi-device key distribution over an **authenticated (account-derived) channel — not a plaintext QR**. Documents the relay-sees-only-ciphertext guarantee and the features it kills (server previews → client-side, P6). Enforces INV2, INV5, INV13. Does **not** own transport framing or human auth. **P5 — [`PLAN_RELAY_AUTH_ISOLATION.md`](./PLAN_RELAY_AUTH_ISOLATION.md) · Auth & Tenant Isolation.** Owns human auth (Passkey/WebAuthn primary, TOTP fallback, **never SMS**, OIDC SSO for teams, **step-up before opening a session**); agent auth (per-host mTLS + SPIFFE-style short-lived auto-rotating certs, DB stores only public keys, INV4/INV14); **capability tokens** on the WS upgrade (§4.3 host+rights scope, INV15); the **deny-by-default tenant-authz model** (`account_id` from the authenticated principal, NEVER client-supplied, INV3) enforced on **every connect AND reattach** (INV6); the **hard cross-tenant isolation invariant** (INV1) + a **permanent CI tripwire test** (device A → host B = 403); per-tenant rate-limits/quotas; global + per-host **revocation** that kills live tunnels in seconds (INV12); and an **immutable audit log with zero payload logging** + cross-tenant-crossing alerts (INV10). Enforces INV1, INV3, INV4, INV6, INV10, INV12, INV14, INV15. Does **not** own the byte path. **P6 — [`PLAN_RELAY_FRONTEND.md`](./PLAN_RELAY_FRONTEND.md) · Browser / Frontend (`relay-web/`).** Owns the login UI (passkey), connecting through the relay served from the tenant **subdomain** (same-origin, scheme-following `wss:`, M6), browser-side E2E via Web Crypto (SubtleCrypto, consuming §4.4 `relay-e2e/`), **CLIENT-SIDE preview rendering** (server previews die under E2E — the authorized, key-holding browser decrypts + renders a read-only xterm), and the pairing/onboarding + dashboard (host ●online status, add-machine flow). **The core xterm byte path in `public/` stays untouched.** Enforces INV3, INV15 (client side), and consumes INV2/INV13 guarantees. Does **not** own server-side routing, accounts, or crypto primitives. --- ## 6. Single-owner decisions (reconciliation — FIX 6) Two capabilities were implemented in more than one plan; the DECISION now has exactly one owner and the others delegate. Consumer plans cite these verbatim — no re-implementation. **6a. WS-upgrade authorization — sole owner P5 (`onUpgrade`/`onReattach`).** The full authorization decision (Origin/CSWSH retained + capability token verify + DPoP proof-of-possession + single-use `jti` burn + pre-auth throttle + step-up gate + per-tenant rate + deny-by-default cross-tenant gate + audit) lives **only** in P5 `relay-auth/src/enforce/onUpgrade.ts` / `onReattach.ts`. **P1 T8 `authorizeUpgrade` is a THIN ADAPTER that delegates to P5** — it parses the upgrade (Origin, subprotocol token via FIX 5, subdomain), builds the `UpgradeContext`, calls P5, and maps the `AuthzOutcome` to a §4.1 `MuxOpen` (or a 401/403 close). P1 T8 must supply P5's `UpgradeContext` fields — including `dpop: DpopContext` and `activeSessionCount` — and holds **no** independent authz logic. P5 is the injected `CapabilityVerifier`/authorizer; P1 owns only the byte splice. **6b. `deviceAuthProof` — sole issuer/verifier P5 (`relay-auth`).** P5 `capability/device-proof.ts` owns minting (`signDeviceAuthProof`) and verification (`verifyDeviceAuthProof`), binding the proof to **`{clientEphPub, clientNonce}`** (per §4.4 `ClientHello`) so a captured proof cannot be replayed into a different handshake. **P4 consumes it as an INJECTED dependency** — the §4.4 `DeviceAuthProofProvider` (client side) and a `verifyDeviceProof(proof, { clientEphPub, clientNonce })` host-side dep — and never imports crypto identity from P5 directly. **P2 obtains the verifier via P4's host-handshake wiring** (`createHostHandshake({ verifyDeviceProof, … })`), **NOT** by importing from `relay-e2e` (the old P2 T15 `import … from 'relay-e2e'` was wrong). Binding param `{ clientEphPub, clientNonce }` is unified across P2/P4/P5. `deviceAuthProof` issuance/verification therefore has exactly one home (P5); P4 carries + shape- validates it; P2 receives the verifier through P4.