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

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

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

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

78 KiB
Raw Blame History

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 §0 LOCKED DECISIONS, §3 (control/data split, multiplexing, routing, drain), §5 (MVP shortcuts); PLAN_RELAY_INDEX.md §3 (security invariants), §4 (FROZEN SHARED CONTRACTS — cited verbatim below, never redefined), §5 (P1 charter). Conventions follow PLAN.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 pairP2 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 T2T14 per task
relay-contracts/** read-only (INDEX-frozen §4.1 types) — P1 imports, never edits

Freeze rule: T2's codec + T3T5 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 T2T5)                      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 busP3/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 T2T14 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.

// 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:
    // 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.<b64u> 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:
    // 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<void> }
    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:
    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_DOMAINloadDataPlaneConfig 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: T3T5, T12.
  • Contract:
    export const MUX_HEADER_BYTES = 15
    export const MUX_VERSION = 1 as const
    export const TYPE_TO_BYTE: Readonly<Record<MuxFrameType, number>>  // open→0x01 … goaway→0x07
    export const BYTE_TO_TYPE: ReadonlyMap<number, MuxFrameType>
    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_INTEGERthrows (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):
    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:
    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<number, {window, sent, delivered}>; 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: T2T4.
  • Contract (timer injectable, per PLAN_VOICE_COMMANDS confirm-window pattern):
    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:
    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 MuxSessions wired mouth-to-mouth via each other's sendWire):
      • relay openStream(open) → agent's onOpen fires with a fresh monotonic streamId; streamIds 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<streamId, streamCtx> 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:
    // 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<ResolvedHost | null> }
    export function extractSubdomain(hostHeader: string, baseDomain: string): string | null
    
  • Steps (TDD):
    1. RED: extractSubdomain('alice.term.example.com', 'term.example.com')'alice'; a bare baseDomainnull; 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):
    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<AuthzOutcome>
      onReattach(ctx: UpgradeContext & { sessionId: string }, now: number): Promise<AuthzOutcome>
    }
    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<Record<string, string>>  // 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<UpgradeDecision>
    
  • 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.<b64u> 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=<id> 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.<b64u>']) 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=<token>') → 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:
    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<void>
      heartbeat(hostId: string): Promise<void>
      deregister(hostId: string): Promise<void>
    }
    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<string, AgentTunnel> }
    
  • 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: <from agentCaCertPath/agentCaChainPath> }.
      • 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<hostId, AgentTunnel>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:
    export interface RelayNode {
      handleAgentTunnel(ws: WebSocketLike, peerCert: Uint8Array): void
      handleBrowserUpgrade(req: UpgradeRequest, ws: WebSocketLike): Promise<void>  // authorize → openStream → splice
      drain(reason: number): Promise<void>          // 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<typeof createAgentListener>
      authorize: (req: UpgradeRequest) => Promise<UpgradeDecision>   // = 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 wsstream 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 jtis, 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 jtis (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:
    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<string, AgentTunnel>
      registrar: RouteRegistrar
      reason: DrainReason
      inFlightGraceMs: number        // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked (Finding-2)
    }): Promise<void>
    export function drainHost(hostId: string, deps: {
      tunnels(): ReadonlyMap<string, AgentTunnel>
      registrar: RouteRegistrar
      reason: DrainReason
      inFlightGraceMs: number        // HONORED ONLY for operatorDrain/shutdown; FORCED to 0 for revoked
    }): Promise<void>   // 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):
    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<RelayNode, 'closeTunnel'>                       // whole-host lever (T10)
      drainHost(hostId: string, reason: typeof DRAIN_REASON.revoked): Promise<void>     // immediate host teardown (T11)
      tunnels(): ReadonlyMap<string, AgentTunnel>               // 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<string, AgentTunnel>,
                                  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 (hostaccountglobal); 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.<domain> 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.<domain>) + 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: T2T11, 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 imports; 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

# --- 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.<domain>, 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.<domain>, 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 T2T11, 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).