Files
web-terminal/docs/PLAN_RELAY_INDEX.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

46 KiB
Raw Permalink Blame History

Rendezvous-Relay Service — Plan-of-Plans (INDEX)

Product: a multi-tenant rendezvous-relay SERVICE that bridges external devices to each customer's self-hosted web-terminal — an "ngrok-for-Claude-Code" with end-to-end encryption. Source of intent: EXPLORE_RELAY_SERVICE.md — its §0 LOCKED DECISIONS are AUTHORITATIVE and CLOSED; do not reopen them in any plan. This INDEX is the coordination point for the 6 implementation plans — the analog of src/types.ts for the base app. It fixes: overview & phasing (§1), the cross-plan dependency DAG (§2), the numbered security invariants (§3), the FROZEN SHARED CONTRACTS every plan must cite verbatim (§4), and a per-plan charter (§5). The 6 plans MUST reference §3 and §4 by name and MUST NOT redefine any contract locally. Conventions follow PLAN.md: stable task IDs grouped into dependency waves, an Owns: disjoint-file list per task, function-signature-level contracts, TDD (tests FIRST), explicit test cases, per-task security notes. Progress is logged in PROGRESS_LOG.mdorchestrator-only writer (subagents return a ready-to-paste entry, G1).


0. Scope boundary — what does NOT change

The existing web-terminal (src/) is a byte-shuttle and stays byte-for-byte unchanged except one config touch-point: ALLOWED_ORIGINS gains the assigned subdomain at agent-install time (EXPLORE §3 "one base-app touch-point"). No new code in src/, public/, protocol.ts, or the session model. All new code lives in NEW top-level packages:

Package Owner plan Role
relay-contracts/ INDEX-frozen (§4) — the coordination point, analog of src/types.ts Shared Zod schemas + TS types for every §4 contract. Dependency-free (no ws/pg/DOM ambient) so agent, relay, control-plane, and browser can all import it. Frozen here; changed only via this INDEX.
term-relay/ P1 (data plane) + P3 (control plane) Relay nodes (stateless data plane) and the control plane (accounts, routing, pairing, metering). Physically split — EXPLORE §3 "control plane vs data plane".
agent/ P2 Host-agent: enrollment, mTLS dial-out, mux tunnel holder, loopback forwarder, E2E endpoint.
relay-e2e/ P4 Shared E2E crypto core (handshake state machine, AEAD envelope), imported by agent/ and the browser bundle.
relay-web/ P6 Browser bundle served from the tenant subdomain: login, dashboard, client-side E2E + preview.

The upgraded discipline: byte-shuttle → CIPHERTEXT-shuttle. The relay and the agent move opaque, authenticated, per-tenant-routed blobs and parse zero terminal semantics (INV2, INV11).


1. Overview & phasing

Three phases, mapped 1:1 to EXPLORE §7. Each plan's tasks are tagged with the phase they land in; a plan spans phases (e.g. TRANSPORT ships an frp scaffold in v0.8 and the native mux in v0.9).

v0.8 — MVP single-node relay ("prove the café demo")

The pitch is the demo (EXPLORE §5): open alice.term.<domain> on a phone, type a token, land in the laptop's shell — no router/VPN/static-IP configured. Deliberate shortcuts, retired later:

  • frp scaffold as the transport stepping-stone (P1) — frps on one VPS + frpc-wrapped agent (P2).
  • Flat SQLite account table, manual provisioning (P3): { accountId, subdomain, agentToken(hashed), clientToken(hashed) }.
  • Shared clientToken password gate + signed cookie at the relay edge (P6) — the auth the base app never had.
  • No E2E, no Passkey, no capability tokens yet — but the frame path is designed so ciphertext drops in without reshaping it.
  • Plans active: P1 (frp scaffold + native-mux frame spec written but not yet the substrate), P2 (frpc-wrap + npx …agent pair skeleton), P3 (flat table), P6 (password gate). P4/P5 write their contracts (so v0.9/v0.10 don't reshape data) but ship no runtime.

v0.9 — Multi-tenant SaaS (native mux, real accounts, dashboard, metering)

Retire every v0.8 shortcut except plaintext:

  • Native WS mux replaces frp (P1) — the §4.1 frame format becomes the real substrate; per-stream flow control, heartbeat, reconnection.
  • Ed25519 pairing + mTLS with short-lived rotating certs (P2 + P3 + P5) — DB stores only public keys (INV4).
  • Postgres control plane (P3): immutable accounts/hosts/sessions, pairing-code issuance/redemption, per-tenant subdomain assignment, Redis routing table (host_id → relay_node heartbeat-TTL), metering hooks (paired hosts + concurrent viewers), graceful relay-node drain.
  • Capability tokens on the WS upgrade + deny-by-default tenant authz + the CI cross-tenant tripwire (P5).
  • Self-serve dashboard: host ●online status, add-machine flow, static-binary agent + service install (P6 + P2).

v0.10 — E2E-hardening (the non-negotiable phase, done BEFORE scaling users)

  • Full browser↔agent E2E (P4): authenticated X25519 ECDH through the relay, host-key TOFU/pinning, AEAD frames with per-message nonce + monotonic sequence, session-key lifecycle, authenticated multi-device key distribution.
  • Passkey/WebAuthn primary + step-up before opening a session + fast global/per-host revocation + immutable zero-payload audit log (P5).
  • CLIENT-SIDE preview rendering (P6) — server previews die under E2E; the key-holding browser decrypts + renders a read-only xterm.
  • Third-party pentest targeting tenant-crossing and E2E-MITM-by-malicious-relay.

2. Dependency DAG across the 6 plans

                         relay-contracts/  (§4 FROZEN — this INDEX)
                                   │  every plan imports it read-only
                                   ▼
                    ┌──────── P1 TRANSPORT ────────┐   (mux frame + stream lifecycle = the substrate)
                    │                              │
                    ▼                              ▼
             P2 AGENT ◀────────── mux ──────────▶ P3 CONTROL PLANE
        (dial-out, mTLS, holds tunnel,      (accounts, routing table, pairing
         forwards to :3000, E2E endpoint)    issuance, mTLS cert authority, metering)
                    │                              │
                    └───────────┬──────────────────┘
                                ▼
              ┌────────── P5 AUTH & ISOLATION ──────────┐  (capability tokens, deny-by-default
              │   enforces authz on P1 upgrade + P3      │   authz, mTLS verify, CI tripwire,
              │   connect/reattach; cert rotation         │   revocation, audit)
              │                                          │
              │            P4 E2E ──────────────────────┤  (rides P1 DATA frames as opaque
              │   (handshake through relay, AEAD          │   ciphertext; keys bound to P2
              │    envelope, key lifecycle)               │   enrollment pubkey + P5 device auth)
              └───────────────────┬──────────────────────┘
                                  ▼
                          P6 FRONTEND
        (login/passkey, dashboard, connect via subdomain, browser-side E2E
         via SubtleCrypto, CLIENT-SIDE preview render; core xterm byte path untouched)

Ordering rules (what must exist before what):

  1. relay-contracts/ (§4) is W0 for the whole program — frozen before any plan's W1. A plan needing a new shared field changes it here (a coordination point), never locally.
  2. P1 TRANSPORT before P2 AGENT & P3 CONTROL PLANE — both consume the §4.1 mux frame + stream lifecycle. (In v0.8 the frp scaffold lets P2/P3 start against a stub; the native-mux swap in v0.9 is contract-compatible by construction.)
  3. P2 + P3 before P5 AUTH — capability tokens sign over account_id/host_id from the P3 registries; mTLS verifies P2's enrolled pubkey.
  4. P2 + P3 before P4 E2E — the E2E handshake pins the host's enrollment pubkey (P2) fetched from the host registry (P3); relay forwarding of handshake frames uses P1 DATA frames.
  5. P4 + P5 before P6 FRONTEND — the browser needs the capability-token issuance path (P5) and the E2E core (P4) before it can connect + decrypt + render client-side previews.
  6. Cross-cutting: every plan cites its enforced invariants (§3). No plan may weaken the base-app Origin check; it is retained AND augmented by a capability token (INV6/INV15).

2.1 W0 task — author relay-contracts/ (INDEX-owned; FIX 1)

Every plan imports relay-contracts/ read-only, but no plan owned authoring it (FIX 1) — so the INDEX owns one W0 task, W0-CONTRACTS, that builds and freezes it before any plan's W1 dispatches.

  • Owns (disjoint from all six plans): relay-contracts/package.json, relay-contracts/tsconfig.json, relay-contracts/src/**, relay-contracts/test/**. Dependency-free — no ws/pg/DOM ambient — so agent (P2), relay (P1/P3), auth (P5), E2E core (P4), and browser (P6) can all import it.
  • Authors the entire §4 surface as Zod schemas + inferred TS types (string-literal unions, not enums):
    • §4.1 MuxFrameType/MuxFrameHeader/MuxOpen; encodeMuxFrame/decodeMuxFrame/decodeHeader; CBOR payload codecs (encodeOpen/decodeOpen, encodeWindowUpdate/decodeWindowUpdate, encodeGoaway/decodeGoaway); GoAwayReason + decodeGoAwayReason.
    • §4.2 HostRecord/HostStatus/PlanTier/RouteEntry; the FIX 4 teardown shapes (RevocationScope/KillSignal/RevocationBus, RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS); the INV8 version-table DDL (host_versions/hosts_current).
    • §4.3 CapabilityToken/CapabilityRight/verifyCapabilityToken; the FIX 5 subprotocol constants (APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX, encodeTokenSubprotocol, extractTokenFromSubprotocols).
    • §4.4 the FIX 2 evolved E2E surface (AeadKey, DirectionalKeys, sync sealFrame/openFrame, E2ESession/createE2ESession, HandshakeResult, ClientHello/HostHello/E2EEnvelope, buildClientHandshake/AuthorizedDeviceContext/DeviceAuthProofProvider, HKDF label constants) plus the FIX 3 replay surface (ReplayKeyParams, deriveContentKey, sealReplayFrame, openReplayCiphertext).
    • §4.5 EnrollResult (incl. hostContentSecret, FIX 3) + PairingCodeRecord.
  • Verify: tsc --noEmit strict + a KAT/round-trip test per codec (mux frame, CBOR payloads, envelope, subprotocol) so P1↔P2↔P4↔P6 agree byte-for-byte. Freeze gate: all §4 exports test-green before W0-CONTRACTS is done; thereafter changed only through this INDEX (§2 rule 1). Crypto implementations of the §4.4 functions live in P4's relay-e2e/ (which re-exports them); relay-contracts owns the shapes.

3. Cross-cutting SECURITY INVARIANTS (numbered, testable)

These are program-wide. Each of the 6 plans MUST list which invariants it enforces and ship a test per invariant it owns. Violating any INV blocks merge (CRITICAL, per code-review.md).

ID Invariant Testable assertion Primary owners
INV1 Cross-tenant isolation — A can never reach B. No code path resolves a host by raw address/port/user-supplied hostname; the client names only a host_id it is authorized for. Permanent CI tripwire: device authenticated as account A requesting host_id owned by B → 403, on both first connect and reattach. Fuzz host_id guessing → 403. P5 (owns tripwire), P1, P3
INV2 Relay handles only ciphertext. The data plane never holds plaintext terminal bytes; DATA-frame payloads are opaque AEAD envelopes (§4.4). Inject a known plaintext marker through the tunnel; assert it never appears in relay memory dumps / buffers / logs. Relay code imports no xterm/ANSI parser. P1, P4, P2
INV3 account_id always derives from the authenticated principal — NEVER client-supplied. No request/frame field named account_id/tenant_id is ever trusted as source of truth. Send a forged account_id in body/query/frame → ignored; authz uses only the session/cert-derived principal. Static check: no read of client account_id in an authz decision. P5, P3, P6
INV4 No shared secrets — per-host asymmetric identity. Agents authenticate with a per-host Ed25519 key + mTLS; the DB stores only public keys. DB dump contains no private key / bearer secret capable of impersonating a host. Enrollment private key never leaves the host process. P2, P3, P5
INV5 No plaintext / secret at rest. Ciphertext-only buffers with short TTL on the relay; control-plane stores hashes/pubkeys, never raw tokens or shell bytes. Grep at-rest stores (Redis/PG/disk) for plaintext markers and secret material → none. Ring/replay buffers hold ciphertext. P1, P3, P4
INV6 Deny-by-default authz on connect AND reattach. Every WS upgrade and every session reattach re-validates session.account_id == host.account_id; default is reject. Drop the token → reject. Reattach with a session_id not owned by the principal → 403. No "allow if unspecified" branch. P5, P1, P3
INV7 Stateless data plane. A relay node holds no durable tenant state; a crash loses nothing (agents reconnect + re-register). Kill a node mid-session → agent reconnects elsewhere, PTY survives, no data loss; node process has no DB-of-record. P1, P3
INV8 Immutable records + atomic snapshot swap. Account/host/routing records are never mutated in place; updates create a new record/snapshot swapped atomically. Concurrent update test observes only whole old or whole new snapshot, never a torn read. No in-place field mutation in registries. P3
INV9 Secrets in env/secret-manager, validated at startup, never logged. Startup fails fast if a required secret is missing/invalid. Log scan asserts zero secret material emitted. all
INV10 Immutable audit log with ZERO payload. Every attach/manage/kill/enroll/revoke is logged; terminal payload is never logged; cross-tenant-crossing attempts raise an alert. Audit entries contain metadata only (principal, host_id, action, ts) — never keystrokes/output. A → B attempt emits an alert event. P5, P3
INV11 No terminal parsing in relay/agent (ciphertext/byte-shuttle discipline). Neither relay nor agent interprets ANSI/attach/input/resize semantics. Relay/agent codebase has no dependency on terminal parsers; frames pass through opaque. P1, P2
INV12 Fast revocation kills live tunnels in seconds. Global + per-host revocation stops cert renewal AND tears down the live tunnel. Revoke a host → its live tunnel drops and reconnect is refused within seconds; capability tokens for it stop validating. P5, P3, P2
INV13 Anti-replay/injection: per-message nonce + strictly monotonic sequence. AEAD frames carry a unique nonce and a monotonic seq; out-of-order/duplicate/tampered frames are rejected. Replay a captured frame → rejected. Reorder seq → rejected. Flip a ciphertext bit → AEAD tag fails. P4
INV14 mTLS with SPIFFE-style short-lived, auto-rotating certs. Agent↔relay is mutually authenticated; certs are short-TTL and rotate without downtime. Expired cert → handshake refused. Rotation mid-tunnel is seamless. CA never signs a cert for a pubkey not in the host registry. P5, P2
INV15 Capability token required on the WS upgrade. Origin/CSWSH check is retained AND a signed capability token (host + rights scope) is required; token scopes exactly one host and a rights subset. Upgrade without a valid token → 401. Token scoped to attach cannot kill. Foreign-Origin still 401 (base-app behavior retained). P5, P1, P6

4. FROZEN SHARED CONTRACTS

These live in relay-contracts/ as Zod schemas + inferred TS types (validation at every boundary is mandatory — coding-style.md "Input Validation"). Types are PascalCase, functions camelCase, unions are string-literal unions, not enums. No plan may redefine any of these locally. Byte-level fields are big-endian (network order) unless noted. Wire values are hex here.

4.1 Mux frame format & stream lifecycle (P1 owns; P2 consumes)

One physical outbound wss:// per host carries N sessions × M mirrored clients + heartbeat. Each inbound browser connection ⇒ one logical stream ⇒ one fresh ws://127.0.0.1:3000 on the host.

Frame header — 15 bytes fixed, then length bytes of payload:

offset size field         meaning
  0     1   version       0x01
  1     1   type          0x01 OPEN · 0x02 DATA · 0x03 CLOSE · 0x04 PING · 0x05 PONG · 0x06 WINDOW_UPDATE · 0x07 GOAWAY
  2     1   flags         bit0 FIN (last DATA on stream) · bit1 RST (abnormal close) · others reserved=0
  3     4   streamId      uint32; 0 = connection-level control (PING/PONG/GOAWAY/WINDOW_UPDATE for whole link)
  7     8   payloadLen    uint64 byte length of payload (MUST be ≤ negotiated maxFrameBytes)
 15   var   payload       opaque bytes (see per-type below)

Per-type payload:

  • OPEN (relay→agent): CBOR of MuxOpen { streamId, subdomain, requestPath, originHeader, remoteAddrHash, capabilityTokenRef }. Agent dials ws://127.0.0.1:3000<requestPath> replaying Origin: originHeader. The relay has already authorized this stream (P5); the agent trusts the authenticated tunnel. Carries no terminal bytes.
  • DATA: opaque ciphertext — the E2E encrypted envelope (§4.4) once E2E ships; a raw WS payload passthrough in v0.8 pre-E2E. The relay never inspects it (INV2/INV11).
  • CLOSE: empty; flags.RST distinguishes error close from graceful. Closing a stream on either side frees the paired localhost socket.
  • PING/PONG (streamId 0): 8-byte opaque token echoed back; heartbeat every 15s, miss ⇒ tunnel dead ⇒ agent reconnects.
  • WINDOW_UPDATE: uint32 credit increment for streamId (or 0 for the whole link) — credit-based per-stream flow control so one heavy vim/top redraw can't starve another stream. Initial window is negotiated at tunnel setup; a sender blocks a stream when its credit hits 0.
  • GOAWAY (streamId 0): uint32 lastStreamId + uint32 reason — graceful drain (EXPLORE §3 relay-node drain): agent finishes in-flight streams then reconnects elsewhere. Reason codes are the frozen GoAwayReason map below (1 operatorDrain · 2 revoked · 3 shutdown) — the same codes P1 drain.ts DRAIN_REASON and P5 revocation (FIX 4) emit; revoked forces grace-to-0.

Stream lifecycle: OPEN → (DATA | WINDOW_UPDATE)* → CLOSE. Illegal transitions (DATA before OPEN, DATA after CLOSE, unknown streamId) ⇒ RST that stream, never the tunnel. streamIds are allocated by the relay, monotonically increasing per tunnel, never reused within a tunnel's lifetime.

export type MuxFrameType = 'open' | 'data' | 'close' | 'ping' | 'pong' | 'windowUpdate' | 'goaway'
export interface MuxFrameHeader {
  readonly version: 1
  readonly type: MuxFrameType
  readonly fin: boolean
  readonly rst: boolean
  readonly streamId: number      // uint32; 0 = connection-level
  readonly payloadLen: number    // uint64 (safe-int guarded ≤ maxFrameBytes)
}
export interface MuxOpen {
  readonly streamId: number
  readonly subdomain: string
  readonly requestPath: string       // e.g. '/term?join=<id>' — opaque passthrough
  readonly originHeader: string      // real browser Origin, validated end-to-end
  readonly remoteAddrHash: string    // salted hash of client IP (audit only, INV10)
  readonly capabilityTokenRef: string // jti of the token that authorized this stream (P5)
}
export function encodeMuxFrame(h: MuxFrameHeader, payload: Uint8Array): Uint8Array
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array }
// Typed control-frame payload codecs (CBOR) — authored by W0-CONTRACTS (§2.1), consumed by P1/P2:
export function encodeOpen(open: MuxOpen): Uint8Array
export function decodeOpen(payload: Uint8Array): MuxOpen                       // Zod-guarded; throws on bad shape
export function encodeWindowUpdate(credit: number): Uint8Array                 // uint32
export function decodeWindowUpdate(payload: Uint8Array): number
export function encodeGoaway(lastStreamId: number, reason: GoAwayReason): Uint8Array
export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: GoAwayReason }
// GOAWAY reason as a string-literal union (NOT an enum); wire code ⇄ label via decodeGoAwayReason:
export type GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'
export function decodeGoAwayReason(code: number): GoAwayReason                 // 1→operatorDrain · 2→revoked · 3→shutdown; else throws

4.2 Account / host data model (P3 owns; Postgres = ownership source of truth, Redis = live location)

Immutable records (INV8): updates insert a new row / swap a snapshot; no in-place mutation.

accounts
  account_id      uuid  PK        -- unguessable, never recycled
  plan            text            -- 'free'|'personal'|'pro'|'team'  (string-literal union)
  created_at      timestamptz
  status          text            -- 'active'|'suspended'
  -- auth principals join via webauthn_credentials / oidc_identities (P5-owned tables)

hosts
  host_id         uuid  PK        -- unguessable UUIDv4, NEVER recycled (INV1)
  account_id      uuid  FK -> accounts   -- ownership source of truth
  subdomain       text  UNIQUE    -- 'alice' in alice.term.<domain>; stable across reconnects
  agent_pubkey    bytea           -- Ed25519 PUBLIC key only (INV4); private key never leaves host
  enroll_fpr      text            -- fingerprint of agent_pubkey, pinned by browser for E2E TOFU (§4.4)
  status          text            -- 'online'|'offline'|'draining'|'revoked'  (string-literal union)
  last_seen       timestamptz     -- heartbeat freshness
  created_at      timestamptz
  revoked_at      timestamptz NULL -- set on revocation (INV12); status flips to 'revoked'

sessions
  session_id      uuid  PK        -- base-app sessionId, opaque to relay; unguessable
  host_id         uuid  FK -> hosts
  account_id      uuid            -- DENORMALIZED for O(1) deny-by-default authz (INV3/INV6)
  created_at      timestamptz
  last_attach_at  timestamptz

pairing_codes                     -- §4.5
  code_hash       text  PK        -- hash of single-use code; raw code never stored (INV5)
  account_id      uuid  FK
  expires_at      timestamptz     -- short TTL
  redeemed_at     timestamptz NULL -- single-use: non-null ⇒ spent

-- Redis (ephemeral, heartbeat-TTL) — NOT source of truth:
route:{host_id} -> { relayNodeId, updatedAt }   EXPIRE = heartbeat_ttl   -- live routing table (INV7)
revoked:{jti}   -> 1                             EXPIRE = token_exp        -- capability-token revocation (INV12)
export type HostStatus = 'online' | 'offline' | 'draining' | 'revoked'
export type PlanTier = 'free' | 'personal' | 'pro' | 'team'
export interface HostRecord {
  readonly hostId: string; readonly accountId: string; readonly subdomain: string
  readonly agentPubkey: Uint8Array; readonly enrollFpr: string
  readonly status: HostStatus; readonly lastSeen: string; readonly createdAt: string
  readonly revokedAt: string | null
}
export interface RouteEntry { readonly relayNodeId: string; readonly updatedAt: string }

Control→data-plane revocation teardown (FIX 4 — INV12). Fast revocation must tear down an already-open tunnel, not merely refuse the next connect. The teardown channel + signal shape are frozen here (promoted out of relay-auth so P1 does not import P5, keeping the DAG acyclic):

  • Redis pub/sub channel: relay:revocations — the one named control→data-plane bus. P3/P5 PUBLISH a KillSignal on it (P5 revoke() after marking revoked:{jti} / flipping hosts.status='revoked'); every P1 relay node SUBSCRIBES and, per affected stream, injects a §4.1 CLOSE+flags.RST (host/account scope) or a connection-level GOAWAY (global scope) using the existing frozen §4.1 wire — no new frame type. Teardown budget: REVOCATION_PUSH_BUDGET_MS = 2000. P3 OQ4's "control→node drain channel" reconciles to this exact channel; P5 stays socket-free (publishes only).
// FROZEN in relay-contracts (§4.2). P5 publishes; P1 subscribes; P1's killsScope() predicate selects streams.
export type RevocationScope =
  | { readonly kind: 'host'; readonly hostId: string }
  | { readonly kind: 'account'; readonly accountId: string }
  | { readonly kind: 'global' }
export interface KillSignal {
  readonly scope: RevocationScope
  readonly at: number          // epoch seconds the revocation was issued
  readonly reason: string      // metadata only, ZERO payload (INV10)
}
export interface RevocationBus { publish(signal: KillSignal): Promise<void> }   // P3/P1 implement the `relay:revocations` transport
export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const
export const REVOCATION_PUSH_BUDGET_MS = 2000 as const

INV8 version-table DDL (immutable records + atomic snapshot swap). Mutable columns above (hosts.status/last_seen, sessions.last_attach_at) are never updated in place; a new version row is inserted and the current pointer is swapped atomically, so a concurrent read sees whole-old or whole-new:

host_versions
  version_id   uuid PK
  host_id      uuid FK -> hosts
  snapshot     jsonb            -- full immutable HostRecord snapshot
  created_at   timestamptz
  supersedes   uuid NULL        -- prior version_id (audit chain)
hosts_current
  host_id      uuid PK
  version_id   uuid FK -> host_versions   -- CAS-swapped pointer to the live snapshot (atomic)

4.3 Capability token (P5 owns; verified on every P1 upgrade and P3 connect/reattach)

Signed, stateless, short-TTL (Ed25519-signed PASETO/JWS). sub and host come from the authenticated principal + registry (INV3) — never from client input. Revocable by jti (INV12).

export type CapabilityRight = 'attach' | 'manage' | 'kill'
export interface CapabilityToken {
  readonly sub: string          // principal id (account/device) — from authenticated session, NOT client
  readonly aud: string          // subdomain this token is valid at (Host-confusion guard, INV1)
  readonly host: string         // exact host_id scope — single host, never wildcard
  readonly rights: readonly CapabilityRight[] // least-privilege subset; 'attach' ⊉ 'kill'
  readonly iat: number; readonly exp: number  // short TTL
  readonly jti: string          // unique id for revocation-list lookup
  // signature over the above by the control-plane signing key (verified at the relay edge)
}
export function verifyCapabilityToken(raw: string, expectedAud: string, now: number): CapabilityToken

Rule: a share-QR / device grant issues a token scoped to one host, a rights subset, expiring — granting one session read-or-write without granting the account (EXPLORE §4a.3).

WS-upgrade token transport — FROZEN wire format (FIX 5). The browser's native WebSocket API cannot set request headers, so the token rides the Sec-WebSocket-Protocol subprotocol list (preferred) or a short-lived HttpOnly; Secure; SameSite=Strict cookie (fallback). Never the query string (leaks into proxy/CDN/frp logs, history, Referer). The subprotocol encoding is frozen so P1 (relay) and P6 (browser) agree exactly — they diverged locally (relay.capability.v1 vs term.relay.v1) and this pins one:

// FROZEN in relay-contracts (§4.3). The client opens:
//   new WebSocket(url, [APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX + base64url(rawToken)])
export const APP_SUBPROTOCOL = 'term.relay.v1' as const          // the app subprotocol — the ONLY value ever accepted/echoed
export const TOKEN_SUBPROTOCOL_PREFIX = 'term.token.' as const   // the token entry = this prefix + base64url(token)
export function encodeTokenSubprotocol(rawToken: string): string // => 'term.token.' + base64url(rawToken)
export function extractTokenFromSubprotocols(values: readonly string[]): string | null // strips prefix, base64url-decodes; null if absent

Echo rule (MUST): the relay reads the token from the term.token.<b64u> entry, strips it before verify, and the accepted/echoed Sec-WebSocket-Protocol in the handshake response MUST be term.relay.v1 (the app subprotocol), NEVER the token entry — echoing the token would leak the bearer secret into logs and the handshake response. After open, the client asserts ws.protocol === 'term.relay.v1' and tears down otherwise.

4.4 E2E handshake sequence & encrypted frame envelope (P4 owns; relay forwards, never derives)

Authenticated X25519 ECDH THROUGH the relay — the relay forwards these as opaque §4.1 DATA frames and cannot derive the key (INV2). The browser pins the host's enrollment fingerprint (enroll_fpr from §4.2, TOFU or pinned) so a malicious relay cannot MITM.

Browser (device, authenticated via P5)                 Host-agent (enrolled, §4.5)
  │  client_hello ─────────────────(relay DATA)──────────▶  │
  │    { clientEphPub(X25519), clientNonce, aeadOffer,       │
  │      deviceAuthProof }  // proof = account-derived, binds device to account (INV3)
  │                                                          │
  │  ◀────────────────(relay DATA)──────── host_hello ──────│
  │    { hostEphPub(X25519), hostNonce, aeadChoice,          │
  │      enrollFpr, sig }  // sig = Ed25519(agent_pubkey) over transcript
  │                                                          │
  │  verify sig with pinned enrollFpr (§4.2); mismatch ⇒ ABORT (no MITM)
  │  sharedSecret = X25519(clientEphPriv, hostEphPub)        │  = X25519(hostEphPriv, clientEphPub)
  │  master  = HKDF(sharedSecret, salt=clientNonce||hostNonce, info="relay-e2e/v1")
  │  keys    = DirectionalKeys{ c2h, h2c } = HKDF-Expand(master, "relay-e2e/v1/c2h" | "…/h2c")
  │  ── both sides hold BOTH direction subkeys; relay never did ──   │

Direction-split (FIX 2 — anti-reflection / anti-nonce-reuse): the master secret is split into two disjoint AEAD subkeys — c2h (client→host) and h2c (host→client) — so a deterministic per-seq nonce never produces the same (key, nonce) in both directions and a relay cannot reflect a c2h frame back as h2c. Live frames use these ephemeral subkeys; replay/preview frames use the recoverable K_content below.

This block supersedes the original single-sessionKey: CryptoKey shape (FIX 2). P4 converged on an AeadKey opaque wrapper (not CryptoKey) with synchronous sealFrame/openFrame (audited @noble/ciphers, isomorphic, and XChaCha which WebCrypto lacks), aad = directionLabel‖seq, and direction-split DirectionalKeys. P2 T15 and P6 T8/T9 cite THIS surface verbatim — no local redefinition.

export type AeadAlg = 'aes-256-gcm' | 'xchacha20-poly1305'
export type AeadKey = { readonly __aeadKey: unique symbol }   // opaque wrapper over a 32-byte key (NOT CryptoKey)
export type SessionRole = 'client' | 'host'

export interface ClientHello {
  readonly clientEphPub: Uint8Array; readonly clientNonce: Uint8Array
  readonly aeadOffer: readonly AeadAlg[]
  readonly deviceAuthProof: string   // per-handshake, bound to clientEphPub‖clientNonce (FIX 6b); P5 mints, P4 shape-validates
}
export interface HostHello {
  readonly hostEphPub: Uint8Array; readonly hostNonce: Uint8Array
  readonly aeadChoice: AeadAlg; readonly enrollFpr: string; readonly sig: Uint8Array
}

// Direction-split subkeys (FIX 2). Client seals with c2h & opens with h2c; host seals with h2c & opens with c2h.
export interface DirectionalKeys { readonly c2h: AeadKey; readonly h2c: AeadKey }
export const HKDF_INFO = 'relay-e2e/v1' as const
export const HKDF_INFO_C2H = 'relay-e2e/v1/c2h' as const
export const HKDF_INFO_H2C = 'relay-e2e/v1/h2c' as const

// Encrypted frame envelope carried inside every §4.1 DATA payload once E2E is live:
export interface E2EEnvelope {
  readonly seq: bigint            // strictly monotonic per direction (anti-replay, INV13)
  readonly nonce: Uint8Array      // DETERMINISTIC f(seq), 12B GCM / 24B XChaCha (no random branch — see P4 §T9)
  readonly ciphertext: Uint8Array // AEAD(subkey, plaintext, aad = directionLabel‖seq)
  readonly tag: Uint8Array        // AEAD auth tag; failure ⇒ drop + tear down
}

// SYNCHRONOUS single-key primitives (key: AeadKey, NOT CryptoKey). aad = directionLabel‖seq (u64 BE).
export function sealFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
export function openFrame(key: AeadKey, env: E2EEnvelope, expectedSeq: bigint): Uint8Array

// Stateful directional wrapper the agent/browser use over a stream (holds BOTH subkeys + role + seq guards):
export interface E2ESession {
  readonly role: SessionRole
  seal(plaintext: Uint8Array): Uint8Array    // write subkey → next send seq → sealFrame → encoded DATA payload
  open(dataPayload: Uint8Array): Uint8Array  // read subkey → decode → SequenceGuard.accept → openFrame
  rederive(next: HandshakeResult): void      // re-key on refresh/reconnect; installs new DirectionalKeys; resets seq guards
}
export interface HandshakeResult {
  readonly keys: DirectionalKeys; readonly aead: AeadAlg; readonly transcript: Uint8Array
}
export interface ClientHandshake {
  start(): Promise<ClientHello>
  onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise<HandshakeResult>  // agentPubkey from P3 registry, independent TLS channel
}
export function createE2ESession(role: SessionRole, result: HandshakeResult): E2ESession
// Handshake factory P6 calls; ctx carries the P5 device-proof provider, TOFU pin store, and host-scoped secret (§4.5):
export function buildClientHandshake(ctx: AuthorizedDeviceContext, hostId: string, aeadOffer: readonly AeadAlg[]): ClientHandshake
export interface AuthorizedDeviceContext {
  readonly deviceAuthProofProvider: DeviceAuthProofProvider  // P5 — mints per-handshake proof bound to clientEphPub‖clientNonce (FIX 6b)
  readonly pinStore: DevicePinStore                          // P6 — TOFU/pin over recomputed fingerprints
  readonly hostContentSecret: Uint8Array                     // §4.5 host-scoped secret; NEVER logged, NEVER sent to relay
}
export interface DeviceAuthProofProvider {
  proofFor(hostId: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }): Promise<string>
}

// Recoverable replay content-key (FIX 3) — live frames use ephemeral DirectionalKeys; ring-buffer/preview
// frames use K_content, recoverable after reload so "refresh and the session survives" holds under E2E:
export interface ReplayKeyParams {
  readonly hostContentSecret: Uint8Array   // host-scoped, delivered wrapped-to-agent at §4.5 enrollment (NOT a raw account secret)
  readonly sessionId: string               // base-app sessionId — salt; K_content is per-session, per-host
  readonly alg: AeadAlg
}
export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const
export function deriveContentKey(p: ReplayKeyParams): AeadKey                             // HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO)
export function sealReplayFrame(k: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope  // agent seals replay-bound output (P2)
export function openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array         // browser client-side replay/preview decrypt (P6)

Session-key lifecycle: ephemeral direction subkeys are derived on attach and re-derived on refresh/reconnect (E2ESession.rederive, forward-secret). Ring-buffer replay of ciphertext survives a reload because it is sealed under the recoverable K_content (deriveContentKey), not the ephemeral subkeys (EXPLORE §4c). Multi-device: each authorized device runs its own handshake gated by an account-derived deviceAuthProof and re-derives the identical K_content over an authenticated channel — NOT a plaintext QR (INV3). DevicePinStore / SequenceGuard are P4-internal helper types (not §4 wire).

4.5 Pairing protocol (P3 issues, P2 redeems)

1. ISSUE   (human authenticated, P5)  → control plane mints single-use short-TTL pairing code;
                                        stores { code_hash, account_id, expires_at } (raw code never stored, INV5)
2. GENERATE (host, P2)  `npx web-terminal-agent pair ABCD-1234`
                                        → agent generates Ed25519 keypair LOCALLY (private key stays, INV4)
3. REDEEM  (host → control plane, TLS) POST /enroll { code, agentPubkey, csr }
4. BIND    (control plane, P3)         verify code unredeemed+unexpired → bind host_id ↔ account_id,
                                        store agent_pubkey + enroll_fpr, assign stable subdomain,
                                        sign short-lived mTLS cert (INV14), MINT + WRAP hostContentSecret
                                        (FIX 3), mark code redeemed (single-use)
5. RETURN                              EnrollResult { host_id, subdomain, cert, caChain, hostContentSecret }
                                        → agent installs as service; unwraps + stores hostContentSecret in its keystore

Host-scoped content secret (FIX 3 — recoverable replay key delivery). EnrollResult gains hostContentSecret, the input to §4.4 deriveContentKey (ReplayKeyParams). Single owner: P3 mints and wraps it at BIND — a per-host random secret sealed/wrapped to the host's own enrolled Ed25519 identity (agent_pubkey), so it is host-scoped (NOT a raw account-wide secret) and the control plane can invalidate that specific wrap on host/device revocation (INV12) without an account-wide rotation. P2 receives it in EnrollResult, unwraps with its enrollment private key (never leaves the host, INV4), and holds it for deriveContentKey. Authorized browser devices obtain the same hostContentSecret via P5 (unwrapped only after auth/step-up) → re-derive the identical K_content → decrypt replayed ciphertext after a reload.

// FROZEN in relay-contracts (§4.5). hostContentSecret is wrapped-to-agent-identity bytes; P2 unwraps locally.
export interface EnrollResult {
  readonly hostId: string; readonly subdomain: string
  readonly cert: string; readonly caChain: string
  readonly hostContentSecret: Uint8Array   // FIX 3 — wrapped to agent_pubkey by P3 at BIND; feeds §4.4 deriveContentKey
}
export interface PairingCodeRecord {
  readonly codeHash: string; readonly accountId: string
  readonly expiresAt: string; readonly redeemedAt: string | null
}

Redemption is atomic + single-use: a redeemed_at compare-and-set prevents double-spend. Steady state after pairing: agent dials wss://relay/agent, proves key possession via mTLS, relay writes route:{host_id} in Redis with heartbeat TTL (INV7). Reconnect uses the same 1/2/4…cap-30s backoff the base app already ships (EXPLORE §3).


5. Per-plan charters (scope fences — no overlap, no gaps)

P1 — PLAN_RELAY_TRANSPORT.md · Tunnel & Mux Protocol + Relay Data Plane. Owns the native WS multiplexing protocol (§4.1 frame format, stream open/close lifecycle, per-stream credit-based flow-control/backpressure, 15s heartbeat, reconnection) between host-agent and the stateless relay data plane; TLS termination; per-tenant subdomain routing (route by authenticated session, not Host header, INV1); opaque CIPHERTEXT forwarding (never parses terminal data, INV2/INV11); and the v0.8 frp-based MVP scaffold as an explicit stepping-stone toward the native mux. Enforces INV1, INV2, INV5, INV6, INV7, INV11, INV15. Does not own accounts, crypto keys, or UI.

P2 — PLAN_RELAY_AGENT.md · Host Agent (agent/). Owns enrollment/pairing redemption (§4.5: locally-generated Ed25519 keypair + single-use pairing-code, private key never leaves host, INV4), outbound dial + mTLS with short-lived certs (INV14), registration, holding the §4.1 mux tunnel, forwarding logical streams to the UNCHANGED web-terminal at 127.0.0.1:3000, the agent-side E2E endpoint (§4.4 host_hello/sealFrame), reconnection/backoff (reuse the 1/2/4…cap-30s policy), and distribution (npx …-agent pair → static binary; launchd/systemd service install). Enforces INV2, INV4, INV11, INV12, INV14. Does not own the frame spec (imports §4.1 from P1) or account issuance.

P3 — PLAN_RELAY_CONTROLPLANE.md · Control Plane & Accounts. Owns the account & host registries (§4.2 Postgres, immutable records, INV8), pairing-code issuance+redemption (§4.5), per-tenant subdomain assignment, the live routing table (Redis route:{host_id} heartbeat-TTL; Postgres = ownership source of truth, INV7), provisioning/deprovisioning APIs, billing-metering hooks (paired hosts + concurrent viewers), the mTLS cert authority (signs only pubkeys in the host registry, INV14), and graceful relay-node drain coordination (§4.1 GOAWAY). Enforces INV1, INV3, INV4, INV5, INV6, INV8, INV10, INV12. Does not own the byte path or crypto frame format.

P4 — PLAN_RELAY_E2E.md · End-to-End Encryption (relay-e2e/). Owns browser↔agent E2E: authenticated X25519 ECDH through the relay (§4.4; relay forwards, never derives), host-key pinning/TOFU bound at enrollment (browser verifies enroll_fpr from §4.2), the AEAD frame envelope (§4.4 E2EEnvelope, AES-256-GCM / XChaCha20-Poly1305, per-message nonce + monotonic sequence for anti-replay/injection, INV13), session-key lifecycle + re-derivation on refresh, and multi-device key distribution over an authenticated (account-derived) channel — not a plaintext QR. Documents the relay-sees-only-ciphertext guarantee and the features it kills (server previews → client-side, P6). Enforces INV2, INV5, INV13. Does not own transport framing or human auth.

P5 — PLAN_RELAY_AUTH_ISOLATION.md · Auth & Tenant Isolation. Owns human auth (Passkey/WebAuthn primary, TOTP fallback, never SMS, OIDC SSO for teams, step-up before opening a session); agent auth (per-host mTLS + SPIFFE-style short-lived auto-rotating certs, DB stores only public keys, INV4/INV14); capability tokens on the WS upgrade (§4.3 host+rights scope, INV15); the deny-by-default tenant-authz model (account_id from the authenticated principal, NEVER client-supplied, INV3) enforced on every connect AND reattach (INV6); the hard cross-tenant isolation invariant (INV1) + a permanent CI tripwire test (device A → host B = 403); per-tenant rate-limits/quotas; global + per-host revocation that kills live tunnels in seconds (INV12); and an immutable audit log with zero payload logging + cross-tenant-crossing alerts (INV10). Enforces INV1, INV3, INV4, INV6, INV10, INV12, INV14, INV15. Does not own the byte path.

P6 — PLAN_RELAY_FRONTEND.md · Browser / Frontend (relay-web/). Owns the login UI (passkey), connecting through the relay served from the tenant subdomain (same-origin, scheme-following wss:, M6), browser-side E2E via Web Crypto (SubtleCrypto, consuming §4.4 relay-e2e/), CLIENT-SIDE preview rendering (server previews die under E2E — the authorized, key-holding browser decrypts + renders a read-only xterm), and the pairing/onboarding + dashboard (host ●online status, add-machine flow). The core xterm byte path in public/ stays untouched. Enforces INV3, INV15 (client side), and consumes INV2/INV13 guarantees. Does not own server-side routing, accounts, or crypto primitives.


6. Single-owner decisions (reconciliation — FIX 6)

Two capabilities were implemented in more than one plan; the DECISION now has exactly one owner and the others delegate. Consumer plans cite these verbatim — no re-implementation.

6a. WS-upgrade authorization — sole owner P5 (onUpgrade/onReattach). The full authorization decision (Origin/CSWSH retained + capability token verify + DPoP proof-of-possession + single-use jti burn + pre-auth throttle + step-up gate + per-tenant rate + deny-by-default cross-tenant gate + audit) lives only in P5 relay-auth/src/enforce/onUpgrade.ts / onReattach.ts. P1 T8 authorizeUpgrade is a THIN ADAPTER that delegates to P5 — it parses the upgrade (Origin, subprotocol token via FIX 5, subdomain), builds the UpgradeContext, calls P5, and maps the AuthzOutcome to a §4.1 MuxOpen (or a 401/403 close). P1 T8 must supply P5's UpgradeContext fields — including dpop: DpopContext and activeSessionCount — and holds no independent authz logic. P5 is the injected CapabilityVerifier/authorizer; P1 owns only the byte splice.

6b. deviceAuthProof — sole issuer/verifier P5 (relay-auth). P5 capability/device-proof.ts owns minting (signDeviceAuthProof) and verification (verifyDeviceAuthProof), binding the proof to {clientEphPub, clientNonce} (per §4.4 ClientHello) so a captured proof cannot be replayed into a different handshake. P4 consumes it as an INJECTED dependency — the §4.4 DeviceAuthProofProvider (client side) and a verifyDeviceProof(proof, { clientEphPub, clientNonce }) host-side dep — and never imports crypto identity from P5 directly. P2 obtains the verifier via P4's host-handshake wiring (createHostHandshake({ verifyDeviceProof, … })), NOT by importing from relay-e2e (the old P2 T15 import … from 'relay-e2e' was wrong). Binding param { clientEphPub, clientNonce } is unified across P2/P4/P5. deviceAuthProof issuance/verification therefore has exactly one home (P5); P4 carries + shape- validates it; P2 receives the verifier through P4.