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

849 lines
78 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | 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` 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 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**.
```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.<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**:
```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<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**:
```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**: T3T5, T12.
- **Contract**:
```ts
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_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<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):
```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<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**:
```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<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 `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<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**:
```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<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**:
```ts
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 `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<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):
```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<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 (`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.<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 `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.<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).