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

607 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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`](./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`](../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`](./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.md`](./PROGRESS_LOG.md) — **orchestrator-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. `streamId`s are allocated by
the relay, monotonically increasing per tunnel, never reused within a tunnel's lifetime.
```ts
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)
```
```ts
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).
```ts
// 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).
```ts
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:
```ts
// 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.
```ts
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.
```ts
// 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`](./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`](./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`](./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`](./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`](./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`](./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.