# P1 — Tunnel & Mux Protocol + Relay Data Plane — Implementation Plan > **Plan of record for the byte hot-loop.** Owns the native WS multiplexing protocol > (frame codec, stream lifecycle, per-stream credit flow-control, 15s heartbeat, reconnection, > graceful drain) between the host-agent and the **stateless** relay data plane; TLS termination; > per-tenant **subdomain** routing (route by *authenticated session*, never the Host header); > **opaque CIPHERTEXT forwarding** (parses zero terminal semantics); and the **v0.8 frp-based MVP > scaffold** as an explicit stepping-stone toward the native mux. > > **Authoritative inputs** (do not reopen): [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) > §0 LOCKED DECISIONS, §3 (control/data split, multiplexing, routing, drain), §5 (MVP shortcuts); > [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) §3 (security invariants), §4 (**FROZEN SHARED > CONTRACTS** — cited verbatim below, never redefined), §5 (P1 charter). > Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md): > stable task IDs in dependency waves, disjoint `Owns:` per task, function-signature-level 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 & non-scope **In scope (this plan, nothing else):** - The **native WS mux** substrate: binary frame codec for the §4.1 frame format, per-stream state machine (`OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`), **credit-based per-stream flow-control / backpressure** (WINDOW_UPDATE), 15s **PING/PONG heartbeat**, and **GOAWAY** graceful drain. - The **stateless relay data-plane node**: TLS termination for inbound browser `wss://`, holding outbound agent mux tunnels, the WS-upgrade authorization edge (Origin **retained** + capability token **required**), **per-tenant subdomain routing** resolved from the authenticated principal, and the **opaque byte splice** browser-WS ↔ mux-stream ↔ (agent's) `ws://127.0.0.1:3000`. - The **v0.8 frp scaffold**: `frps` config (wildcard vhost/subdomain routing) as the transport stepping-stone, contract-compatible with the native mux so the v0.9 swap reshapes nothing. **Explicitly NOT in scope (owned by other plans — depend on their interfaces, never their files):** - Accounts / host & routing registries / pairing issuance / cert authority / metering → **P3 CONTROL PLANE**. - Ed25519 enrollment, agent dial-out client, `frpc` wrap, loopback forwarder, `npx …-agent pair` → **P2 AGENT**. - Capability-token issuance/signing, mTLS cert verify, deny-by-default authz model, CI cross-tenant tripwire, revocation, audit log, rate-limits → **P5 AUTH & ISOLATION**. (P1 *enforces* these at the edge via injected interfaces; it does not *own* their logic.) - E2E handshake + AEAD envelope → **P4 E2E** (rides P1 DATA frames as opaque ciphertext). - Login / dashboard / browser-side crypto / client-side preview → **P6 FRONTEND**. **Base app unchanged:** `src/`, `public/`, `protocol.ts`, the session model — **byte-for-byte untouched**. P1 terminates at `ws://127.0.0.1:3000` where the agent (P2) is *just another local WS client*. The single base-app touch-point (`ALLOWED_ORIGINS` gaining the subdomain) is applied at **agent-install** time (P2), not by P1. --- ## 1. Security invariants this plan enforces Per INDEX §3, P1 is a **primary owner** of **INV1, INV2, INV5, INV6, INV7, INV11, INV15** and contributes to **INV9, INV12, INV13** (transport carries them). Mapping to enforcement points: | INV | Where P1 enforces it | Task | |---|---|---| | **INV1** cross-tenant isolation — A can never reach B | `authorizeUpgrade` (thin adapter, FIX 6a) resolves the subdomain → `requestedHostId` and **delegates the `token.host == hostId` gate to P5's `onUpgrade`/`onReattach`**; Host header is a hint only, never the authority. No code path resolves a host by raw addr/port/user hostname. | T8 (→P5), T9, T13 | | **INV2** relay handles only ciphertext | DATA payloads are copied **opaque**; the splice never decodes them; relay imports **no** xterm/ANSI parser. | T2, T10, T13 | | **INV5** no plaintext/secret at rest | Data plane holds **no** durable buffers; per-connection allocation only, **no global mutable buffer pool**; nothing written to disk/Redis by P1 except metadata via P3. | T10, T13 | | **INV6** deny-by-default authz on connect AND reattach | The deny-by-default **decision** is P5's (`onUpgrade`/`onReattach`, FIX 6a); `authorizeUpgrade` is a thin adapter that opens a stream **only** on P5's `{ok:true}` and passes every 401/403 through verbatim. Reattach flows the same P5 gate via `onReattach` (base-app `sessionId` re-authorized as a claim, not trusted). | T8 (→P5) | | **INV7** stateless data plane | Node holds only in-RAM tunnel/stream maps; a crash loses nothing (agent reconnects + re-registers via P3). No DB-of-record on the node. | T9, T10, T11, T13 | | **INV11** no terminal parsing in relay/agent | Static tripwire: relay/mux code has zero dependency on any terminal/ANSI parser; frames pass through opaque. | T2, T10, T13 | | **INV15** capability token required on WS upgrade | The adapter extracts the token via the **frozen §4.3 FIX-5 subprotocol/cookie carriers only** (never the query string) and forwards it to P5, which enforces Origin/CSWSH + token validity + `aud`/`rights`. The relay echoes only `APP_SUBPROTOCOL` (`term.relay.v1`), never the token entry. | T8 (→P5) | | **INV9** secrets validated at startup, never logged | `loadDataPlaneConfig` fails fast on missing TLS/CA/secret material; no secret ever logged. **Data-plane edge access logs MUST NOT include the raw upgrade `req.url`, the `Authorization` header, or the `Sec-WebSocket-Protocol` header value** (any of which can carry the capability token); log only `{subdomain, hostId, jti, decision, ts}`. | T1, T8, T10 | | **INV12** fast revocation | **T14 subscribes to the frozen §4.2 `relay:revocations` bus** (FIX 4) and maps a `KillSignal` → immediate §4.1 teardown: host/account scope → `drainHost(reason=revoked)` RST+close (grace forced to 0, ≤ `REVOCATION_PUSH_BUDGET_MS`), global → node-wide GOAWAY+RST. Three-tier levers: `closeStream(hostId, {jti})` (single-device, see OQ6), `closeTunnel`/`drainHost(revoked)` (whole host, immediate), `operatorDrain`/`shutdown` (grace only for clean migration). P5 **publishes**; every P1 node **subscribes** (P1 never imports P5). | T6, T9, T10, T11, **T14** | | **INV13** anti-replay/injection | P1 preserves per-message ordering and never reorders/dedups DATA (so P4's `seq`/nonce guarantees hold end-to-end); illegal frame transitions RST the stream. | T3, T6 | --- ## 2. Files & ownership (new package `term-relay/`, data-plane subtree only) P1 and P3 co-inhabit `term-relay/` but own **disjoint subtrees** (INDEX §0). P1 owns `term-relay/mux/` (the reusable protocol library, also imported by P2), `term-relay/data-plane/`, and `term-relay/frp-scaffold/`. **Types** for §4.1 live in the INDEX-frozen `relay-contracts/`; P1 imports them read-only and never redefines them. Files ≤ 400 lines typical, 800 hard max (`coding-style.md`). | File | Action | Task | Phase | |---|---|---|---| | `term-relay/package.json`, `term-relay/tsconfig.json`, `term-relay/vitest.config.ts` | create — package skeleton (P1 seeds; P3 extends its own subtree) | T1 | v0.8 | | `term-relay/data-plane/config.ts` | create — env → `DataPlaneConfig` (Zod, fail-fast, INV9) | T1 | v0.8 | | `term-relay/mux/frame-codec.ts` | create — `encodeMuxFrame`/`decodeMuxFrame`/`decodeHeader` (§4.1) | T2 | spec v0.8 / live v0.9 | | `term-relay/mux/type-bytes.ts` | create — `MuxFrameType ⇄ byte` + `flags` bit maps (§4.1) | T2 | v0.9 | | `term-relay/mux/frame-guards.ts` | create — Zod boundary validation of OPEN/WINDOW_UPDATE/GOAWAY payloads (CBOR decode + `relay-contracts` schema) | T2 | v0.9 | | `term-relay/mux/stream.ts` | create — pure stream state machine (`nextStreamState`, transitions) | T3 | v0.9 | | `term-relay/mux/flow-control.ts` | create — credit-based `FlowController` (per-stream + link) | T4 | v0.9 | | `term-relay/mux/heartbeat.ts` | create — 15s PING/PONG liveness (injectable timer) | T5 | v0.9 | | `term-relay/mux/mux-session.ts` | create — multiplexer over one WS: demux, streams, flow, heartbeat, GOAWAY | T6 | v0.9 | | `term-relay/data-plane/subdomain-router.ts` | create — `extractSubdomain` + `RouteResolver` interface (impl = P3) | T7 | v0.9 | | `term-relay/data-plane/upgrade.ts` | create — `authorizeUpgrade` edge (Origin + capability token, INV1/6/15) | T8 | v0.9 | | `term-relay/data-plane/agent-listener.ts` | create — accept agent dial-out, mTLS-verify (P5 iface), build `MuxSession`, register route (P3 iface) | T9 | v0.9 | | `term-relay/data-plane/relay-node.ts` | create — stateless node: browser upgrade → openStream → **opaque splice** | T10 | v0.9 | | `term-relay/data-plane/drain.ts` | create — GOAWAY graceful drain + `closeTunnel` for revocation (INV7/INV12) | T11 | v0.9 | | `term-relay/data-plane/revocation-subscriber.ts` | create — subscribe `relay:revocations` (§4.2, FIX 4) → `KillSignal`→§4.1 CLOSE+RST/GOAWAY via T10/T11 (INV12) | T14 | v0.9 | | `term-relay/frp-scaffold/frps.toml` | create — wildcard-subdomain vhost config (v0.8 substrate) | T12 | v0.8 | | `term-relay/frp-scaffold/README.md` | create — VPS + wildcard DNS/TLS + `frps` runbook; retirement note | T12 | v0.8 | | `term-relay/frp-scaffold/plugin-hook.ts` | create — thin frp server-plugin HTTP shim → delegates authz to P3 (no tenancy logic here) | T12 | v0.8 | | `term-relay/test/**` (mux + data-plane + tripwire) | create — vitest unit/integration + INV tripwires | T2–T14 | per task | | `relay-contracts/**` | **read-only** (INDEX-frozen §4.1 types) — P1 imports, never edits | — | — | **Freeze rule:** T2's codec + T3–T5 pure modules export a stable surface consumed by T6, then T6 by T9/T10; freeze each layer before the next consumes it (same discipline as PLAN_VOICE_COMMANDS §2). --- ## 3. Dependency waves ``` W0 package skeleton + config T1 │ ▼ W1 pure protocol leaves (all parallel) T2 codec · T3 stream · T4 flow · T5 heartbeat │ (also parallel, separate lane) T12 frp scaffold ── v0.8, no dep on W1 ▼ W2 mux session (assembles T2–T5) T6 │ ▼ W3 data-plane wiring (parallel where file-disjoint) T7 router · T8 upgrade(→P5) · T9 agent-listener │ T10 relay-node (dep T6,T8,T9) · T11 drain (dep T6,T9) │ T14 revocation-subscriber (dep T10,T11) ── FIX 4 ▼ W4 invariant tripwires + integration T13 ``` **Cross-plan dependencies (interfaces, not files):** - **T8** (FIX 6a) **delegates the authorization decision to P5** → injected `Authorizer` (`onUpgrade`/`onReattach`, §6a) consuming P5's frozen `UpgradeContext`/`AuthzOutcome`/`DpopContext`. T8 no longer imports a `CapabilityVerifier`/`OriginCheck` — P5 owns Origin/CSWSH + token verify + DPoP + rate + burn + audit. - **T7/T8** depend on **P3**'s host registry read → injected `RouteResolver` (subdomain → `{hostId, accountId}`); T8 uses it only to name `requestedHostId` for the `UpgradeContext` (P5 makes the cross-tenant call). - **T9** depends on **P5**'s mTLS peer-cert verify → injected `MtlsVerifier`; and **P3**'s Redis routing table write → injected `RouteRegistrar` (`route:{host_id}` heartbeat-TTL, §4.2). - **T10** DATA path is contract-shared with **P2** (agent side of §4.1) and carries **P4**'s `E2EEnvelope` opaquely (§4.4) — P1 needs no P4 code (INV2). - **T11** GOAWAY drain is invoked by **P3** node-drain coordination (`operatorDrain`/`shutdown`, grace honored); whole-host `closeTunnel`/`drainHost(reason=revoked)` (immediate, grace forced to 0) and single-device `closeStream(hostId, {jti})` are the levers. - **T14** (FIX 4) **subscribes to the frozen §4.2 `relay:revocations` bus** — **P3/P5 publish** a `KillSignal`, every P1 node consumes it and drives T10/T11 teardown. The `KillSignal`/`RevocationScope`/channel constants live in `relay-contracts` (promoted out of `relay-auth`) so **P1 never imports P5** (DAG stays acyclic). P3 OQ4's "control→node drain channel" reconciles to this exact channel. In **v0.8**, the frp scaffold (T12) lets P2/P3 integrate against a real transport while T2–T14 are built; the v0.9 native-mux swap is contract-compatible by construction (both speak §4.1 routing + opaque byte forwarding), so nothing downstream reshapes. --- ## 4. Frozen shared contracts consumed (cited verbatim from INDEX §4 — NOT redefined) P1 **imports** these from `relay-contracts/`; it must never re-declare them locally. **§4.1 Mux frame (15-byte header)** — `version(1B)=0x01 · type(1B) · flags(1B: bit0 FIN, bit1 RST) · streamId(4B uint32, 0=link-level) · payloadLen(8B uint64 ≤ maxFrameBytes) · payload(var)`. Types: `0x01 OPEN · 0x02 DATA · 0x03 CLOSE · 0x04 PING · 0x05 PONG · 0x06 WINDOW_UPDATE · 0x07 GOAWAY`. Lifecycle `OPEN → (DATA | WINDOW_UPDATE)* → CLOSE`; illegal transition ⇒ **RST that stream, never the tunnel**; `streamId` allocated by the **relay**, monotonic per tunnel, **never reused**. ```ts // from relay-contracts (§4.1) — imported, frozen: 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; readonly payloadLen: number } export interface MuxOpen { readonly streamId: number; readonly subdomain: string; readonly requestPath: string readonly originHeader: string; readonly remoteAddrHash: string; readonly capabilityTokenRef: string } export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array } ``` - **§4.3 CapabilityToken** — the token **shape** T8 references (but does **not** itself verify — verification is delegated to P5, FIX 6a): `sub` (principal, from authenticated session, **never client**), `aud` (subdomain, Host-confusion guard, INV1), `host` (exact `host_id`, single host, never wildcard), `rights: ('attach'|'manage'|'kill')[]`, `iat`/`exp` (short TTL), `jti` (revocation lookup). P1 extracts the raw token at the edge and hands it to P5's `onUpgrade`/`onReattach`, which calls `verifyCapabilityToken(raw, expectedAud, now)` internally — T8 never calls it directly. - **§4.3 WS-upgrade subprotocol wire format (FIX 5) — FROZEN, cited verbatim; T8's `extractCapabilityToken` MUST use these constants, never local literals**: ```ts // from relay-contracts (§4.3) — imported, frozen. 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):** read the token from the `term.token.` entry via `extractTokenFromSubprotocols`, strip it before handing to P5, and the accepted/echoed `Sec-WebSocket-Protocol` in the handshake response **MUST be `APP_SUBPROTOCOL` (`'term.relay.v1'`), NEVER the token entry**. Query-string token transport is forbidden. (The earlier local literals `'term.relay.v1'`/`'term.token.'` in this plan are now these frozen constants — no local redefinition.) - **§4.2 control→data-plane revocation teardown (FIX 4) — FROZEN, cited verbatim; consumed by T14**: ```ts // from relay-contracts (§4.2) — imported, frozen. P3/P5 PUBLISH; every P1 node SUBSCRIBES. 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 } export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const export const REVOCATION_PUSH_BUDGET_MS = 2000 as const ``` P1 **subscribes** to `RELAY_REVOCATIONS_CHANNEL` and, per affected stream, injects a §4.1 `CLOSE`+`flags.RST` (host/account scope) or a link-level `GOAWAY` (global scope) using the **existing frozen §4.1 wire — no new frame type** — within `REVOCATION_PUSH_BUDGET_MS`. P5 stays socket-free (publishes only). - **§4.2 data model** — consumed read-only via `RouteResolver`/`RouteRegistrar`: `hosts.host_id` (unguessable UUIDv4, never recycled), `hosts.subdomain` (stable), `route:{host_id} → {relayNodeId, updatedAt}` (Redis heartbeat-TTL, INV7). P1 **reads** `hostId`/`accountId`; it **writes** only the routing entry through P3's registrar. - **§4.4 E2EEnvelope** — carried **inside** each DATA payload once E2E ships; P1 forwards it opaque and **never** derives keys or inspects `seq`/`nonce`/`ciphertext` (INV2). No P1 change at v0.10. --- ## 5. Task specifications ### W0 #### T1 · Package skeleton + data-plane config `[ ]` — v0.8 - **Owns**: `term-relay/package.json`, `term-relay/tsconfig.json`, `term-relay/vitest.config.ts`, `term-relay/data-plane/config.ts`, `term-relay/test/config.test.ts` - **Depends**: `relay-contracts/` exists (INDEX W0). · **Parallel-safe**: with T12. - **Contract**: ```ts export interface DataPlaneConfig { readonly baseDomain: string // e.g. 'term.example.com' — for extractSubdomain readonly bindHost: string // default '0.0.0.0' readonly bindPort: number // browser wss listener readonly agentBindPort: number // agent dial-out listener readonly tlsCertPath: string; readonly tlsKeyPath: string // relay's OWN server cert (browser-facing wss:) readonly agentCaCertPath: string // CA/trust-anchor that MUST sign the agent's client cert on agentBindPort (mTLS root) readonly agentCaChainPath: string // intermediate chain for agent-cert validation (rotation-safe; may equal agentCaCertPath if single-tier) readonly relayNodeId: string // stable node id written into route:{host_id} readonly maxFrameBytes: number // §4.1 payloadLen ceiling; default 1 MiB readonly initialWindowBytes: number // §4.1 credit initial window; default 256 KiB readonly heartbeatIntervalMs: number // default 15_000 readonly routeTtlMs: number // Redis route TTL; default 45_000 } export function loadDataPlaneConfig(env: NodeJS.ProcessEnv): DataPlaneConfig ``` - **Steps (TDD)**: 1. **RED** `config.test.ts`: missing `TLS_CERT_PATH`/`TLS_KEY_PATH`/`BASE_DOMAIN` → `loadDataPlaneConfig` throws with a clear message (fail-fast, INV9); **missing `AGENT_CA_CERT_PATH` (the mTLS trust-anchor) → throws** — the agent-facing listener MUST NOT start without a CA to validate client certs against (else "mTLS" silently degrades to accept-any-cert, Finding-4 footgun); non-numeric `MAX_FRAME_BYTES` → throws; happy path returns typed frozen object; defaults applied. 2. **GREEN** implement with a Zod schema (`z.coerce.number()` for ints, `z.string().min(1)` for paths). Return `Object.freeze(...)` (immutability). **Never log** cert/CA paths' contents; never log secrets. - **Security note (INV9)**: startup MUST fail if TLS server material, the `AGENT_CA_CERT_PATH` mTLS trust-anchor, or `BASE_DOMAIN` is absent; no secret value is ever emitted to logs. The CA-path presence check is the config-level guarantee that T9 can wire TLS-layer `requestCert: true`/`rejectUnauthorized: true` (no accept-any-cert path exists). - **Accept**: `npx vitest run config` green; `tsc --noEmit` clean. ### W1 — pure protocol leaves (all parallel, DOM-free, no `ws`/`pg`) #### T2 · Mux frame codec `[ ]` — spec written v0.8, becomes substrate v0.9 - **Owns**: `term-relay/mux/frame-codec.ts`, `term-relay/mux/type-bytes.ts`, `term-relay/mux/frame-guards.ts`, `term-relay/test/frame-codec.test.ts` - **Depends**: `relay-contracts` §4.1 types. · **Parallel-safe**: T3–T5, T12. - **Contract**: ```ts export const MUX_HEADER_BYTES = 15 export const MUX_VERSION = 1 as const export const TYPE_TO_BYTE: Readonly> // open→0x01 … goaway→0x07 export const BYTE_TO_TYPE: ReadonlyMap export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array export function decodeHeader(buf: Uint8Array): MuxFrameHeader // header-only peek (15B) export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array } // CBOR-encoded typed payloads, validated at the boundary (coding-style: validate untrusted data): 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: number): Uint8Array export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: number } ``` - **Steps (TDD)**: 1. **RED** `frame-codec.test.ts` (round-trip + adversarial): - round-trip each type; `encode∘decode === identity` for header fields incl. `fin`/`rst` bit packing. - `streamId=0` link-level frames decode with `streamId===0`. - `payloadLen` uses uint64 big-endian; a value > `Number.MAX_SAFE_INTEGER` → **throws** (safe-int guard, §4.1). - **negative**: truncated buffer (< 15B) → throws; `payloadLen` ≠ actual payload length → throws; unknown `type` byte (0x00, 0x08) → throws; `version ≠ 0x01` → throws; `payloadLen > maxFrameBytes` caller-check hook (codec exposes the raw length; the ceiling is enforced by T6 with config). - `decodeOpen` rejects a CBOR payload missing `subdomain`/`host`-scope fields (Zod) — **fuzz malformed CBOR → throws, never returns partial** (INV boundary validation). 2. **GREEN** implement with `DataView` for the fixed header (big-endian) and `cbor-x` (or a tiny CBOR) for typed payloads; Zod-validate decoded OPEN/WINDOW_UPDATE/GOAWAY. - **Security note (INV2/INV11)**: this module imports **no** terminal/ANSI parser; DATA payloads are handled as opaque `Uint8Array` and never inspected. `frame-guards.ts` validates *control* frames only (OPEN/WU/GOAWAY), never DATA content. - **Accept**: `npx vitest run frame-codec` green; static check (T13) confirms no xterm/ansi import. #### T3 · Stream state machine `[ ]` — v0.9 - **Owns**: `term-relay/mux/stream.ts`, `term-relay/test/stream.test.ts` - **Depends**: `relay-contracts` types. · **Parallel-safe**: T2/T4/T5. - **Contract** (pure reducer — no I/O, no mutation of inputs): ```ts export type StreamState = 'idle' | 'open' | 'halfClosedLocal' | 'halfClosedRemote' | 'closed' export type StreamTransition = { readonly next: StreamState } | { readonly illegal: true } export function initialStreamState(): StreamState // 'idle' export function nextStreamState( current: StreamState, type: MuxFrameType, fin: boolean, dir: 'inbound' | 'outbound' ): StreamTransition export function isTerminal(s: StreamState): boolean ``` - **Steps (TDD)**: 1. **RED** `stream.test.ts`: legal path `idle --OPEN--> open --DATA*--> open --CLOSE--> closed`; `DATA` with `fin` → half-closed in the sending direction; both-side FIN → `closed`. **Illegal (→ `{illegal:true}`, caller RSTs the stream):** DATA before OPEN; DATA after CLOSE; OPEN on an already-open streamId; any frame after `closed`. Direction matters (inbound FIN vs outbound FIN produce different half-closed states). 2. **GREEN** implement as a total function returning a **new** state (never mutate). - **Security note (INV13)**: illegal transitions are *rejected at the stream level* (RST one stream), never silently coerced — this is what lets P4's `seq` monotonicity mean something end-to-end. - **Accept**: `npx vitest run stream` green; function < 50 lines. #### T4 · Credit-based flow control `[ ]` — v0.9 - **Owns**: `term-relay/mux/flow-control.ts`, `term-relay/test/flow-control.test.ts` - **Depends**: none (pure). · **Parallel-safe**: T2/T3/T5. - **Contract**: ```ts export const DEFAULT_INITIAL_WINDOW = 256 * 1024 export const WINDOW_REPLENISH_THRESHOLD = 0.5 // emit WINDOW_UPDATE when half consumed export interface FlowController { registerStream(streamId: number, initialWindow: number): void releaseStream(streamId: number): void canSend(streamId: number, bytes: number): boolean // false ⇒ backpressure (sender blocks) consumeSendCredit(streamId: number, bytes: number): void // deduct on outbound DATA grantCredit(streamId: number, credit: number): void // apply inbound WINDOW_UPDATE creditFor(streamId: number): number onDelivered(streamId: number, bytes: number): number // returns credit to replenish (0 if < threshold) } export function createFlowController(): FlowController ``` - **Steps (TDD)**: 1. **RED** `flow-control.test.ts`: `canSend` true until credit exhausted, then false; `consumeSendCredit` past window is rejected by `canSend` (never negative credit); `grantCredit` unblocks; `onDelivered` returns a replenish amount only once the threshold is crossed, else 0; unknown streamId → `canSend` false (deny-by-default), `creditFor` 0. Per-stream isolation: exhausting stream A's window does **not** affect stream B (**heavy `vim`/`top` redraw can't starve another stream**, §4.1). 2. **GREEN** implement with a per-stream `Map`; immutable-style updates (replace the record). - **Security note**: unknown/released streams are deny-by-default (no credit) — prevents send-after-close write amplification. - **Accept**: `npx vitest run flow-control` green. #### T5 · Heartbeat / liveness `[ ]` — v0.9 - **Owns**: `term-relay/mux/heartbeat.ts`, `term-relay/test/heartbeat.test.ts` - **Depends**: none. · **Parallel-safe**: T2–T4. - **Contract** (timer injectable, per PLAN_VOICE_COMMANDS confirm-window pattern): ```ts export const HEARTBEAT_INTERVAL_MS = 15_000 // §4.1: PING every 15s export const HEARTBEAT_MISS_LIMIT = 3 // 3 missed PONGs ⇒ dead (~45s) export interface HeartbeatDeps { sendPing(token: Uint8Array): void onDead(): void now?: () => number schedule?: (fn: () => void, ms: number) => { cancel(): void } // default setInterval-backed } export interface Heartbeat { start(): void; stop(): void; onPong(token: Uint8Array): void } export function createHeartbeat(deps: HeartbeatDeps): Heartbeat ``` - **Steps (TDD)**: 1. **RED** with a fake scheduler: after `start`, a PING is sent every interval with a fresh 8-byte token; a matching `onPong` resets the miss counter; **3 intervals with no PONG → `onDead` fires exactly once** and no further PINGs; a PONG with a stale/unknown token does **not** reset the counter (prevents a spoofed/duplicated PONG from masking a dead link); `stop` cancels cleanly (no `onDead` after stop). 2. **GREEN** implement with the injected scheduler; token = random 8 bytes (crypto RNG). - **Security note (INV13-adjacent)**: PONG must echo the exact outstanding token; an unmatched token is ignored, so a replayed PONG can't keep a dead/hijacked tunnel "alive". - **Accept**: `npx vitest run heartbeat` green. ### W2 #### T6 · Mux session (multiplexer over one WS) `[ ]` — v0.9 - **Owns**: `term-relay/mux/mux-session.ts`, `term-relay/test/mux-session.test.ts` - **Depends**: T2, T3, T4, T5. · **Parallel-safe**: with T7, T12. - **Contract**: ```ts export interface MuxStreamHandle { readonly streamId: number writeData(payload: Uint8Array): boolean // false ⇒ backpressured (buffered until WINDOW_UPDATE) close(rst?: boolean): void } export interface MuxSessionDeps { role: 'relay' | 'agent' sendWire(frame: Uint8Array): void // write encoded frame to the underlying ws onOpen(open: MuxOpen, stream: MuxStreamHandle): void // relay: n/a (relay opens); agent: dial :3000 onData(streamId: number, payload: Uint8Array): void // OPAQUE bytes (INV2) — never parsed onClose(streamId: number, rst: boolean): void onDead(): void maxFrameBytes: number initialWindowBytes: number } export interface MuxSession { openStream(open: MuxOpen): MuxStreamHandle // relay-side: allocates monotonic streamId onWire(buf: Uint8Array): void // feed inbound bytes: decode → dispatch drain(lastStreamId: number, reason: number): void // send GOAWAY (T11) close(): void } export function createMuxSession(deps: MuxSessionDeps): MuxSession ``` - **Steps (TDD)**: 1. **RED** `mux-session.test.ts` (two `MuxSession`s wired mouth-to-mouth via each other's `sendWire`): - relay `openStream(open)` → agent's `onOpen` fires with a fresh monotonic `streamId`; `streamId`s never reused within the session. - `writeData` on relay → agent `onData` receives **byte-identical** payload; a known plaintext marker in the payload is delivered verbatim and **never** inspected by the session (INV2). - **flow control**: fill a stream's window → `writeData` returns `false`; a WINDOW_UPDATE from the peer resumes flushing buffered data; stream A backpressure does not block stream B. - **heartbeat**: no PONG for `HEARTBEAT_MISS_LIMIT` intervals → `onDead`. - **lifecycle enforcement**: an inbound DATA for an unknown/closed streamId → session emits a **CLOSE with RST for that stream only**, the tunnel stays up (T3 illegal transition). - **frame ceiling**: an inbound frame with `payloadLen > maxFrameBytes` → RST that stream (or GOAWAY if link-level), never OOM. 2. **GREEN** implement: incremental wire parser (buffer until a full 15B header + payload), `Map` with T3 state + T4 credit, T5 heartbeat on streamId 0, GOAWAY handling. - **Security note (INV2/INV5/INV11)**: DATA is copied opaque; buffers are **per-stream, per-session** (no global pool), freed on CLOSE — no cross-tenant buffer bleed possible. The session imports no terminal parser. - **Accept**: `npx vitest run mux-session` green; file ≤ 400 lines (extract helpers if larger). ### W3 — data-plane wiring #### T7 · Subdomain router `[ ]` — v0.9 - **Owns**: `term-relay/data-plane/subdomain-router.ts`, `term-relay/test/subdomain-router.test.ts` - **Depends**: T1 (config). · **Parallel-safe**: T8, T9, T11, T12. - **Contract**: ```ts // RouteResolver is IMPLEMENTED BY P3 (control plane); P1 owns only the interface it depends on. export interface ResolvedHost { readonly hostId: string; readonly accountId: string; readonly subdomain: string } export interface RouteResolver { resolveSubdomain(subdomain: string): Promise } export function extractSubdomain(hostHeader: string, baseDomain: string): string | null ``` - **Steps (TDD)**: 1. **RED**: `extractSubdomain('alice.term.example.com', 'term.example.com')` → `'alice'`; a bare `baseDomain` → `null`; a **wildcard/confusion attempt** `'alice.term.example.com.evil.com'` → `null`; multi-label subdomain `'a.b.term.example.com'` → `null` (reject nested; single-label tenant only, INV1 Host-confusion guard); case-insensitive host; trailing dot tolerated. 2. **GREEN** implement a strict suffix match returning the single leftmost label only. - **Security note (INV1)**: `extractSubdomain` is a **hint** for candidate lookup; it is **never** the authority — T8 requires the capability token's `aud` to equal this subdomain and `token.host` to equal the resolver's `hostId`. Nested/confusing Host headers resolve to `null` (reject). - **Accept**: `npx vitest run subdomain-router` green. #### T8 · Upgrade authorization edge — **THIN ADAPTER over P5** `[ ]` — v0.9 **(INV1/INV6/INV15 surface)** > **FIX 6a — single-owner authorization.** The authorization **decision** (Origin/CSWSH + capability-token > verify + DPoP proof-of-possession + single-use `jti` burn + pre-auth throttle + step-up + per-tenant rate + > deny-by-default cross-tenant gate + audit) has **exactly one owner: P5** (`relay-auth/src/enforce/onUpgrade.ts` > / `onReattach.ts`). **`authorizeUpgrade` holds NO independent authz logic** — it only (a) parses the upgrade > (subdomain + FIX-5 token extraction + remoteAddr hash), (b) resolves the subdomain → `requestedHostId` via P3's > `RouteResolver`, (c) builds P5's frozen `UpgradeContext` (incl. `dpop: DpopContext` + `activeSessionCount`), > (d) calls the injected P5 `Authorizer`, and (e) maps the returned `AuthzOutcome` → a §4.1 `MuxOpen` (or a > 401/403 close). Reattach flows the same gate via `onReattach` (base-app `sessionId` re-authorized as a claim). - **Owns**: `term-relay/data-plane/upgrade.ts`, `term-relay/test/upgrade.test.ts` - **Depends**: T2 (MuxOpen), T7 (resolver). **Cross-plan**: **P5 `Authorizer` = `onUpgrade`/`onReattach` + `UpgradeContext`/`AuthzOutcome`/`DpopContext` (§6a, imported read-only)**, P3 `RouteResolver`. · **Parallel-safe**: T9, T11. - **Contract** (all P5 types imported from `relay-auth`, never redefined here): ```ts import { APP_SUBPROTOCOL, extractTokenFromSubprotocols } from 'relay-contracts' // §4.3 FIX 5, frozen import type { UpgradeContext, AuthzOutcome, DpopContext } from 'relay-auth' // §6a, P5-owned, frozen // P5 is the sole authorizer. P1 injects it; P1 supplies the ctx, P5 makes the decision. export interface Authorizer { onUpgrade(ctx: UpgradeContext, now: number): Promise onReattach(ctx: UpgradeContext & { sessionId: string }, now: number): Promise } export interface UpgradeRequest { readonly host: string; readonly origin: string | undefined readonly url: string // requestPath ONLY (opaque passthrough) — NEVER a token source readonly subprotocols: readonly string[] // parsed Sec-WebSocket-Protocol values (FIX-5 token transport) readonly cookies: Readonly> // parsed Cookie header (fallback token transport) readonly remoteAddr: string readonly dpop: DpopContext // FIX 6a: proof-of-possession material P5 needs (passed through, not inspected) readonly activeSessionCount: number // FIX 6a: supplied for P5's per-tenant/step-up policy readonly sessionId?: string // present ⇒ reattach path (→ onReattach); absent ⇒ onUpgrade } export type UpgradeDecision = | { readonly ok: true; readonly open: MuxOpen; readonly hostId: string; readonly acceptedSubprotocol: string } | { readonly ok: false; readonly status: 401 | 403 } // Token transport is FIXED, not free-form (FIX 5). Extract ONLY from the two allowed carriers via the // frozen §4.3 helper, NEVER from req.url query. Returns the opaque token string, or null if absent. export function extractCapabilityToken(req: UpgradeRequest): { token: string; via: 'subprotocol' | 'cookie' } | null export function authorizeUpgrade(req: UpgradeRequest, deps: { authorizer: Authorizer; resolver: RouteResolver baseDomain: string; now: () => number; remoteAddrSalt: string requiredRight: CapabilityRight // 'attach' for a session upgrade }): Promise ``` - **Capability-token transport (FIX 5 — frozen §4.3, cited verbatim, INV15).** The browser's native `WebSocket` API cannot set arbitrary request headers, so the token rides one of exactly **two** carriers, in this order: 1. **`Sec-WebSocket-Protocol` subprotocol (PREFERRED).** The client opens `new WebSocket(url, [APP_SUBPROTOCOL, encodeTokenSubprotocol(rawToken)])` — i.e. `['term.relay.v1', 'term.token.' + base64url(token)]`. `extractCapabilityToken` reads the token via the frozen `extractTokenFromSubprotocols(req.subprotocols)` (strips `TOKEN_SUBPROTOCOL_PREFIX`, base64url-decodes). The relay **MUST echo `acceptedSubprotocol = APP_SUBPROTOCOL` (`'term.relay.v1'`)** in the handshake response — **never** the `term.token.` entry (echoing the token leaks the bearer secret). `UpgradeDecision.ok` therefore fixes `acceptedSubprotocol: 'term.relay.v1'` (non-nullable — the app subprotocol is always echoed). 2. **Short-lived `HttpOnly; Secure; SameSite=Strict` cookie (FALLBACK).** Set by a prior authenticated HTTPS call (P6), scoped to the tenant subdomain, read from `req.cookies`. `HttpOnly` keeps it out of JS/`Referer`; `Secure` keeps it off plaintext; `SameSite=Strict` complements the retained Origin/CSWSH check. - **FORBIDDEN: the query string.** The token is a bearer-equivalent secret; `authorizeUpgrade` and `extractCapabilityToken` MUST NOT read any token from `req.url`. `req.url` carries **only** `requestPath` (opaque `?join=` passthrough, never parsed for authz). Query-string tokens leak verbatim into reverse-proxy/CDN/frp access logs, browser history, and `Referer` headers. - **Adapter logic (NO local authz decision — parse, delegate, map; INV6 deny-by-default lives in P5)**: ``` subdomain = extractSubdomain(req.host, baseDomain); if !subdomain → 401 (unparseable Host, local) extracted = extractCapabilityToken(req); // FIX-5 carriers only; NEVER req.url query if !extracted → 401 (no token carrier present, local) resolved = await resolver.resolveSubdomain(subdomain); if !resolved → 403 (unknown subdomain, local) ctx: UpgradeContext = { capabilityRaw: extracted.token, originHeader: req.origin ?? '', expectedAud: subdomain, requestedHostId: resolved.hostId, // P1 NAMES the host it resolved; P5 gates token.host against it (INV1) requiredRight, remoteAddrHash: hash(remoteAddrSalt, req.remoteAddr), activeSessionCount: req.activeSessionCount, dpop: req.dpop, principal: null /* P5 resolves from token */, } outcome = req.sessionId ? await authorizer.onReattach({ ...ctx, sessionId: req.sessionId }, now()) // P5 owns the decision : await authorizer.onUpgrade(ctx, now()) if !outcome.ok → { ok:false, status: outcome.status } open = { streamId: 0 /*allocated by MuxSession*/, subdomain, requestPath: pathOf(req.url), originHeader: req.origin ?? '', remoteAddrHash: ctx.remoteAddrHash, capabilityTokenRef: outcome.jti /* OQ5 RESOLVED — P5 §6a AuthzOutcome.ok now carries the verified jti */ } return { ok:true, open, hostId: outcome.hostId, acceptedSubprotocol: APP_SUBPROTOCOL } ``` **`account_id`/`host_id` are derived by P5 from the signed token + registry — NEVER from client query/body/header (INV3).** The adapter passes `requestedHostId` (resolved from the subdomain), the raw token, Origin, and DPoP material to P5; **every accept/reject verdict is P5's**. `requestPath` is opaque passthrough the relay never parses for authz. - **Steps (TDD)**: 1. **RED** `upgrade.test.ts` (mock **P5 `authorizer`** + `resolver` — assert delegation, not local re-decision): - **delegation:** a happy `onUpgrade` (returns `{ok:true, hostId, principal, jti}`) → `{ok:true}`, `open.hostId === outcome.hostId`, `open.subdomain === 'alice'`, `acceptedSubprotocol === APP_SUBPROTOCOL`; assert `authorizer.onUpgrade` was called **exactly once** with a `UpgradeContext` carrying `expectedAud==='alice'`, `requestedHostId===resolved.hostId`, `requiredRight`, `dpop`, and `activeSessionCount` (proves ctx is fully populated). - **no local authz:** when P5 returns `{ok:false, status:401}` (foreign Origin / bad token / expired / aud-mismatch — all decided in P5), the adapter returns that status **verbatim** and opens **no** stream; the adapter contains no Origin/token/rights/cross-tenant branch of its own (assert by construction + coverage: every reject status originates from the `authorizer` mock). - **reattach:** `req.sessionId` present → `authorizer.onReattach` is called (not `onUpgrade`) with `{...ctx, sessionId}`; its `{ok:false}` maps straight through. - **token transport (FIX 5/INV15):** a token in `Sec-WebSocket-Protocol` (`['term.relay.v1','term.token.']`) is extracted via `extractTokenFromSubprotocols` and forwarded as `ctx.capabilityRaw`, and `acceptedSubprotocol === 'term.relay.v1'` (the `term.token.` entry is **never** echoed); a token in `cookies['term_cap']` is accepted; **a token placed ONLY in the query string (`req.url = '/term?cap='`) → 401 and `authorizer.onUpgrade` is NEVER called** (spy count 0) — `extractCapabilityToken` never reads `req.url`, so no ctx is even built. - **cross-tenant is P5's (INV1):** the adapter forwards `requestedHostId = resolved.hostId` and the raw token; the 403 for `token.host !== hostId` is asserted at P5's `onUpgrade` (its own test), **not** re-implemented here — T8 only asserts the status is passed through. - **local parse failures only:** unparseable Host → 401 without calling the authorizer; unknown subdomain (`resolver` null) → 403 without calling the authorizer (nothing to authorize). 2. **GREEN** implement thin: parse → resolve → build ctx → `await authorizer.onUpgrade/onReattach` → map. `hash` = HMAC-SHA256(salt, ip) truncated (audit-only, INV10). No decision branches beyond the three local parse guards above. - **Security note**: FIX 6a makes this the **adapter**, not the gate — the "single hardest gate in P1" is now P5's `onUpgrade`/`onReattach`, so the decision cannot drift between two implementations. The adapter still enforces the FIX-5 transport (token only via subprotocol/cookie, never query; echo only `APP_SUBPROTOCOL`) and derives identity only from the signed token + registry via P5 (INV3). **Logging discipline (INV9):** the edge MUST NOT log `req.url`, the `Authorization` header, or the `Sec-WebSocket-Protocol` value (each can carry the bearer-equivalent token); it logs only `{subdomain, hostId, jti, decision, ts}`. P5 owns the *permanent CI cross-tenant tripwire*; T13 ships P1's data-plane-level assertion that the adapter never overrides a P5 deny. - **Accept**: `npx vitest run upgrade` green; adapter has **zero** authz branches (delegation-only), verified by the "every reject originates from the authorizer mock" assertion. #### T9 · Agent tunnel listener `[ ]` — v0.9 **(INV4/INV7/INV12/INV14 surface)** - **Owns**: `term-relay/data-plane/agent-listener.ts`, `term-relay/test/agent-listener.test.ts` - **Depends**: T6 (MuxSession), T1 (config). **Cross-plan**: P5 `MtlsVerifier` (§ INV14), P3 `RouteRegistrar` (§4.2 `route:{host_id}`). · **Parallel-safe**: T8, T11. - **Contract**: ```ts export interface MtlsVerifier { // P5 impl — verifies the peer cert against the host registry pubkey verifyPeer(peerCert: Uint8Array): { hostId: string; accountId: string } | null } export interface RouteRegistrar { // P3 impl — Redis route:{host_id} with heartbeat TTL (INV7) register(hostId: string, relayNodeId: string, ttlMs: number): Promise heartbeat(hostId: string): Promise deregister(hostId: string): Promise } export interface AgentTunnel { readonly hostId: string; readonly session: MuxSession; closeTunnel(): void } export function createAgentListener(deps: { config: DataPlaneConfig; mtls: MtlsVerifier; registrar: RouteRegistrar onTunnel(t: AgentTunnel): void }): { attach(ws: WebSocketLike, peerCert: Uint8Array): void; tunnels(): ReadonlyMap } ``` - **TLS-layer contract (two-tier defense, Finding-4):** the agent-facing TLS server on `agentBindPort` MUST be constructed with **`requestCert: true` AND `rejectUnauthorized: true`**, `ca` loaded from `config.agentCaCertPath` (+ `agentCaChainPath`). This makes the TLS handshake itself reject any connection that presents **no** client cert or a cert not chaining to our CA — **before** `attach()` or `verifyPeer()` is ever called. `verifyPeer` then runs only on a cert that has **already passed CA-chain validation**, and does the app-layer bind (cert pubkey → registry `agent_pubkey`, INV14/INV4). This is the explicit guard against the "send-any-cert, we check it ourselves after the fact" downgrade: presentation is enforced at the TLS layer, identity binding at the app layer — never one without the other. - **Steps (TDD)**: 1. **RED** `agent-listener.test.ts` (mock ws + mtls + registrar): - valid peer cert → `MuxSession` created, `registrar.register(hostId, nodeId, routeTtlMs)` called, tunnel indexed by `hostId`. - **TLS-layer enforcement (Finding-4):** a TLS handshake with **no client cert presented** is rejected at the TLS layer (`rejectUnauthorized`) — `attach()`/`verifyPeer()` are **never** invoked; assert the `attach` spy count is 0. Likewise a cert not chaining to `config.agentCaCertPath` → TLS reject, `attach` never called. Assert the TLS server options passed to the server factory are `{ requestCert: true, rejectUnauthorized: true, ca: }`. - **invalid/expired cert (`verifyPeer` → null) → the ws is closed, NO route registered** (INV14 — CA never binds an unknown pubkey; no shared secret path, INV4). (This is the app-layer check that runs *after* a cert has already passed CA-chain validation, e.g. cert chains to our CA but its pubkey is not in the host registry.) - heartbeat: every `heartbeatIntervalMs`, `registrar.heartbeat(hostId)` is called; on `onDead`/ws-close, `registrar.deregister(hostId)` runs and the tunnel is removed (**stateless — nothing durable survives**, INV7). - **revocation (INV12):** `closeTunnel()` tears down the ws + deregisters within the test tick. - reconnection: a second `attach` for the same `hostId` (agent reconnected on this node) replaces the prior tunnel and re-registers (latest wins; old one deregistered). 2. **GREEN** implement; the node holds only an in-RAM `Map` — **no DB of record** (INV7). Build the TLS server with `requestCert: true`, `rejectUnauthorized: true`, `ca` from the config CA paths; `attach` is only ever reached for a TLS-validated peer cert. - **Security note (INV4/INV7/INV12/INV14)**: agent auth is **mTLS only**, enforced at **two tiers** — TLS-layer `requestCert`/`rejectUnauthorized` against `config.agentCaCertPath` guarantees a cert was presented *and* chains to our CA, then P5's `verifyPeer` binds that cert's pubkey to the registry `agent_pubkey`. No bearer/shared secret is ever accepted here; there is no code path that reaches `attach()` for an unauthenticated or no-cert peer. A node crash loses only the in-RAM map; agents reconnect and re-register (P3). `closeTunnel` is the revocation lever P5/P3 call to drop a live tunnel in seconds. - **Accept**: `npx vitest run agent-listener` green. #### T10 · Stateless relay node + opaque splice `[ ]` — v0.9 **(INV2/INV5/INV7/INV11 core)** - **Owns**: `term-relay/data-plane/relay-node.ts`, `term-relay/test/relay-node.test.ts` - **Depends**: T6, T8, T9, T1. · **Parallel-safe**: T11 (disjoint file). - **Contract**: ```ts export interface RelayNode { handleAgentTunnel(ws: WebSocketLike, peerCert: Uint8Array): void handleBrowserUpgrade(req: UpgradeRequest, ws: WebSocketLike): Promise // authorize → openStream → splice drain(reason: number): Promise // DRAIN_REASON value; delegates to T11 closeTunnel(hostId: string): void // WHOLE-host revocation lever (P5/P3) — kicks every device // SINGLE-device revocation (Finding-3): RST exactly the stream(s) opened under one capability token, // leaving every other device on the same host attached. Driven by P5's revoked:{jti} list (§4.2). // Returns the number of streams RST (0 if none matched — deny-by-default: unknown selector is a no-op). closeStream(hostId: string, selector: { readonly jti: string } | { readonly streamId: number }): number } export function createRelayNode(deps: { config: DataPlaneConfig; listener: ReturnType authorize: (req: UpgradeRequest) => Promise // = authorizeUpgrade bound to deps }): RelayNode ``` - **Splice (the byte hot-loop) — OPAQUE, zero parsing**: ``` decision = await authorize(req); if !decision.ok → ws.close(decision.status) (INV6/INV15) tunnel = listener.tunnels().get(decision.hostId); if !tunnel → ws.close(1013 /*try later*/) stream = tunnel.session.openStream(decision.open) // relay allocates streamId splices.add({ hostId: decision.hostId, streamId: stream.streamId, // per-jti index for closeStream (Finding-3) jti: decision.open.capabilityTokenRef, ws, stream }) ws.onMessage(bytes => stream.writeData(bytes)) // browser → agent : verbatim Uint8Array (INV2) stream via session.onData(id, bytes) → ws.send(bytes) // agent → browser : verbatim (INV2) ws.onClose(() => { stream.close(); splices.remove(...) }) stream.onClose(() => { ws.close(); splices.remove(...) }) ``` Buffers are **per-connection** (each `ws`↔`stream` pair) — **no global mutable buffer pool** (INV5, kills cross-tenant buffer bleed). The node never decodes DATA (INV2/INV11): a byte is a byte. - **`closeStream` (single-device revocation, Finding-3/INV12)**: the node maintains an in-RAM splice index (`splices`) mapping `hostId → jti → {streamId, ws, stream}` (built at splice time above, torn down on close — still **stateless**, in-RAM only, INV7). `closeStream(hostId, {jti})` looks up every splice for that host+`jti`, calls `stream.close(/*rst*/ true)` (RSTs just those streams via the mux, tunnel stays up) and `ws.close(4403)` on each, removes them from the index, and returns the count. `{streamId}` is the same by exact stream. An unmatched selector RSTs nothing and returns 0 (deny-by-default no-op — never falls back to a broader kill). This is the primitive P5 calls when it adds a `jti` to `revoked:{jti}` (§4.2) to revoke **one shared device** while the legitimate owner's other devices (different `jti`s, latest-writer-wins sharing) stay attached. - **Steps (TDD)**: 1. **RED** `relay-node.test.ts` (in-memory ws pairs + a fake agent MuxSession): - browser upgrade authorized → a stream opens to the correct `hostId`'s tunnel; bytes typed at the browser arrive **byte-identical** at the agent side and vice-versa. - **INV2 ciphertext marker:** push a unique marker through; assert the relay-node code never inspects it and the marker is not retained in any node-level structure after CLOSE. - unauthorized upgrade (decision 401/403) → ws closed with the status; **no** stream opened, **no** tunnel touched. - no tunnel for the host (agent offline) → ws closed (1013), no crash. - **INV7 crash-survival:** drop the node's in-RAM maps (simulate crash) → no persisted state; a fresh node with a reconnected tunnel serves new upgrades; in-flight browser ws just reconnect (base-app backoff). - **INV1 isolation at the node:** an upgrade authorized for `hostA` can only ever reach `hostA`'s tunnel — there is no API to reach a tunnel by index/address; lookup is strictly `decision.hostId` (from the token, INV3). - **single-device revocation (Finding-3/INV12):** open two browser splices on the same host under two different `jti`s (`jtiA`, `jtiB` — the base-app multi-device sharing case); `closeStream(hostId, {jti: jtiA})` RSTs **only** `jtiA`'s stream and closes its ws (code 4403), returns `1`, while `jtiB`'s splice keeps flowing bytes bidirectionally (the legitimate owner's other device stays attached). Two splices under the **same** `jti` → both RST, returns `2`. `closeStream(hostId, {jti: 'unknown'})` → returns `0`, RSTs nothing (deny-by-default no-op, never widens to a whole-host kill). After a ws/stream closes normally, its splice-index entry is gone so a later `closeStream` for its `jti` returns `0` (no stale handle). 2. **GREEN** implement thin; keep the file ≤ 300 lines (extract the splice helper / splice-index into a small module if needed). The splice index is in-RAM only (INV7). - **Security note (INV2/INV5/INV7/INV11/INV12)**: the node is a **ciphertext-shuttle** — opaque splice, per-connection buffers, no durable state, no terminal parser. `closeStream` gives P5 a **surgical** revocation lever (RST exactly the streams under one revoked `jti`) so revoking a single compromised/shared device does **not** kick the owner's other devices — removing the "nuke everyone or nothing" false choice that would otherwise delay real revocation. This is the load-bearing "byte-shuttle → ciphertext-shuttle" upgrade. - **Accept**: `npx vitest run relay-node` green. #### T11 · Graceful drain + GOAWAY `[ ]` — v0.9 **(INV7/INV12)** - **Owns**: `term-relay/data-plane/drain.ts`, `term-relay/test/drain.test.ts` - **Depends**: T6 (GOAWAY), T9 (tunnels). · **Parallel-safe**: T10 (disjoint file). - **Contract**: ```ts export const DRAIN_REASON = { operatorDrain: 1, revoked: 2, shutdown: 3 } as const export type DrainReason = typeof DRAIN_REASON[keyof typeof DRAIN_REASON] export function drainNode(deps: { tunnels(): ReadonlyMap registrar: RouteRegistrar reason: DrainReason inFlightGraceMs: number // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked (Finding-2) }): Promise export function drainHost(hostId: string, deps: { tunnels(): ReadonlyMap registrar: RouteRegistrar reason: DrainReason inFlightGraceMs: number // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked }): Promise // single-host (revocation / operator drain) ``` - **Grace-window rule (reason-differentiated, Finding-2/INV12):** the effective grace is `effectiveGraceMs = (reason === DRAIN_REASON.revoked) ? 0 : inFlightGraceMs`. A nonzero `inFlightGraceMs` is a *convenience for graceful operator drains/shutdowns only* (let an in-flight vim/paste flush before moving the host to another node). **`reason=revoked` is a security action against a compromised/malicious host or device** — it MUST force immediate teardown regardless of any caller- supplied `inFlightGraceMs`: RST every stream now, close the tunnel now, `registrar.deregister` now. This is what makes INV12's testable assertion ("its live tunnel drops within seconds") hold even when the operator-drain grace is tuned to tens of seconds. - **Steps (TDD)**: 1. **RED**: - `drainNode` with `reason=operatorDrain` sends **GOAWAY(lastStreamId, reason)** on streamId 0 to every tunnel; existing streams are allowed to finish within `inFlightGraceMs`, then tunnels close and routes are deregistered (P3). New `openStream` after GOAWAY is refused. - **`drainHost(hostId, {reason: DRAIN_REASON.revoked, inFlightGraceMs: N})` tears down all of that host's streams IMMEDIATELY regardless of N** (assert with a large N, e.g. 30_000): every stream is RST, the tunnel closes, and `registrar.deregister(hostId)` runs within the test tick — the grace window is **never** waited on. Same for `drainNode` with `reason=revoked` across all tunnels. - `drainHost` targets exactly one host and leaves siblings running (whole-host revocation, INV12). 2. **GREEN** implement: compute `effectiveGraceMs` from `reason` before scheduling teardown; deregister via `registrar.deregister` so the ingress stops routing to this node/host. - **Security note (INV12)**: `drainHost` is the whole-host revocation path — for `reason=revoked` it is GOAWAY + **immediate** RST + close + deregister (zero grace), dropping a compromised host's live tunnel within seconds; `operatorDrain`/`shutdown` keep a grace window purely for clean migration. **The customer's PTY survives** on their machine (base-app PTY≠WS decoupling); a relay-node bounce is invisible to the running Claude Code task (EXPLORE §3). For revoking **one shared device without kicking the others**, use T10's `RelayNode.closeStream` (per-`jti`), not a whole-host drain. - **Accept**: `npx vitest run drain` green. #### T14 · Revocation subscriber — `relay:revocations` → §4.1 teardown `[ ]` — v0.9 **(INV12 data-plane teardown owner, FIX 4)** > **FIX 4 — one control→data-plane teardown contract.** Fast revocation must tear down an **already-open** > tunnel, not merely refuse the next connect. The frozen §4.2 bus (`RELAY_REVOCATIONS_CHANNEL` + > `KillSignal`/`RevocationScope`, promoted into `relay-contracts` so P1 does **not** import P5 — DAG stays > acyclic) is the **single** named channel: **P3/P5 PUBLISH, every P1 node SUBSCRIBES**. This task is P1's > subscriber — the data-plane end of INV12. It injects **no new wire type**: it maps a `KillSignal` onto the > existing §4.1 `CLOSE`+`flags.RST` (per stream) or link-level `GOAWAY` (global) via T10/T11's levers. - **Owns**: `term-relay/data-plane/revocation-subscriber.ts`, `term-relay/test/revocation-subscriber.test.ts` - **Depends**: T10 (`RelayNode.closeStream`/`closeTunnel`), T11 (`drainHost`/`drainNode`). **Cross-plan**: subscribes to a Redis pub/sub client (injected); the publisher is **P5** `revoke()` / **P3** (§4.2). · **Parallel-safe**: none in W3 (consumes T10+T11); runs after both freeze. - **Contract** (frozen §4.2 types imported from `relay-contracts`, never redefined): ```ts import type { KillSignal, RevocationScope } from 'relay-contracts' import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations', 2000 export interface RevocationSubscriberDeps { subscribe(channel: string, onMessage: (raw: string) => void): { close(): void } // injected Redis pub/sub node: Pick // whole-host lever (T10) drainHost(hostId: string, reason: typeof DRAIN_REASON.revoked): Promise // immediate host teardown (T11) tunnels(): ReadonlyMap // to enumerate hosts for account/global scope routeResolver: RouteResolver // account → hostIds this node currently serves now: () => number onApplied?(signal: KillSignal, hostsAffected: number, elapsedMs: number): void // budget/audit metadata only (INV10) } // Pure scope→hosts selector (testable in isolation): which of THIS node's live hosts a signal hits. export function hostsForScope(scope: RevocationScope, liveHosts: ReadonlyMap, accountOf: (hostId: string) => string): readonly string[] export function startRevocationSubscriber(deps: RevocationSubscriberDeps): { close(): void } ``` - **Mapping (KillSignal → §4.1 teardown, no new frame type)**: ``` on message on RELAY_REVOCATIONS_CHANNEL: signal = parseKillSignal(raw) // Zod-validate at the boundary; malformed → drop + count (no throw across the bus) switch signal.scope.kind: 'host' → drainHost(scope.hostId, DRAIN_REASON.revoked) // GOAWAY? no — RST every stream now + close tunnel + deregister (T11, grace forced 0) 'account' → for hostId in hostsForScope(scope, tunnels(), accountOf): drainHost(hostId, revoked) 'global' → for every tunnel: drainHost(hostId, revoked) // link-level GOAWAY + RST all (node-wide) onApplied?(signal, hostsAffected, now()-startedAt) // metadata only; MUST NOT log the reason string as payload (INV10) ``` Every path uses **T11's `reason=revoked` immediate teardown (grace forced to 0)** so the §4.2 `REVOCATION_PUSH_BUDGET_MS = 2000` budget is met. An unknown/absent host (already gone) is a **no-op** (deny-by-default: nothing to tear down, never a broader kill). - **Steps (TDD)**: 1. **RED** `revocation-subscriber.test.ts` (fake pub/sub + spy `drainHost`/`closeTunnel`, in-RAM tunnels): - a `{scope:{kind:'host',hostId:H}}` message → `drainHost(H, revoked)` called once **within `REVOCATION_PUSH_BUDGET_MS`** (assert with a fake clock); a host **not** on this node → no-op (0 calls). - `{scope:{kind:'account',accountId:A}}` → exactly the node's live hosts owned by A are torn down; a sibling host of another account keeps flowing (INV1 blast-radius contained to the account). - `{scope:{kind:'global'}}` → every live tunnel torn down (node-wide GOAWAY+RST). - **immediacy (INV12):** teardown does **not** wait any grace — assert `drainHost` is invoked with `reason=revoked` (grace forced to 0 per T11), even if the node's operator-drain grace is large. - **malformed signal** (bad JSON / missing `scope`) → dropped, counted, **no** teardown fired, subscriber stays alive (a poisoned message can't kill the bus or trigger a spurious kill). - **no payload leak (INV10):** `signal.reason` is never written to a data structure or log as content; only `{scope, hostsAffected, elapsedMs}` metadata is emitted via `onApplied`. - `close()` unsubscribes cleanly (no teardown after close). 2. **GREEN** implement thin: `subscribe(RELAY_REVOCATIONS_CHANNEL, …)`, Zod-parse, `hostsForScope`, fan out to `drainHost(…, DRAIN_REASON.revoked)`. `hostsForScope` is a pure function (unit-tested standalone). - **Security note (INV12/INV1/INV10)**: this closes the FIX-4 gap — P5/P3 publish a `KillSignal`, **every** P1 node reacts by RST-ing exactly the in-scope streams and dropping the tunnels **immediately** (grace 0), so a compromised host/account is off the data plane within the 2s budget **even for connections already open**. Scope selection is blast-radius-bounded (`host` ⊂ `account` ⊂ `global`); a host this node doesn't serve is a no-op (no cross-tenant reach, INV1). The `reason` string is metadata only — never forwarded as terminal payload (INV10). P1 **subscribes only**; it never publishes (P5 stays the sole revocation authority). **Single-device (per-`jti`) revocation** is a finer granularity than the frozen `KillSignal` scope expresses — see §9 OQ6. - **Accept**: `npx vitest run revocation-subscriber` green; teardown latency assertion ≤ `REVOCATION_PUSH_BUDGET_MS`. ### W1-parallel lane — v0.8 scaffold #### T12 · frp MVP scaffold (v0.8 stepping-stone) `[ ]` — v0.8 - **Owns**: `term-relay/frp-scaffold/frps.toml`, `term-relay/frp-scaffold/README.md`, `term-relay/frp-scaffold/plugin-hook.ts`, `term-relay/test/plugin-hook.test.ts` - **Depends**: none (independent lane). **Cross-plan**: P3 exposes an authz hook endpoint; P2 wraps `frpc` (agent side). - **Deliverables**: - `frps.toml`: single-node `frps` on the VPS, **wildcard vhost** so `*.term.` routes by subdomain to the agent's proxy (EXPLORE §5.4); TLS terminated here or at Cloudflare in front (M6 scheme-following preserved end-to-end); `vhostHTTPSPort`/`subdomainHost` set to `BASE_DOMAIN`. - `plugin-hook.ts`: a **thin** HTTP server-plugin shim implementing frp's `Login`/`NewProxy`/`NewUserConn` hooks by **delegating** the decision to P3's control-plane authz endpoint (checks `agentToken`/`clientToken` hashes, subdomain ownership). **No tenancy logic lives here** — it forwards and enforces the deny-by-default answer. - `README.md`: the café-demo runbook — VPS + wildcard DNS (`*.term.`) + wildcard TLS (LetsEncrypt DNS-01) + `frps` + the P2 `frpc`-wrapped agent; **plus an explicit "retirement" section**: what each frp piece maps to in the native mux (frp yamux → §4.1 mux; frp subdomain vhost → T7 router; frp login plugin → T8 upgrade gate) so v0.9 is a swap, not a rewrite. - **Steps (TDD)**: 1. **RED** `plugin-hook.test.ts`: a `NewUserConn`/`Login` payload with an unknown token → hook returns **reject** (deny-by-default); a valid one whose subdomain the control plane owns → allow; the hook **forwards** to P3 and never decides tenancy itself; malformed payload (Zod) → reject. 2. **GREEN** implement the shim (Zod-validate the frp hook body at the boundary). - **Security note**: even the v0.8 scaffold keeps auth at the relay edge (the auth the base app never had, EXPLORE §5.5). The shared `clientToken`/`agentToken` gate is a **known, temporary** shortcut (INDEX v0.8) — it is replaced by capability tokens (T8) + mTLS (T9) + Passkey/pairing (P2/P3/P5) in v0.9/v0.10; the frame path is already designed so ciphertext (P4) drops into DATA without reshaping (INV2). **No plaintext terminal bytes are parsed even in v0.8** — frp forwards opaque TCP/WS streams (INV11). - **Accept**: `npx vitest run plugin-hook` green; README runbook reproduces the café demo on one VPS. ### W4 — invariant tripwires & integration #### T13 · P1 invariant tripwires + integration `[ ]` — v0.9 - **Owns**: `term-relay/test/inv-tripwires.test.ts`, `term-relay/test/integration/data-plane.test.ts` - **Depends**: T2–T11, T14. · **Parallel-safe**: none (integration convergence, like PLAN §W4). - **Steps (tests)**: - **INV11 static:** assert the `term-relay/mux/**` and `term-relay/data-plane/**` dependency graph imports **no** xterm/ANSI/terminal parser (scan `import`s; fail if any match `/xterm|ansi|vt100|terminal-parser/i`). - **INV2 ciphertext:** end-to-end through a real `MuxSession` pair + `relay-node` splice, inject a unique plaintext marker; assert it appears **only** as opaque bytes in transit and is absent from every relay-side retained structure and from logs. - **INV1 cross-tenant (data-plane assertion, complements P5's permanent CI tripwire):** device authorized for host A → 403 when hitting host B's subdomain, on first connect **and** on reconnect/reattach. - **INV7 stateless:** kill a node mid-stream → no data at rest; reconnected tunnel on a new node serves fresh upgrades; PTY (base app) unaffected. - **INV6 deny-by-default (FIX 6a):** the `authorizeUpgrade` adapter opens a stream **only** on P5's `{ok:true}`; every P5 `{ok:false}` and every local parse-guard failure returns 401/403 with no stream opened — assert the adapter has no independent allow branch (a P5 deny is never overridden into an allow). - **INV13 ordering:** DATA frames are delivered in order and never reordered/deduped by the relay (so P4's `seq` holds); an illegal transition RSTs one stream, not the tunnel. - **INV12 revocation (Finding-2/Finding-3 + FIX 4):** end-to-end, a `KillSignal{scope:{kind:'host',hostId}}` published on `relay:revocations` is consumed by **T14** and tears down that host's live tunnel within `REVOCATION_PUSH_BUDGET_MS` (already-open streams, not just the next connect); an `account` scope tears down only that account's hosts on the node (siblings keep flowing, INV1); `closeStream(hostId, {jti})` drops exactly one shared device's stream while a sibling device (different `jti`) keeps flowing; `drainHost(hostId, {reason: revoked, inFlightGraceMs: large})` tears down the whole host **without** waiting the grace window (immediate), whereas `reason: operatorDrain` honors it. - **INV15 token transport (Finding-1):** a browser upgrade carrying the capability token **only** in the query string is rejected (401) end-to-end; a token via `Sec-WebSocket-Protocol` (or `HttpOnly` cookie) is accepted; assert no test log line contains the raw token, `req.url`, or the `Sec-WebSocket-Protocol` value (INV9 access-log discipline). - **INV14 mTLS presentation (Finding-4):** a simulated agent handshake with no client cert (or a cert not chaining to `agentCaCertPath`) is rejected at the TLS layer — `attach()`/`verifyPeer()` are never reached. - **Accept**: `npx vitest run` (all `term-relay/` P1 tests) green; coverage ≥ 80% across mux + data-plane (`coding-style`/`testing.md`). --- ## 6. Security section (P1) **Threat model:** the relay bridges full interactive shells for many tenants; a data-plane compromise is worst-case RCE-shaped (EXPLORE §4). P1's job is to keep the node a **dumb ciphertext-shuttle** so it *cannot* leak or inject what it *cannot* parse, and to make cross-tenant reach structurally impossible. - **Cross-tenant isolation (INV1)** — the only path to a host is `authorizeUpgrade`, which (FIX 6a) resolves the subdomain → `requestedHostId` and **delegates the `token.host == hostId` gate to P5's `onUpgrade`/`onReattach`** — the decision has exactly one owner, so it cannot drift between two implementations. No code resolves a host by raw address/port/user hostname. Host header is a hint, never authority (`aud` guard + nested-subdomain rejection, T7; P5 owns the token/`aud` check). `host_id` is an unguessable, never-recycled UUIDv4 (§4.2). Fuzzed `host_id` guessing → 403 (P5 `onUpgrade`; T13 asserts the adapter passes it through). - **Ciphertext-shuttle (INV2/INV11)** — DATA payloads are opaque `Uint8Array`; no module imports a terminal parser (static tripwire T13). E2E envelopes (§4.4) ride DATA unread; **no P1 change at v0.10**. - **Deny-by-default authz (INV6) + capability token on upgrade (INV15)** — the full decision (Origin/CSWSH retained **AND** §4.3 token required, default reject, least-privilege rights, DPoP, single-use burn, rate, step-up, audit) is **owned solely by P5** (`onUpgrade`/`onReattach`, FIX 6a). P1's `authorizeUpgrade` is a **thin adapter**: it extracts the token via the frozen §4.3 FIX-5 carriers (subprotocol/cookie, never query), builds P5's `UpgradeContext` (incl. `dpop` + `activeSessionCount`), and opens a stream only on P5's `{ok:true}`. - **`account_id`/`host_id` never client-supplied (INV3)** — identity derives only from the token + registry; `requestPath` query (`?join=…`) is opaque passthrough, never used for authz. - **No shared secrets / mTLS (INV4/INV14)** — the agent listener accepts only mTLS-verified peers (P5 checks the cert against the registry **public** key); no bearer/shared-secret branch exists in v0.9+. - **Stateless, no plaintext at rest (INV5/INV7)** — the node holds only in-RAM maps + per-connection buffers (no global pool → no buffer bleed); a crash persists nothing; P1 writes no shell bytes anywhere. - **Fast revocation (INV12), FIX 4 single channel** — every P1 node **subscribes** to the frozen §4.2 `relay:revocations` bus (T14); **P3/P5 publish** a `KillSignal`. On a `host`/`account`-scoped signal the subscriber tears down the in-scope tunnel(s) via `drainHost(reason=revoked)` — RST every stream + close + deregister **immediately (grace forced to 0, Finding-2)**, within `REVOCATION_PUSH_BUDGET_MS` — and on `global` a node-wide GOAWAY+RST. `operatorDrain`/`shutdown` keep a grace window only for clean migration. Single-device (per-`jti`) `closeStream` remains a T10 lever but currently has no bus scope (§9 OQ6). P1 **subscribes only, never publishes** (P5 stays sole revocation authority; P1 does not import P5). The customer's PTY survives locally in every case. - **Secrets (INV9)** — `loadDataPlaneConfig` fails fast on missing TLS server material, the `AGENT_CA_CERT_PATH` mTLS trust-anchor, or `BASE_DOMAIN`; **no `console.log`** in P1 (`code-review.md`); nothing secret is ever logged. **The capability token is bearer-equivalent**, so the data-plane edge access log excludes the raw upgrade `req.url`, the `Authorization` header, and the `Sec-WebSocket-Protocol` value — it records only `{subdomain, hostId, jti, decision, ts}` (Finding-1). - **Anti-replay/injection carrier (INV13)** — P1 preserves DATA ordering and RSTs illegal stream transitions, so P4's per-message nonce + monotonic `seq` are meaningful end-to-end. **v0.8 shortcut, stated honestly:** the frp scaffold uses a shared `clientToken`/`agentToken` gate at the edge (INDEX v0.8). It is a **known temporary** posture, replaced by capability tokens + mTLS + Passkey/pairing before the first paid signup (EXPLORE §7). Even in v0.8, frp forwards opaque streams — **no terminal parsing** (INV11). --- ## 7. Verification ```bash # --- pure protocol leaves --- npx vitest run --dir term-relay frame-codec # §4.1 codec round-trip + adversarial/fuzz npx vitest run --dir term-relay stream # stream state machine (legal + illegal transitions) npx vitest run --dir term-relay flow-control # credit windows, per-stream isolation, deny-by-default npx vitest run --dir term-relay heartbeat # 15s PING/PONG, miss→dead, stale-token ignored # --- mux + data plane --- npx vitest run --dir term-relay mux-session # multiplex, backpressure, RST-one-stream, frame ceiling npx vitest run --dir term-relay subdomain-router # extractSubdomain, Host-confusion rejection npx vitest run --dir term-relay upgrade # FIX 6a: adapter delegates to P5 onUpgrade/onReattach; FIX 5 token transport; zero local authz branches npx vitest run --dir term-relay agent-listener # INV4/INV7/INV14: mTLS-only, register/heartbeat/deregister npx vitest run --dir term-relay relay-node # INV2/INV5/INV7/INV11: opaque splice, per-conn buffers npx vitest run --dir term-relay drain # INV7/INV12: GOAWAY drain + per-host revocation npx vitest run --dir term-relay revocation-subscriber # INV12/FIX4: relay:revocations KillSignal → §4.1 CLOSE+RST/GOAWAY ≤ 2s # --- v0.8 scaffold --- npx vitest run --dir term-relay plugin-hook # frp hook deny-by-default → delegates to P3 # --- invariant tripwires + integration + coverage --- npx vitest run --dir term-relay inv-tripwires # INV11 static import scan, INV2 marker, INV1 data-plane 403 npx vitest run --dir term-relay integration # end-to-end data-plane splice through a MuxSession pair npx vitest run --dir term-relay --coverage # ≥ 80% across mux + data-plane (testing.md) cd term-relay && npx tsc --noEmit # types clean; relay-contracts imported read-only grep -rEi 'xterm|ansi|vt100|terminal-parser' term-relay/mux term-relay/data-plane && echo FAIL || echo OK # INV11 ``` **Manual / deployment verification (v0.8 café demo, T12):** one VPS, wildcard DNS `*.term.`, wildcard TLS (LetsEncrypt DNS-01 or Cloudflare in front), `frps` running, a P2 `frpc`-wrapped agent paired on a laptop. From a phone browser open `alice.term.`, pass the `clientToken` gate, land in the laptop shell — **no router/VPN/static-IP configured**. Confirm: (1) a relay-node restart does **not** kill the running shell (PTY survives, INV7); (2) a foreign-Origin page cannot open the WS (CSWSH retained); (3) all 212 base-app tests still pass (`npm test` in repo root) — the relay adds a layer *underneath* the existing WebSocket, it does not modify `src/`. --- ## 8. Phasing summary | Phase | P1 tasks | Deliverable | |---|---|---| | **v0.8 MVP** | T1 (config), T12 (frp scaffold), **T2 spec-written** | Café demo on frp; §4.1 frame format authored (not yet the substrate); auth at the relay edge (shared-token shortcut). | | **v0.9 SaaS** | T2–T11, **T14**, T13 | Native WS mux **replaces frp**: §4.1 as the real substrate, per-stream flow control, 15s heartbeat, reconnection, GOAWAY drain, stateless node, subdomain routing by authenticated session, **upgrade gate as a thin adapter delegating to P5 `onUpgrade`/`onReattach` (FIX 6a)**, mTLS agent listener, opaque splice, and the **`relay:revocations` subscriber that turns a `KillSignal` into immediate §4.1 teardown (FIX 4)**. | | **v0.10 E2E-hardening** | *(no P1 code change)* | DATA already carries P4's `E2EEnvelope` opaquely (INV2) — the frame path was designed so ciphertext drops in without reshaping. P1 only re-runs its INV2/INV13 tripwires against the E2E build. | --- ## 9. Open questions (for the orchestrator — do NOT guess, per CLAUDE.md subagent rule) 1. **CBOR dependency for typed control-frame payloads (OPEN/WINDOW_UPDATE/GOAWAY).** §4.1 says OPEN is "CBOR of MuxOpen". Confirm the shared CBOR lib choice (`cbor-x`?) and whether it belongs in `relay-contracts/` (so P2 agent + P1 relay share one encoder) or is P1-owned in `mux/`. Leaning: put the CBOR codec in `relay-contracts/` next to the frozen types so both sides import one impl. 2. **Where `encodeMuxFrame`/`decodeMuxFrame` physically live.** §4.1 lists the signatures under "P1 owns" but the *types* are frozen in `relay-contracts/`. This plan puts the **binary codec impl** in `term-relay/mux/frame-codec.ts` (P1) and has P2 import it. Confirm P2 importing from `term-relay/mux/` is acceptable, or whether the codec should move into `relay-contracts/` to keep the agent from depending on the relay package. (Either is contract-compatible; it's a packaging call.) 3. **TLS termination boundary.** EXPLORE §3 allows terminating TLS at the relay **or** at Cloudflare in front. `DataPlaneConfig` carries cert/key paths for in-process termination; confirm the default deployment (in-process LetsEncrypt vs CF-in-front) so the T12 runbook picks one as primary. 4. **Ingress vs in-process routing at scale.** This plan keeps routing in-process on a single node (v0.8/v0.9, YAGNI per EXPLORE §7 v0.11). Confirm the Redis-routing-table + L7-ingress (Envoy/Traefik) split is deferred to a later multi-node plan and not P1's responsibility now. 5. **`AuthzOutcome` surfaces `jti` — RESOLVED (reconcile OQ5).** T8 is a thin adapter that no longer verifies the capability token (P5's `onUpgrade` does), so it cannot read `token.jti` itself — but `MuxOpen.capabilityTokenRef` (used by T10's per-`jti` splice index for single-device `closeStream`) needs it. P5 now adds `readonly jti: string` to the `ok` branch of `AuthzOutcome` (PLAN_RELAY_AUTH_ISOLATION.md T3), surfacing the *verified* token's jti; P1 consumes `outcome.jti` read-only for `capabilityTokenRef`. No local redefinition of `AuthzOutcome` in P1. 6. **Single-device (per-`jti`) revocation vs the frozen `KillSignal` scope.** FIX 4 froze `RevocationScope` as `host | account | global` only — it has **no per-device/per-`jti` granularity**. But INV12 and T10's `closeStream(hostId, {jti})` still require revoking **one shared device while the owner's other devices stay attached**. The `relay:revocations` bus therefore cannot currently express single-device revocation; T14 only covers host/account/global. **Ask the INDEX owner** whether to (a) add a `{kind:'device'; hostId; jti}` arm to `RevocationScope` (then T14 routes it to `closeStream`), or (b) keep single-device revocation on a separate per-`jti` mechanism. Pending that decision, T10's `closeStream` remains but has no bus trigger. **Do NOT extend `RevocationScope` locally** — it must change at the INDEX coordination point (§4.2).