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.
78 KiB
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 followPLAN.md/PLAN_VOICE_COMMANDS.md: stable task IDs in dependency waves, disjointOwns:per task, function-signature-level contracts, TDD (tests FIRST), explicit test cases incl. security/negative, per-task security notes.PROGRESS_LOG.mdis 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:
frpsconfig (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,
frpcwrap, loopback forwarder,npx …-agent pair→ P2 AGENT. - Capability-token issuance/signing, mTLS cert verify, deny-by-default authz model, CI cross-tenant tripwire, revocation, audit log, rate-limits → P5 AUTH & ISOLATION. (P1 enforces these at the edge via injected interfaces; it does not own their logic.)
- E2E handshake + AEAD envelope → P4 E2E (rides P1 DATA frames as opaque ciphertext).
- Login / dashboard / browser-side crypto / client-side preview → P6 FRONTEND.
Base app unchanged: src/, public/, protocol.ts, the session model — byte-for-byte
untouched. P1 terminates at ws://127.0.0.1:3000 where the agent (P2) is just another local WS
client. The single base-app touch-point (ALLOWED_ORIGINS gaining the subdomain) is applied at
agent-install time (P2), not by P1.
1. Security invariants this plan enforces
Per INDEX §3, P1 is a primary owner of INV1, INV2, INV5, INV6, INV7, INV11, INV15 and contributes to INV9, INV12, INV13 (transport carries them). Mapping to enforcement points:
| INV | Where P1 enforces it | Task |
|---|---|---|
| INV1 cross-tenant isolation — A can never reach B | authorizeUpgrade (thin adapter, FIX 6a) resolves the subdomain → requestedHostId and delegates the token.host == hostId gate to P5's onUpgrade/onReattach; Host header is a hint only, never the authority. No code path resolves a host by raw addr/port/user hostname. |
T8 (→P5), T9, T13 |
| INV2 relay handles only ciphertext | DATA payloads are copied opaque; the splice never decodes them; relay imports no xterm/ANSI parser. | T2, T10, T13 |
| INV5 no plaintext/secret at rest | Data plane holds no durable buffers; per-connection allocation only, no global mutable buffer pool; nothing written to disk/Redis by P1 except metadata via P3. | T10, T13 |
| INV6 deny-by-default authz on connect AND reattach | The deny-by-default decision is P5's (onUpgrade/onReattach, FIX 6a); authorizeUpgrade is a thin adapter that opens a stream only on P5's {ok:true} and passes every 401/403 through verbatim. Reattach flows the same P5 gate via onReattach (base-app sessionId re-authorized as a claim, not trusted). |
T8 (→P5) |
| INV7 stateless data plane | Node holds only in-RAM tunnel/stream maps; a crash loses nothing (agent reconnects + re-registers via P3). No DB-of-record on the node. | T9, T10, T11, T13 |
| INV11 no terminal parsing in relay/agent | Static tripwire: relay/mux code has zero dependency on any terminal/ANSI parser; frames pass through opaque. | T2, T10, T13 |
| INV15 capability token required on WS upgrade | The adapter extracts the token via the frozen §4.3 FIX-5 subprotocol/cookie carriers only (never the query string) and forwards it to P5, which enforces Origin/CSWSH + token validity + aud/rights. The relay echoes only APP_SUBPROTOCOL (term.relay.v1), never the token entry. |
T8 (→P5) |
| INV9 secrets validated at startup, never logged | loadDataPlaneConfig fails fast on missing TLS/CA/secret material; no secret ever logged. Data-plane edge access logs MUST NOT include the raw upgrade req.url, the Authorization header, or the Sec-WebSocket-Protocol header value (any of which can carry the capability token); log only {subdomain, hostId, jti, decision, ts}. |
T1, T8, T10 |
| INV12 fast revocation | T14 subscribes to the frozen §4.2 relay:revocations bus (FIX 4) and maps a KillSignal → immediate §4.1 teardown: host/account scope → drainHost(reason=revoked) RST+close (grace forced to 0, ≤ REVOCATION_PUSH_BUDGET_MS), global → node-wide GOAWAY+RST. Three-tier levers: closeStream(hostId, {jti}) (single-device, see OQ6), closeTunnel/drainHost(revoked) (whole host, immediate), operatorDrain/shutdown (grace only for clean migration). P5 publishes; every P1 node subscribes (P1 never imports P5). |
T6, T9, T10, T11, T14 |
| INV13 anti-replay/injection | P1 preserves per-message ordering and never reorders/dedups DATA (so P4's seq/nonce guarantees hold end-to-end); illegal frame transitions RST the stream. |
T3, T6 |
2. Files & ownership (new package term-relay/, data-plane subtree only)
P1 and P3 co-inhabit term-relay/ but own disjoint subtrees (INDEX §0). P1 owns term-relay/mux/
(the reusable protocol library, also imported by P2), term-relay/data-plane/, and
term-relay/frp-scaffold/. Types for §4.1 live in the INDEX-frozen relay-contracts/; P1 imports
them read-only and never redefines them. Files ≤ 400 lines typical, 800 hard max (coding-style.md).
| File | Action | Task | Phase |
|---|---|---|---|
term-relay/package.json, term-relay/tsconfig.json, term-relay/vitest.config.ts |
create — package skeleton (P1 seeds; P3 extends its own subtree) | T1 | v0.8 |
term-relay/data-plane/config.ts |
create — env → DataPlaneConfig (Zod, fail-fast, INV9) |
T1 | v0.8 |
term-relay/mux/frame-codec.ts |
create — encodeMuxFrame/decodeMuxFrame/decodeHeader (§4.1) |
T2 | spec v0.8 / live v0.9 |
term-relay/mux/type-bytes.ts |
create — MuxFrameType ⇄ byte + flags bit maps (§4.1) |
T2 | v0.9 |
term-relay/mux/frame-guards.ts |
create — Zod boundary validation of OPEN/WINDOW_UPDATE/GOAWAY payloads (CBOR decode + relay-contracts schema) |
T2 | v0.9 |
term-relay/mux/stream.ts |
create — pure stream state machine (nextStreamState, transitions) |
T3 | v0.9 |
term-relay/mux/flow-control.ts |
create — credit-based FlowController (per-stream + link) |
T4 | v0.9 |
term-relay/mux/heartbeat.ts |
create — 15s PING/PONG liveness (injectable timer) | T5 | v0.9 |
term-relay/mux/mux-session.ts |
create — multiplexer over one WS: demux, streams, flow, heartbeat, GOAWAY | T6 | v0.9 |
term-relay/data-plane/subdomain-router.ts |
create — extractSubdomain + RouteResolver interface (impl = P3) |
T7 | v0.9 |
term-relay/data-plane/upgrade.ts |
create — authorizeUpgrade edge (Origin + capability token, INV1/6/15) |
T8 | v0.9 |
term-relay/data-plane/agent-listener.ts |
create — accept agent dial-out, mTLS-verify (P5 iface), build MuxSession, register route (P3 iface) |
T9 | v0.9 |
term-relay/data-plane/relay-node.ts |
create — stateless node: browser upgrade → openStream → opaque splice | T10 | v0.9 |
term-relay/data-plane/drain.ts |
create — GOAWAY graceful drain + closeTunnel for revocation (INV7/INV12) |
T11 | v0.9 |
term-relay/data-plane/revocation-subscriber.ts |
create — subscribe relay:revocations (§4.2, FIX 4) → KillSignal→§4.1 CLOSE+RST/GOAWAY via T10/T11 (INV12) |
T14 | v0.9 |
term-relay/frp-scaffold/frps.toml |
create — wildcard-subdomain vhost config (v0.8 substrate) | T12 | v0.8 |
term-relay/frp-scaffold/README.md |
create — VPS + wildcard DNS/TLS + frps runbook; retirement note |
T12 | v0.8 |
term-relay/frp-scaffold/plugin-hook.ts |
create — thin frp server-plugin HTTP shim → delegates authz to P3 (no tenancy logic here) | T12 | v0.8 |
term-relay/test/** (mux + data-plane + tripwire) |
create — vitest unit/integration + INV tripwires | T2–T14 | per task |
relay-contracts/** |
read-only (INDEX-frozen §4.1 types) — P1 imports, never edits | — | — |
Freeze rule: T2's codec + T3–T5 pure modules export a stable surface consumed by T6, then T6 by T9/T10; freeze each layer before the next consumes it (same discipline as PLAN_VOICE_COMMANDS §2).
3. Dependency waves
W0 package skeleton + config T1
│
▼
W1 pure protocol leaves (all parallel) T2 codec · T3 stream · T4 flow · T5 heartbeat
│ (also parallel, separate lane) T12 frp scaffold ── v0.8, no dep on W1
▼
W2 mux session (assembles T2–T5) T6
│
▼
W3 data-plane wiring (parallel where file-disjoint) T7 router · T8 upgrade(→P5) · T9 agent-listener
│ T10 relay-node (dep T6,T8,T9) · T11 drain (dep T6,T9)
│ T14 revocation-subscriber (dep T10,T11) ── FIX 4
▼
W4 invariant tripwires + integration T13
Cross-plan dependencies (interfaces, not files):
- T8 (FIX 6a) delegates the authorization decision to P5 → injected
Authorizer(onUpgrade/onReattach, §6a) consuming P5's frozenUpgradeContext/AuthzOutcome/DpopContext. T8 no longer imports aCapabilityVerifier/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 namerequestedHostIdfor theUpgradeContext(P5 makes the cross-tenant call). - T9 depends on P5's mTLS peer-cert verify → injected
MtlsVerifier; and P3's Redis routing table write → injectedRouteRegistrar(route:{host_id}heartbeat-TTL, §4.2). - T10 DATA path is contract-shared with P2 (agent side of §4.1) and carries P4's
E2EEnvelopeopaquely (§4.4) — P1 needs no P4 code (INV2). - T11 GOAWAY drain is invoked by P3 node-drain coordination (
operatorDrain/shutdown, grace honored); whole-hostcloseTunnel/drainHost(reason=revoked)(immediate, grace forced to 0) and single-devicecloseStream(hostId, {jti})are the levers. - T14 (FIX 4) subscribes to the frozen §4.2
relay:revocationsbus — P3/P5 publish aKillSignal, every P1 node consumes it and drives T10/T11 teardown. TheKillSignal/RevocationScope/channel constants live inrelay-contracts(promoted out ofrelay-auth) so P1 never imports P5 (DAG stays acyclic). P3 OQ4's "control→node drain channel" reconciles to this exact channel.
In v0.8, the frp scaffold (T12) lets P2/P3 integrate against a real transport while T2–T14 are built; the v0.9 native-mux swap is contract-compatible by construction (both speak §4.1 routing + opaque byte forwarding), so nothing downstream reshapes.
4. Frozen shared contracts consumed (cited verbatim from INDEX §4 — NOT redefined)
P1 imports these from relay-contracts/; it must never re-declare them locally.
§4.1 Mux frame (15-byte header) — version(1B)=0x01 · type(1B) · flags(1B: bit0 FIN, bit1 RST) · streamId(4B uint32, 0=link-level) · payloadLen(8B uint64 ≤ maxFrameBytes) · payload(var). Types:
0x01 OPEN · 0x02 DATA · 0x03 CLOSE · 0x04 PING · 0x05 PONG · 0x06 WINDOW_UPDATE · 0x07 GOAWAY.
Lifecycle OPEN → (DATA | WINDOW_UPDATE)* → CLOSE; illegal transition ⇒ RST that stream, never the
tunnel; streamId allocated by the relay, monotonic per tunnel, never reused.
// 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(exacthost_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'sonUpgrade/onReattach, which callsverifyCapabilityToken(raw, expectedAud, now)internally — T8 never calls it directly. - §4.3 WS-upgrade subprotocol wire format (FIX 5) — FROZEN, cited verbatim; T8's
extractCapabilityTokenMUST use these constants, never local literals:Echo rule (MUST): read the token from the// 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 absentterm.token.<b64u>entry viaextractTokenFromSubprotocols, strip it before handing to P5, and the accepted/echoedSec-WebSocket-Protocolin the handshake response MUST beAPP_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:
P1 subscribes to
// 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 constRELAY_REVOCATIONS_CHANNELand, per affected stream, injects a §4.1CLOSE+flags.RST(host/account scope) or a link-levelGOAWAY(global scope) using the existing frozen §4.1 wire — no new frame type — withinREVOCATION_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 readshostId/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):
- RED
config.test.ts: missingTLS_CERT_PATH/TLS_KEY_PATH/BASE_DOMAIN→loadDataPlaneConfigthrows with a clear message (fail-fast, INV9); missingAGENT_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-numericMAX_FRAME_BYTES→ throws; happy path returns typed frozen object; defaults applied. - GREEN implement with a Zod schema (
z.coerce.number()for ints,z.string().min(1)for paths). ReturnObject.freeze(...)(immutability). Never log cert/CA paths' contents; never log secrets.
- RED
- Security note (INV9): startup MUST fail if TLS server material, the
AGENT_CA_CERT_PATHmTLS trust-anchor, orBASE_DOMAINis absent; no secret value is ever emitted to logs. The CA-path presence check is the config-level guarantee that T9 can wire TLS-layerrequestCert: true/rejectUnauthorized: true(no accept-any-cert path exists). - Accept:
npx vitest run configgreen;tsc --noEmitclean.
W1 — pure protocol leaves (all parallel, DOM-free, no ws/pg)
T2 · Mux frame codec [ ] — spec written v0.8, becomes substrate v0.9
- Owns:
term-relay/mux/frame-codec.ts,term-relay/mux/type-bytes.ts,term-relay/mux/frame-guards.ts,term-relay/test/frame-codec.test.ts - Depends:
relay-contracts§4.1 types. · Parallel-safe: T3–T5, T12. - Contract:
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):
- RED
frame-codec.test.ts(round-trip + adversarial):- round-trip each type;
encode∘decode === identityfor header fields incl.fin/rstbit packing. streamId=0link-level frames decode withstreamId===0.payloadLenuses 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; unknowntypebyte (0x00, 0x08) → throws;version ≠ 0x01→ throws;payloadLen > maxFrameBytescaller-check hook (codec exposes the raw length; the ceiling is enforced by T6 with config). decodeOpenrejects a CBOR payload missingsubdomain/host-scope fields (Zod) — fuzz malformed CBOR → throws, never returns partial (INV boundary validation).
- round-trip each type;
- GREEN implement with
DataViewfor the fixed header (big-endian) andcbor-x(or a tiny CBOR) for typed payloads; Zod-validate decoded OPEN/WINDOW_UPDATE/GOAWAY.
- RED
- Security note (INV2/INV11): this module imports no terminal/ANSI parser; DATA payloads are handled as opaque
Uint8Arrayand never inspected.frame-guards.tsvalidates control frames only (OPEN/WU/GOAWAY), never DATA content. - Accept:
npx vitest run frame-codecgreen; 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-contractstypes. · 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):
- RED
stream.test.ts: legal pathidle --OPEN--> open --DATA*--> open --CLOSE--> closed;DATAwithfin→ 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 afterclosed. Direction matters (inbound FIN vs outbound FIN produce different half-closed states). - GREEN implement as a total function returning a new state (never mutate).
- RED
- Security note (INV13): illegal transitions are rejected at the stream level (RST one stream), never silently coerced — this is what lets P4's
seqmonotonicity mean something end-to-end. - Accept:
npx vitest run streamgreen; 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):
- RED
flow-control.test.ts:canSendtrue until credit exhausted, then false;consumeSendCreditpast window is rejected bycanSend(never negative credit);grantCreditunblocks;onDeliveredreturns a replenish amount only once the threshold is crossed, else 0; unknown streamId →canSendfalse (deny-by-default),creditFor0. Per-stream isolation: exhausting stream A's window does not affect stream B (heavyvim/topredraw can't starve another stream, §4.1). - GREEN implement with a per-stream
Map<number, {window, sent, delivered}>; immutable-style updates (replace the record).
- RED
- Security note: unknown/released streams are deny-by-default (no credit) — prevents send-after-close write amplification.
- Accept:
npx vitest run flow-controlgreen.
T5 · Heartbeat / liveness [ ] — v0.9
- Owns:
term-relay/mux/heartbeat.ts,term-relay/test/heartbeat.test.ts - Depends: none. · Parallel-safe: T2–T4.
- Contract (timer injectable, per PLAN_VOICE_COMMANDS confirm-window pattern):
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):
- RED with a fake scheduler: after
start, a PING is sent every interval with a fresh 8-byte token; a matchingonPongresets the miss counter; 3 intervals with no PONG →onDeadfires 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);stopcancels cleanly (noonDeadafter stop). - GREEN implement with the injected scheduler; token = random 8 bytes (crypto RNG).
- RED with a fake scheduler: after
- 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 heartbeatgreen.
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):
- RED
mux-session.test.ts(twoMuxSessions wired mouth-to-mouth via each other'ssendWire):- relay
openStream(open)→ agent'sonOpenfires with a fresh monotonicstreamId;streamIds never reused within the session. writeDataon relay → agentonDatareceives 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 →
writeDatareturnsfalse; a WINDOW_UPDATE from the peer resumes flushing buffered data; stream A backpressure does not block stream B. - heartbeat: no PONG for
HEARTBEAT_MISS_LIMITintervals →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.
- relay
- 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.
- RED
- 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-sessiongreen; 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):
- RED:
extractSubdomain('alice.term.example.com', 'term.example.com')→'alice'; a barebaseDomain→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. - GREEN implement a strict suffix match returning the single leftmost label only.
- RED:
- Security note (INV1):
extractSubdomainis a hint for candidate lookup; it is never the authority — T8 requires the capability token'saudto equal this subdomain andtoken.hostto equal the resolver'shostId. Nested/confusing Host headers resolve tonull(reject). - Accept:
npx vitest run subdomain-routergreen.
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
jtiburn + 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).authorizeUpgradeholds NO independent authz logic — it only (a) parses the upgrade (subdomain + FIX-5 token extraction + remoteAddr hash), (b) resolves the subdomain →requestedHostIdvia P3'sRouteResolver, (c) builds P5's frozenUpgradeContext(incl.dpop: DpopContext+activeSessionCount), (d) calls the injected P5Authorizer, and (e) maps the returnedAuthzOutcome→ a §4.1MuxOpen(or a 401/403 close). Reattach flows the same gate viaonReattach(base-appsessionIdre-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), P3RouteResolver. · 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
WebSocketAPI cannot set arbitrary request headers, so the token rides one of exactly two carriers, in this order:Sec-WebSocket-Protocolsubprotocol (PREFERRED). The client opensnew WebSocket(url, [APP_SUBPROTOCOL, encodeTokenSubprotocol(rawToken)])— i.e.['term.relay.v1', 'term.token.' + base64url(token)].extractCapabilityTokenreads the token via the frozenextractTokenFromSubprotocols(req.subprotocols)(stripsTOKEN_SUBPROTOCOL_PREFIX, base64url-decodes). The relay MUST echoacceptedSubprotocol = APP_SUBPROTOCOL('term.relay.v1') in the handshake response — never theterm.token.<b64u>entry (echoing the token leaks the bearer secret).UpgradeDecision.oktherefore fixesacceptedSubprotocol: 'term.relay.v1'(non-nullable — the app subprotocol is always echoed).- Short-lived
HttpOnly; Secure; SameSite=Strictcookie (FALLBACK). Set by a prior authenticated HTTPS call (P6), scoped to the tenant subdomain, read fromreq.cookies.HttpOnlykeeps it out of JS/Referer;Securekeeps it off plaintext;SameSite=Strictcomplements the retained Origin/CSWSH check.
- FORBIDDEN: the query string. The token is a bearer-equivalent secret;
authorizeUpgradeandextractCapabilityTokenMUST NOT read any token fromreq.url.req.urlcarries onlyrequestPath(opaque?join=<id>passthrough, never parsed for authz). Query-string tokens leak verbatim into reverse-proxy/CDN/frp access logs, browser history, andRefererheaders.
- 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_idare derived by P5 from the signed token + registry — NEVER from client query/body/header (INV3). The adapter passesrequestedHostId(resolved from the subdomain), the raw token, Origin, and DPoP material to P5; every accept/reject verdict is P5's.requestPathis opaque passthrough the relay never parses for authz. - Steps (TDD):
- RED
upgrade.test.ts(mock P5authorizer+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; assertauthorizer.onUpgradewas called exactly once with aUpgradeContextcarryingexpectedAud==='alice',requestedHostId===resolved.hostId,requiredRight,dpop, andactiveSessionCount(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 theauthorizermock). - reattach:
req.sessionIdpresent →authorizer.onReattachis called (notonUpgrade) 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 viaextractTokenFromSubprotocolsand forwarded asctx.capabilityRaw, andacceptedSubprotocol === 'term.relay.v1'(theterm.token.entry is never echoed); a token incookies['term_cap']is accepted; a token placed ONLY in the query string (req.url = '/term?cap=<token>') → 401 andauthorizer.onUpgradeis NEVER called (spy count 0) —extractCapabilityTokennever readsreq.url, so no ctx is even built. - cross-tenant is P5's (INV1): the adapter forwards
requestedHostId = resolved.hostIdand the raw token; the 403 fortoken.host !== hostIdis asserted at P5'sonUpgrade(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 (
resolvernull) → 403 without calling the authorizer (nothing to authorize).
- delegation: a happy
- 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.
- RED
- 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 onlyAPP_SUBPROTOCOL) and derives identity only from the signed token + registry via P5 (INV3). Logging discipline (INV9): the edge MUST NOT logreq.url, theAuthorizationheader, or theSec-WebSocket-Protocolvalue (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 upgradegreen; 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), P3RouteRegistrar(§4.2route:{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
agentBindPortMUST be constructed withrequestCert: trueANDrejectUnauthorized: true,caloaded fromconfig.agentCaCertPath(+agentCaChainPath). This makes the TLS handshake itself reject any connection that presents no client cert or a cert not chaining to our CA — beforeattach()orverifyPeer()is ever called.verifyPeerthen runs only on a cert that has already passed CA-chain validation, and does the app-layer bind (cert pubkey → registryagent_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):
- RED
agent-listener.test.ts(mock ws + mtls + registrar):- valid peer cert →
MuxSessioncreated,registrar.register(hostId, nodeId, routeTtlMs)called, tunnel indexed byhostId. - 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 theattachspy count is 0. Likewise a cert not chaining toconfig.agentCaCertPath→ TLS reject,attachnever 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; ononDead/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
attachfor the samehostId(agent reconnected on this node) replaces the prior tunnel and re-registers (latest wins; old one deregistered).
- valid peer cert →
- GREEN implement; the node holds only an in-RAM
Map<hostId, AgentTunnel>— no DB of record (INV7). Build the TLS server withrequestCert: true,rejectUnauthorized: true,cafrom the config CA paths;attachis only ever reached for a TLS-validated peer cert.
- RED
- Security note (INV4/INV7/INV12/INV14): agent auth is mTLS only, enforced at two tiers — TLS-layer
requestCert/rejectUnauthorizedagainstconfig.agentCaCertPathguarantees a cert was presented and chains to our CA, then P5'sverifyPeerbinds that cert's pubkey to the registryagent_pubkey. No bearer/shared secret is ever accepted here; there is no code path that reachesattach()for an unauthenticated or no-cert peer. A node crash loses only the in-RAM map; agents reconnect and re-register (P3).closeTunnelis the revocation lever P5/P3 call to drop a live tunnel in seconds. - Accept:
npx vitest run agent-listenergreen.
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:
Buffers are per-connection (each
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(...) })ws↔streampair) — 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) mappinghostId → 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, callsstream.close(/*rst*/ true)(RSTs just those streams via the mux, tunnel stays up) andws.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 ajtitorevoked:{jti}(§4.2) to revoke one shared device while the legitimate owner's other devices (differentjtis, latest-writer-wins sharing) stay attached.- Steps (TDD):
- 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
hostAcan only ever reachhostA's tunnel — there is no API to reach a tunnel by index/address; lookup is strictlydecision.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 onlyjtiA's stream and closes its ws (code 4403), returns1, whilejtiB's splice keeps flowing bytes bidirectionally (the legitimate owner's other device stays attached). Two splices under the samejti→ both RST, returns2.closeStream(hostId, {jti: 'unknown'})→ returns0, 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 latercloseStreamfor itsjtireturns0(no stale handle).
- browser upgrade authorized → a stream opens to the correct
- 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).
- RED
- Security note (INV2/INV5/INV7/INV11/INV12): the node is a ciphertext-shuttle — opaque splice, per-connection buffers, no durable state, no terminal parser.
closeStreamgives P5 a surgical revocation lever (RST exactly the streams under one revokedjti) 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-nodegreen.
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 nonzeroinFlightGraceMsis a convenience for graceful operator drains/shutdowns only (let an in-flight vim/paste flush before moving the host to another node).reason=revokedis a security action against a compromised/malicious host or device — it MUST force immediate teardown regardless of any caller- suppliedinFlightGraceMs: RST every stream now, close the tunnel now,registrar.deregisternow. 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):
- RED:
drainNodewithreason=operatorDrainsends GOAWAY(lastStreamId, reason) on streamId 0 to every tunnel; existing streams are allowed to finish withininFlightGraceMs, then tunnels close and routes are deregistered (P3). NewopenStreamafter 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, andregistrar.deregister(hostId)runs within the test tick — the grace window is never waited on. Same fordrainNodewithreason=revokedacross all tunnels.drainHosttargets exactly one host and leaves siblings running (whole-host revocation, INV12).
- GREEN implement: compute
effectiveGraceMsfromreasonbefore scheduling teardown; deregister viaregistrar.deregisterso the ingress stops routing to this node/host.
- RED:
- Security note (INV12):
drainHostis the whole-host revocation path — forreason=revokedit is GOAWAY + immediate RST + close + deregister (zero grace), dropping a compromised host's live tunnel within seconds;operatorDrain/shutdownkeep 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'sRelayNode.closeStream(per-jti), not a whole-host drain. - Accept:
npx vitest run draingreen.
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 intorelay-contractsso 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 aKillSignalonto the existing §4.1CLOSE+flags.RST(per stream) or link-levelGOAWAY(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 P5revoke()/ 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):
Every path uses T11's
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)reason=revokedimmediate teardown (grace forced to 0) so the §4.2REVOCATION_PUSH_BUDGET_MS = 2000budget 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):
- RED
revocation-subscriber.test.ts(fake pub/sub + spydrainHost/closeTunnel, in-RAM tunnels):- a
{scope:{kind:'host',hostId:H}}message →drainHost(H, revoked)called once withinREVOCATION_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
drainHostis invoked withreason=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.reasonis never written to a data structure or log as content; only{scope, hostsAffected, elapsedMs}metadata is emitted viaonApplied. close()unsubscribes cleanly (no teardown after close).
- a
- GREEN implement thin:
subscribe(RELAY_REVOCATIONS_CHANNEL, …), Zod-parse,hostsForScope, fan out todrainHost(…, DRAIN_REASON.revoked).hostsForScopeis a pure function (unit-tested standalone).
- RED
- 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). Thereasonstring 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 frozenKillSignalscope expresses — see §9 OQ6. - Accept:
npx vitest run revocation-subscribergreen; 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-nodefrpson 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/subdomainHostset toBASE_DOMAIN.plugin-hook.ts: a thin HTTP server-plugin shim implementing frp'sLogin/NewProxy/NewUserConnhooks by delegating the decision to P3's control-plane authz endpoint (checksagentToken/clientTokenhashes, 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 P2frpc-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):
- RED
plugin-hook.test.ts: aNewUserConn/Loginpayload 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. - GREEN implement the shim (Zod-validate the frp hook body at the boundary).
- RED
- 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/agentTokengate 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-hookgreen; README runbook reproduces the café demo on one VPS.
W4 — invariant tripwires & integration
T13 · P1 invariant tripwires + integration [ ] — v0.9
- Owns:
term-relay/test/inv-tripwires.test.ts,term-relay/test/integration/data-plane.test.ts - Depends: T2–T11, T14. · Parallel-safe: none (integration convergence, like PLAN §W4).
- Steps (tests):
- INV11 static: assert the
term-relay/mux/**andterm-relay/data-plane/**dependency graph imports no xterm/ANSI/terminal parser (scanimports; fail if any match/xterm|ansi|vt100|terminal-parser/i). - INV2 ciphertext: end-to-end through a real
MuxSessionpair +relay-nodesplice, 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
authorizeUpgradeadapter 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
seqholds); 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 onrelay:revocationsis consumed by T14 and tears down that host's live tunnel withinREVOCATION_PUSH_BUDGET_MS(already-open streams, not just the next connect); anaccountscope 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 (differentjti) keeps flowing;drainHost(hostId, {reason: revoked, inFlightGraceMs: large})tears down the whole host without waiting the grace window (immediate), whereasreason: operatorDrainhonors 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(orHttpOnlycookie) is accepted; assert no test log line contains the raw token,req.url, or theSec-WebSocket-Protocolvalue (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.
- INV11 static: assert the
- Accept:
npx vitest run(allterm-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 →requestedHostIdand delegates thetoken.host == hostIdgate to P5'sonUpgrade/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 (audguard + nested-subdomain rejection, T7; P5 owns the token/audcheck).host_idis an unguessable, never-recycled UUIDv4 (§4.2). Fuzzedhost_idguessing → 403 (P5onUpgrade; 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'sauthorizeUpgradeis a thin adapter: it extracts the token via the frozen §4.3 FIX-5 carriers (subprotocol/cookie, never query), builds P5'sUpgradeContext(incl.dpop+activeSessionCount), and opens a stream only on P5's{ok:true}. account_id/host_idnever client-supplied (INV3) — identity derives only from the token + registry;requestPathquery (?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:revocationsbus (T14); P3/P5 publish aKillSignal. On ahost/account-scoped signal the subscriber tears down the in-scope tunnel(s) viadrainHost(reason=revoked)— RST every stream + close + deregister immediately (grace forced to 0, Finding-2), withinREVOCATION_PUSH_BUDGET_MS— and onglobala node-wide GOAWAY+RST.operatorDrain/shutdownkeep a grace window only for clean migration. Single-device (per-jti)closeStreamremains 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) —
loadDataPlaneConfigfails fast on missing TLS server material, theAGENT_CA_CERT_PATHmTLS trust-anchor, orBASE_DOMAIN; noconsole.login 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 upgradereq.url, theAuthorizationheader, and theSec-WebSocket-Protocolvalue — 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
seqare 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 | T2–T11, T14, T13 | Native WS mux replaces frp: §4.1 as the real substrate, per-stream flow control, 15s heartbeat, reconnection, GOAWAY drain, stateless node, subdomain routing by authenticated session, upgrade gate as a thin adapter delegating to P5 onUpgrade/onReattach (FIX 6a), mTLS agent listener, opaque splice, and the relay:revocations subscriber that turns a KillSignal into immediate §4.1 teardown (FIX 4). |
| v0.10 E2E-hardening | (no P1 code change) | DATA already carries P4's E2EEnvelope opaquely (INV2) — the frame path was designed so ciphertext drops in without reshaping. P1 only re-runs its INV2/INV13 tripwires against the E2E build. |
9. Open questions (for the orchestrator — do NOT guess, per CLAUDE.md subagent rule)
- 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 inrelay-contracts/(so P2 agent + P1 relay share one encoder) or is P1-owned inmux/. Leaning: put the CBOR codec inrelay-contracts/next to the frozen types so both sides import one impl. - Where
encodeMuxFrame/decodeMuxFramephysically live. §4.1 lists the signatures under "P1 owns" but the types are frozen inrelay-contracts/. This plan puts the binary codec impl interm-relay/mux/frame-codec.ts(P1) and has P2 import it. Confirm P2 importing fromterm-relay/mux/is acceptable, or whether the codec should move intorelay-contracts/to keep the agent from depending on the relay package. (Either is contract-compatible; it's a packaging call.) - TLS termination boundary. EXPLORE §3 allows terminating TLS at the relay or at Cloudflare in
front.
DataPlaneConfigcarries 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. - 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.
AuthzOutcomesurfacesjti— RESOLVED (reconcile OQ5). T8 is a thin adapter that no longer verifies the capability token (P5'sonUpgradedoes), so it cannot readtoken.jtiitself — butMuxOpen.capabilityTokenRef(used by T10's per-jtisplice index for single-devicecloseStream) needs it. P5 now addsreadonly jti: stringto theokbranch ofAuthzOutcome(PLAN_RELAY_AUTH_ISOLATION.md T3), surfacing the verified token's jti; P1 consumesoutcome.jtiread-only forcapabilityTokenRef. No local redefinition ofAuthzOutcomein P1.- Single-device (per-
jti) revocation vs the frozenKillSignalscope. FIX 4 frozeRevocationScopeashost | account | globalonly — it has no per-device/per-jtigranularity. But INV12 and T10'scloseStream(hostId, {jti})still require revoking one shared device while the owner's other devices stay attached. Therelay:revocationsbus 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 toRevocationScope(then T14 routes it tocloseStream), or (b) keep single-device revocation on a separate per-jtimechanism. Pending that decision, T10'scloseStreamremains but has no bus trigger. Do NOT extendRevocationScopelocally — it must change at the INDEX coordination point (§4.2).