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.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

View File

@@ -0,0 +1,821 @@
# P3 — Control Plane & Accounts — Implementation Plan
> **Plan of the 6** (see [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) §5 charter for P3).
> Owns the **stateful, low-QPS, strongly-consistent** half of the relay: the account & host
> **registries**, **pairing-code** issuance+redemption, **subdomain** assignment, the **live routing
> table**, **provisioning/deprovisioning** APIs, **billing-metering** hooks, the **mTLS cert-signing**
> service (bind-time only), and **graceful relay-node drain** coordination.
> **Source of intent**: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 (LOCKED, closed) + §3
> "control plane vs data plane". **Coordination point**: this plan **imports** the FROZEN SHARED
> CONTRACTS from INDEX §4 read-only and **redefines none of them**. Conventions follow
> [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md): stable task IDs in
> dependency waves, `Owns:` disjoint files, function-signature contracts, TDD (tests FIRST), explicit
> test cases (incl. security/negative), per-task security notes. `PROGRESS_LOG.md` is
> orchestrator-only (subagents return a ready-to-paste entry, G1).
---
## 0. Scope, boundaries, and enforced invariants
**In scope (this plan and no other):**
- Account & host **registries** — Postgres, **immutable records** (INDEX §4.2 schema is frozen; TS
`HostRecord`/`RouteEntry`/`HostStatus`/`PlanTier` imported from `relay-contracts/`).
- **Pairing** issuance + redemption + BIND (INDEX §4.5) — atomic single-use `redeemed_at` CAS.
- Per-tenant **subdomain assignment** (stable across reconnects).
- **Live routing table** — Redis `route:{host_id} → RouteEntry` with heartbeat TTL; **Postgres is the
ownership source of truth, Redis is only "where the live tunnel is right now"** (EXPLORE §3, INV7).
- **Provisioning / deprovisioning** HTTP APIs (create/suspend/delete account; add/revoke host).
- **Billing-metering hooks** — paired hosts + concurrent viewers (EXPLORE §6 cost drivers).
- **mTLS cert-signing at BIND** (INDEX §4.5 step 4) — the CA verifies **CSR proof-of-possession** and
**only** signs pubkeys already in the host registry (the INV14 registry-gate half), using a
**KMS/HSM-held, non-exportable intermediate** key (§3.1); verification cadence/rotation policy is
**P5's**, see §7.
- **Graceful relay-node drain** coordination — emit `GOAWAY` (INDEX §4.1) via the node registry.
- **Immutable audit substrate** — append-only, zero-payload writer (INV10) that P5 also calls.
**Explicitly NOT in scope (other plans — do not touch):** the mux frame codec & byte path (**P1**); the
host-agent (**P2**); the E2E crypto handshake/AEAD (**P4**); human Passkey auth, capability-token
*issuance/signing-key*, deny-by-default *enforcement at the WS upgrade*, per-tenant rate-limit
enforcement, revocation *policy*, and cross-tenant CI tripwire (**P5**); browser UI (**P6**). This plan
**consumes** `verifyCapabilityToken` (§4.3) to authorize its own admin APIs but does **not** own the
signing key — that is P5.
**Enforced invariants (INDEX §3):** **INV1** (cross-tenant isolation — ownership join is the only host
resolution path), **INV3** (`account_id` from the authenticated principal, never client-supplied),
**INV4** (per-host asymmetric identity — DB stores only public keys), **INV5** (no plaintext/secret at
rest — hash pairing codes/tokens, no shell bytes), **INV6** (deny-by-default authz on connect AND
reattach — registry provides the ownership fact), **INV8** (immutable records + atomic snapshot swap),
**INV10** (immutable audit log, zero payload), **INV12** (fast revocation kills live tunnels). Each task
lists the subset it ships a test for.
**Package:** all new code lives in a **new top-level `control-plane/` package** — the physically
separate control-plane half mandated by INDEX §0 ("`term-relay/` … physically split") and EXPLORE §3.
No edits to `src/`, `public/`, `agent/`, `relay-e2e/`, or `relay-web/`. `relay-contracts/` is imported
read-only.
---
## 1. Files & ownership (all under `control-plane/`)
| File | Task | Role |
|------|------|------|
| `control-plane/package.json`, `tsconfig.json`, `vitest.config.ts` | T1 | scaffold; deps `pg ioredis zod fastify` (+ dev `vitest @types/pg`) |
| `control-plane/src/env.ts` | T1 | startup secret/config validation (Zod), fail-fast (INV9) |
| `control-plane/db/migrations/*.sql` | T2 | Postgres DDL for §4.2 `accounts/hosts/sessions/pairing_codes` + `relay_nodes`, `metering_samples`, `audit_log` |
| `control-plane/src/db/pool.ts` | T2 | pg pool + typed `query` wrapper (parameterized only) |
| `control-plane/src/model/records.ts` | T2 | TS record types mirroring §4.2 SQL **verbatim** (`AccountRecord`, `SessionRecord`, `PairingCodeRecord`); re-exports `HostRecord`/`HostStatus`/`PlanTier`/`RouteEntry` from `relay-contracts/` |
| `control-plane/src/registry/accounts.ts` | T3 | immutable account repository |
| `control-plane/src/registry/hosts.ts` | T4 | immutable host repository (ownership source of truth) |
| `control-plane/src/registry/sessions.ts` | T5 | session repository (denormalized `account_id` for O(1) authz) |
| `control-plane/src/subdomain/assign.ts` | T6 | subdomain allocation + collision/validation |
| `control-plane/src/pairing/issue.ts` | T7 | pairing-code issuance |
| `control-plane/src/pairing/redeem.ts` | T8 | atomic single-use redemption + BIND + cert-sign hook + mint/wrap `hostContentSecret` (FIX 3) |
| `control-plane/src/ca/sign.ts` | T8 | bind-time mTLS leaf signer (registry-gated, INV14 half) |
| `control-plane/src/node-auth/identity.ts` | T9 | relay-node identity middleware — derives `nodeId` from the mTLS client-cert subject (never a request field) |
| `control-plane/src/routing/table.ts` | T9 | Redis routing table (`route:{host_id}`, heartbeat TTL) |
| `control-plane/src/routing/nodes.ts` | T10 | relay-node registry + drain coordination (GOAWAY dispatch) |
| `control-plane/src/api/provision.ts` | T11 | provisioning/deprovisioning HTTP routes (incl. v0.9 minimal deprovision) |
| `control-plane/src/api/authz.ts` | T11 | admin-API principal derivation (INV3) — consumes `verifyCapabilityToken` |
| `control-plane/src/deprovision/deprovision.ts` | T11 | v0.9 minimal deprovision (status→'revoked' + dropRoute); no fast tunnel-kill (that is T13/v0.10) |
| `control-plane/src/metering/collect.ts` | T12 | metering ingestion + immutable rollup |
| `control-plane/src/revoke/revoke.ts` | T13 | global + per-host revocation (INV12) |
| `control-plane/src/audit/log.ts` | T14 | immutable zero-payload audit writer (P5 also imports) |
| `control-plane/src/ca/rotate.ts` | T15 | CA leaf **renewal** (`renewHostLeaf`) — registry-gated, distinct file from T8 signer |
| `control-plane/src/boot/ca-wiring.ts` | T15 | CA-key / secret-manager bootstrap + KMS-signer factory; imported by `main.ts` (T11) |
| `control-plane/src/main.ts` | T11 | Fastify bootstrap; wires env → pools → routes; imports `boot/ca-wiring.ts` (T15) |
| `control-plane/test/**` | each | per-module unit + integration tests (Testcontainers PG/Redis) |
| `control-plane/src/flat-store.ts`, `control-plane/db/flat.sqlite.sql` | T0 | **v0.8** flat SQLite `{accountId, subdomain, agentToken(hash), clientToken(hash)}` shim + manual-provision CLI |
Each `src/*.ts` stays 200400 lines (800 hard max, `coding-style.md`). `records.ts` is the local
frozen-shape source for P3-internal types; if a shared field is needed elsewhere it is promoted into
`relay-contracts/` **via the INDEX**, never redeclared (open question OQ1).
---
## 2. Waves (dependency order)
```
CP0 scaffold + schema (blocks all) T0(v0.8 flat) · T1 · T2
CP1 registries (immutable repos) T3 accounts · T4 hosts · T5 sessions · T6 subdomain
│ (T3/T4/T5/T6 file-disjoint → parallel)
CP2 pairing T7 issue · T8 redeem+BIND+CA
CP3 live routing & node coordination T9 routing table (+ node-identity mw) · T10 node registry+drain
CP4 provisioning API & metering T11 provision API (+ v0.9 minimal deprovision) · T12 metering
CP5 hardening (v0.10) T13 fast revocation · T14 audit · T15 cert-rotation (ca/rotate.ts) + secret bootstrap
```
**Phase tags:** T0 = **v0.8**. T1T2 scaffold in v0.8, Postgres DDL lands **v0.9**. T3T12 = **v0.9**.
T13T15 = **v0.10**. (The v0.8 flat store is retired the moment T3/T4 land; the provisioning API in
v0.8 is a manual CLI, promoted to HTTP in T11.) **No v0.9 task depends on a v0.10 task** — T11's
`DELETE /hosts/:id` deprovision ships a **self-contained v0.9 tear-down** (`deprovision/deprovision.ts`:
host status→`'revoked'` + `dropRoute`, T4+T9 only); the *fast, within-seconds tunnel-kill / cert-stop*
hardening (INV12) is a **later, additive** upgrade in T13/v0.10 and is **not** a prerequisite for the
v0.9 route to exist. See finding-driven split in §6/§7/§8.
**Cross-plan dependencies:**
- T8 redemption is the server side of **P2** `POST /enroll` (agent generates the keypair + CSR). T8 also
**mints + wraps `hostContentSecret`** (FIX 3, INDEX §4.5) to the enrolled `agent_pubkey` and returns it in
`EnrollResult`; **P2** unwraps it locally with its enrollment private key (INV4); **P5** later hands the
same host-scoped secret to authorized browser devices (unwrapped only after auth/step-up) so they
re-derive the identical `K_content` (§4.4 `deriveContentKey`) for replay/preview decrypt.
- **T9/T10/T12 authenticate the calling relay node** by its **mTLS client-cert identity** (the
`nodeId`/`hostId` in every such RPC is derived from the verified cert subject, **never** a request
field — the relay-node↔control-plane analog of INV3). This mirrors P1's node-side mTLS credential;
the **node service-cert issuance/rotation** is a **P5/P1 boundary** — see **OQ5**.
- T9/T10 are read by **P1** (data plane looks up `route:{host_id}`; obeys `GOAWAY`).
- **T10/T13 tear down live tunnels over the FROZEN control→data-plane bus** (INDEX §4.2 FIX 4): P3
PUBLISHES a `KillSignal` on the Redis pub/sub channel `RELAY_REVOCATIONS_CHANNEL` (`'relay:revocations'`)
via `RevocationBus.publish`; **every P1 relay node SUBSCRIBES** and injects the existing §4.1
`CLOSE`+`RST` / `GOAWAY` wire (no new frame type) within `REVOCATION_PUSH_BUDGET_MS` (2000ms). **Drain
(T10) and revoke (T13) use the SAME named channel.** OQ4 is **RESOLVED** by this freeze — there is no
separate undefined "P1 dispatcher".
- T11 admin authz consumes **P5**'s `verifyCapabilityToken` (§4.3) and the P5 signing key (env).
- T13 shares the Redis `revoked:{jti}` key with **P5**; T14's writer is imported by **P5**.
- T4's `enroll_fpr` is read by **P4** (browser pins it for E2E TOFU, §4.4).
---
## 3. CP0 — scaffold & schema
### T0 · v0.8 flat SQLite store + manual-provision CLI `[v0.8]`
- **Owns**: `control-plane/src/flat-store.ts`, `control-plane/db/flat.sqlite.sql`, `control-plane/bin/provision.ts`
- **Depends**: none · **Parallel-safe**: with everything (retired by T3/T4)
- **Contract** (INDEX §1 v0.8 "flat SQLite account table"):
```ts
export interface FlatAccount { // v0.8 ONLY — no immutable-record ceremony yet
readonly accountId: string; readonly subdomain: string
readonly agentTokenHash: string; readonly clientTokenHash: string
}
export function provisionFlat(subdomain: string): Promise<{ accountId: string; agentToken: string; clientToken: string }>
export function lookupBySubdomain(subdomain: string): Promise<FlatAccount | null>
export function verifyAgentToken(subdomain: string, raw: string): Promise<boolean> // constant-time compare of hashes
```
- **TDD**: RED test asserts `provisionFlat` stores **hashes only** (grep row → no raw token, INV5);
`verifyAgentToken` true for correct / false for wrong; wrong subdomain → null.
- **Security**: tokens hashed (argon2id/scrypt), constant-time compare, raw returned once at mint.
Enforces INV5. This is the throwaway MVP gate; **all** its guarantees are re-shipped properly in CP1.
### T1 · package scaffold + startup env validation `[v0.8→v0.9]`
- **Owns**: `control-plane/package.json`, `tsconfig.json`, `vitest.config.ts`, `control-plane/src/env.ts`
- **Depends**: none · **Parallel-safe**: after this, all others
- **Contract**:
```ts
export interface ControlPlaneEnv {
readonly pgUrl: string; readonly redisUrl: string
readonly capabilitySignPubkey: Uint8Array // P5 signing PUBLIC key, to VERIFY admin tokens (INV3)
readonly caIntermediateKmsKeyRef: string // KMS/HSM key REF for the INTERMEDIATE leaf-signing key
// (non-exportable; signing = KMS sign() call, never a raw
// private key loaded into process memory — see §3.1 CA arch)
readonly caIntermediateCertPath: string // intermediate cert (public) + chain up to the offline root
readonly nodeMtlsTrustBundlePath: string // CA bundle used to VERIFY relay-node client certs (T9 node-auth)
readonly baseDomain: string // 'term.<domain>' for subdomain assembly
readonly heartbeatTtlSec: number
readonly pairingTtlSec: number // pairing-code TTL; DEFAULT 600 (10 min) if unset (§5 T7)
readonly pairingMaxRedeemAttempts: number // per-code lockout threshold; DEFAULT 5 (§5 T7/T8)
}
export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv // Zod-validated; THROWS on missing/invalid
```
- **TDD**: RED — `loadEnv({})` throws listing every missing key; a bad `pgUrl` throws; a valid map parses;
`pairingTtlSec`/`pairingMaxRedeemAttempts` default to 600/5 when unset; **CA-policy startup check**
boot fails fast if the KMS key referenced by `caIntermediateKmsKeyRef` cannot be resolved OR its key
policy does not restrict `sign` to the control-plane service principal (assert both failure paths throw
before the server binds a port).
- **Security**: INV9 — secrets come from env/secret-manager, **validated at startup, fail-fast, never
logged** (log only key *names* on failure, never values). The CA key is referenced by a **KMS/HSM ref**
and is **never inlined and never loaded raw** into control-plane memory (§3.1).
### 3.1 CA key architecture (Finding-3 hardening — the highest-value secret in the plane)
The mTLS CA can mint a valid identity for **any host across every tenant**, so its custody is specified
explicitly (it must not get *less* rigor than pairing-code hashing):
- **Two-tier hierarchy.** An **offline root CA** (kept off any online host — air-gapped / HSM, never in a
running process) signs a **short-lived intermediate** used for day-to-day leaf signing. The control
plane holds only the **intermediate**, never the root.
- **Non-exportable KMS/HSM key.** The intermediate signing key is a **non-exportable KMS/HSM key**; every
`signHostLeaf`/`renewHostLeaf` (T8/T15) performs a **KMS `sign()` call** — the raw private key is
**never** loaded into control-plane process memory or written to disk (this is stricter than "by
reference": the process can invoke signing but cannot read the key). The KMS key policy MUST restrict
the `sign` operation to the control-plane **service principal only**; T1 fails fast at startup if the
policy is broader (see TDD above).
- **Intermediate rotation cadence.** The intermediate is rotated on a fixed cadence (default **30 days**,
env-tunable) with overlap so in-flight leaves stay valid; rotation is coordinated by T15
(`boot/ca-wiring.ts` provides the KMS-signer factory; `ca/rotate.ts` re-issues leaves under the current
intermediate).
- **Compromise runbook (documented, referenced by the incident process).** *Intermediate compromise*
revoke the intermediate at the root, mint a new intermediate, force **all hosts to renew** (T15
`renewHostLeaf`) under the new intermediate; leaves signed by the old intermediate fail mTLS verify
(P5). *Root compromise* (worst case) → stand up a new root out-of-band, re-issue intermediate,
**force every host to re-enroll** (new pairing round, T7/T8); old chain distrusted at the relay edge.
- Boundary note: **leaf issuance/renewal (signing) is P3**; **rotation *cadence policy* + mTLS *handshake
verification* are P5/INV14** — this plan owns only the KMS-backed signing operation and the startup
key-policy check (OQ2).
### T2 · Postgres DDL + typed pool + record types `[v0.9]`
- **Owns**: `control-plane/db/migrations/*.sql`, `control-plane/src/db/pool.ts`, `control-plane/src/model/records.ts`
- **Depends**: T1
- **DDL**: transcribe INDEX **§4.2 verbatim** — `accounts`, `hosts`, `sessions`, `pairing_codes` — plus
P3-owned `relay_nodes(node_id pk, addr, status, last_seen)`, `metering_samples`, `audit_log` (§4/§7),
plus the **INV8 companion version tables** `account_status_versions(account_id, version, status,
changed_at, changed_by, PRIMARY KEY(account_id, version))` and `host_status_versions(host_id, version,
status, revoked_at, changed_at, changed_by, PRIMARY KEY(host_id, version))`, and a monotonic
`status_version int NOT NULL DEFAULT 1` current-pointer column added to `accounts`/`hosts` (§4 INV8
discipline; pending INDEX promotion, **OQ6**). `host_id`/`account_id`/`session_id` = `uuid` default
`gen_random_uuid()`; `subdomain UNIQUE`; `agent_pubkey bytea`; lifecycle/business columns follow the
versioned-pointer swap (no bare `UPDATE` of a business field without a matching `*_status_versions`
insert in the same txn — see §4 discipline + T3/T4). Liveness columns (`last_seen`, `last_attach_at`)
are advisory freshness caches, updated by a coalesced write, **not** versioned.
- **Contract**:
```ts
export interface AccountRecord { // mirrors §4.2 accounts VERBATIM (not in §4 TS block; OQ1)
readonly accountId: string; readonly plan: PlanTier
readonly createdAt: string; readonly status: 'active' | 'suspended'
}
export interface SessionRecord { // mirrors §4.2 sessions
readonly sessionId: string; readonly hostId: string; readonly accountId: string
readonly createdAt: string; readonly lastAttachAt: string
}
export interface PairingCodeRecord { // mirrors §4.2 pairing_codes (code_hash only, INV5)
readonly codeHash: string; readonly accountId: string
readonly expiresAt: string; readonly redeemedAt: string | null
}
export { HostRecord, HostStatus, PlanTier, RouteEntry } from 'relay-contracts' // §4.2 — NOT redefined
export function query<T>(sql: string, params: readonly unknown[]): Promise<readonly T[]> // parameterized ONLY
```
- **TDD** (Testcontainers PG): migration applies clean; `subdomain` UNIQUE rejects a dup insert;
`pairing_codes.code_hash` is PK; a raw string in place of a `$n` param is impossible (wrapper signature
forbids interpolation).
- **Security**: **parameterized queries only** (no string concat → no SQLi); `agent_pubkey` is a
**public** key (INV4); pairing code stored **hashed** (INV5). Enforces INV4, INV5.
---
## 4. CP1 — registries (immutable repositories)
**INV8 discipline (all of T3T5) — reconciled with the FROZEN §4.2 schema (Finding-6).** The frozen
§4.2 DDL declares `accounts`/`hosts`/`sessions` as **single-PK rows** (`host_id`/`account_id`/
`session_id` is the PK), so a *second* row with the same PK is impossible — versioning cannot be done by
re-inserting into those tables, and this plan does **not** silently invent `*_versions` tables (§4.2 is
frozen; that change must go through the INDEX). The discipline is therefore split by field class:
- **Lifecycle/business fields** (`accounts.status`, `hosts.status`, `hosts.revoked_at`) — versioned via
**companion append-only tables** `account_status_versions(account_id, version, status, changed_at,
changed_by)` and `host_status_versions(host_id, version, status, revoked_at, changed_at, changed_by)`,
with the base-table column acting as the **current pointer**. A status change is **one transaction**:
`INSERT … status_versions (next version)` **then** `UPDATE base SET status/revoked_at = new,
status_version = next` — the single-row pointer `UPDATE` is the only atomic write, and a reader either
sees the old (status, version) pair or the new one, never a torn mix. **Prior snapshots stay readable
from the `*_versions` table** (this is what the T3/T4 "old snapshot still retrievable" tests assert —
against `*_status_versions`, not a duplicate base row). These two companion tables are **added to
§4.2 and to T2's DDL** as a P3-local extension pending INDEX promotion — see **OQ6**.
- **High-frequency liveness fields** (`hosts.last_seen`, `sessions.last_attach_at`) are **NOT versioned**
— one row per heartbeat/attach would be unbounded write amplification. Per INV7, live location/liveness
lives in **Redis** (`route:{host_id}` heartbeat-TTL, T9); the Postgres `last_seen`/`last_attach_at`
columns are **advisory freshness caches** updated by a bounded, coalesced write and are **explicitly
exempt** from the "prior snapshot readable" assertion. T3/T4/T5 tests assert immutability/versioning
**only** for lifecycle/business fields, never for liveness columns.
No `UPDATE … SET <lifecycle_field>` happens without a matching `*_versions` insert in the same txn.
### T3 · account registry `[v0.9]`
- **Owns**: `control-plane/src/registry/accounts.ts`
- **Depends**: T2
- **Contract**:
```ts
export function createAccount(plan: PlanTier): Promise<AccountRecord> // new immutable row
export function getAccount(accountId: string): Promise<AccountRecord | null>
export function setAccountStatus(accountId: string, status: 'active' | 'suspended'): Promise<AccountRecord> // NEW snapshot, atomic swap
```
- **TDD**: `createAccount` returns unguessable uuid + `status:'active'` (writes `status_version=1` + the
seed `account_status_versions` row); `setAccountStatus` performs the single-txn `INSERT
account_status_versions(next) + UPDATE accounts SET status,status_version=next` and returns the **new**
record while the **prior `(status, version)` remains readable from `account_status_versions`** (assert
old snapshot retrievable **from the versions table**, not a duplicate base row); concurrent
`setAccountStatus` → one whole-or-nothing winner, versions strictly monotonic, no torn read (INV8).
- **Security**: INV8. `accountId` unguessable + never recycled (INV1 precondition).
### T4 · host registry (ownership source of truth) `[v0.9]`
- **Owns**: `control-plane/src/registry/hosts.ts`
- **Depends**: T2, T3
- **Contract** (returns the frozen §4.2 `HostRecord`):
```ts
export function bindHost(input: { // called ONLY from pairing BIND (T8), never from client input
readonly accountId: string; readonly subdomain: string
readonly agentPubkey: Uint8Array; readonly enrollFpr: string
}): Promise<HostRecord>
export function getHost(hostId: string): Promise<HostRecord | null>
export function getHostBySubdomain(subdomain: string): Promise<HostRecord | null>
export function listHosts(accountId: string): Promise<readonly HostRecord[]> // ownership scoped
export function setHostStatus(hostId: string, status: HostStatus): Promise<HostRecord> // NEW snapshot
export function ownsHost(accountId: string, hostId: string): Promise<boolean> // INV1/INV6 ownership join
```
- **TDD**: `bindHost` stores **only** the public key (grep row → no private material, INV4);
`enroll_fpr` = fingerprint of `agent_pubkey`; **`ownsHost(A, hostOwnedByB)` → false** (the INV1
ownership fact this plan supplies to P5's tripwire); `getHostBySubdomain` resolves the stable name;
`setHostStatus('revoked')` performs the single-txn `INSERT host_status_versions(next) + UPDATE hosts
SET status='revoked', revoked_at=now(), status_version=next` — prior status is still readable from
`host_status_versions` (feeds INV12). Assert liveness `last_seen` updates do **not** create a version
row (advisory cache, INV7).
- **Security**: **INV1** (host is resolvable only via `host_id`/`subdomain` bound to an account — **no
raw address/port/hostname resolution path exists in this module**), **INV4** (pubkey only), **INV6**
(`ownsHost` is the deny-by-default ownership predicate), **INV8**.
### T5 · session registry `[v0.9]`
- **Owns**: `control-plane/src/registry/sessions.ts`
- **Depends**: T2, T4
- **Contract**:
```ts
export function recordSession(input: { readonly sessionId: string; readonly hostId: string }): Promise<SessionRecord>
// account_id is DENORMALIZED from the host at write time — NEVER supplied by the caller/client (INV3)
export function getSession(sessionId: string): Promise<SessionRecord | null>
export function sessionAccount(sessionId: string): Promise<string | null> // O(1) authz fact for reattach (INV6)
```
- **TDD**: `recordSession` derives `account_id` from `hosts` (ignores any caller-passed account); a
reattach lookup for a `session_id` owned by another account resolves to that account's id (so P5's
reattach check denies) — assert `sessionAccount` never echoes a client-supplied value; unknown
`session_id` → null.
- **Security**: **INV3** (account denormalized from host, never client), **INV6** (reattach
re-validation fact).
### T6 · subdomain assignment `[v0.9]`
- **Owns**: `control-plane/src/subdomain/assign.ts`
- **Depends**: T2, T4
- **Contract**:
```ts
export function normalizeSubdomain(raw: string): string // lowercase, RFC-1123 label, strip invalid
export function isValidSubdomain(raw: string): boolean // 163 chars, [a-z0-9-], not leading/trailing '-'
export function assignSubdomain(accountId: string, requested?: string): Promise<string> // UNIQUE, atomic reserve
export function assembleFqdn(subdomain: string, baseDomain: string): string // 'alice' + 'term.x' → 'alice.term.x'
```
- **TDD**: reserved words (`www`, `api`, `admin`, `_`) rejected; UNIQUE collision → deterministic retry
or 409 (never silently reassign an existing tenant's name); `assignSubdomain` is atomic under
concurrency (two callers, same requested → exactly one wins); host-header-confusion inputs
(`a.b`, `A LICE`, `../`) rejected by `isValidSubdomain`.
- **Security**: subdomain is the browser-origin isolation boundary (EXPLORE §3) — a malformed/duplicate
label must never collapse two tenants into one origin. Supports **INV1**. Validate at boundary
(`coding-style.md`).
---
## 5. CP2 — pairing (INDEX §4.5)
### T7 · pairing-code issuance `[v0.9]`
- **Owns**: `control-plane/src/pairing/issue.ts`
- **Depends**: T2, T3 · **Cross-plan**: issued from an authenticated human session (**P5** provides the
principal; INV3 — `accountId` from the session, never a request field)
- **Contract** (INDEX §4.5 step 1):
```ts
export interface IssuedPairing { readonly code: string; readonly expiresAt: string } // raw code returned ONCE
export function issuePairingCode(accountId: string): Promise<IssuedPairing>
// mints single-use SHORT-TTL code; stores { code_hash, account_id, expires_at, redeem_attempts:0 };
// raw code NEVER stored (INV5). Code format is FIXED (see below) — the entropy claim and the display
// format are reconciled here so they cannot contradict.
```
**Entropy/display reconciliation (Finding-4 — resolves the `ABCD-1234` ≈ 32-bit contradiction).** A
`ABCD-1234` shape (4 letters + 4 digits) is only ~32 bits and is **rejected as the wire code**. The
pairing code is the **sole credential** gating `/enroll` (not capability-token gated, T11), so it is
defined as **26 Crockford-base32 characters = 130 bits of real entropy**, displayed grouped for humans as
**`XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-X`** (6 groups). This is a paste/QR-first credential, not a
type-from-memory PIN. (INDEX §4.5's `pair ABCD-1234` is an **illustrative CLI placeholder**, not a
frozen code format — §4.5 freezes only `code_hash` storage, no typed raw-code contract — so P3, the
issuer, defines the normative format here without redefining any §4 contract.) **In addition**
(defense-in-depth, since brute force of `/enroll` is P3's own attack
surface even before P5's general limiter), redemption is throttled per code (T8): a strict,
**code-scoped exponential lockout** on failed `redeemPairingCode` attempts, independent of P5's
per-tenant rate-limiter. TTL is **short by default**`pairingTtlSec` defaults to **600s (10 min)** (T1),
not left purely env-open.
- **TDD**: issued code is **≥ 128-bit entropy** (assert the sampled character space × length; reject a
32-bit `ABCD-1234` shape); display grouping round-trips to the canonical 130-bit value; DB row stores
**`code_hash` only** (grep → no raw code, INV5) with `redeem_attempts = 0`; `expires_at` = now +
`pairingTtlSec` and defaults to a 10-minute window when the env var is unset; two issues → two distinct
codes.
- **Security**: INV5 (hash at rest), INV3 (account from authenticated principal). ≥128-bit entropy +
short default TTL + code-scoped redeem lockout (T8) make offline/online guessing infeasible within the
leak window (EXPLORE §4a).
### T8 · pairing-code redemption + BIND + bind-time cert `[v0.9]`
- **Owns**: `control-plane/src/pairing/redeem.ts`, `control-plane/src/ca/sign.ts`
- **Depends**: T4, T6, T7 · **Cross-plan**: server side of **P2** `POST /enroll { code, agentPubkey, csr }`
- **Contract** (INDEX §4.5 steps 35):
```ts
// EnrollResult is FROZEN in INDEX §4.5 — IMPORTED from relay-contracts, NOT redefined locally (FIX 3).
// Shape cited verbatim: { hostId, subdomain, cert: string, caChain: string, hostContentSecret: Uint8Array }.
// - cert/caChain are PEM strings: redeem encodes signHostLeaf's DER (Uint8Array / Uint8Array[]) to PEM
// before assembling the frozen EnrollResult (§4.5 field types are the authority).
// - hostContentSecret (FIX 3): per-host CSPRNG secret, WRAPPED to the enrolled Ed25519 agent_pubkey.
import type { EnrollResult } from 'relay-contracts' // §4.5 frozen shape — do not shadow
export function redeemPairingCode(input: {
readonly code: string; readonly agentPubkey: Uint8Array; readonly csr: Uint8Array
}): Promise<EnrollResult> // ATOMIC single-use: compare-and-set redeemed_at in ONE txn (no double-spend).
// BIND (INDEX §4.5 step 4) also MINTS + WRAPS the host-scoped content secret and returns it in
// EnrollResult: after bindHost + signHostLeaf, call mintHostContentSecret(agentPubkey) and set
// EnrollResult.hostContentSecret = the wrapped blob (FIX 3). Single owner of minting = P3 (§4.5).
// CODE-SCOPED LOCKOUT (Finding-4): a failed match atomically increments pairing_codes.redeem_attempts;
// once it reaches `pairingMaxRedeemAttempts` (default 5, T1) the code is locked (treated as spent) with
// exponential backoff between attempts — independent of P5's per-tenant rate limiter. Lockout is keyed
// by `code_hash`, so brute-forcing one victim's code cannot be spread across accounts.
// control-plane/src/ca/sign.ts — registry-gated leaf signer (KMS-backed, §3.1):
export function signHostLeaf(hostId: string, agentPubkey: Uint8Array, csr: Uint8Array): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
// ORDER OF CHECKS (all must pass, same reject path):
// 1. PROOF-OF-POSSESSION — verify the CSR's own PKCS#10 self-signature against the pubkey it carries
// (standard PKI: the CA proves the requester holds the matching private key BEFORE signing).
// 2. the CSR's embedded pubkey == the caller-supplied `agentPubkey` (no substitution).
// 3. (hostId, agentPubkey) is an ACTIVE row in the host registry (INV14 registry-gate).
// A failure at ANY step rejects with the SAME error path — a valid-but-unregistered CSR and a
// registered-but-invalid-signature CSR are both refused. Signing itself = KMS sign() (§3.1), never a
// raw CA private key in memory. Rotation/renewal CADENCE + mTLS handshake verify = P5 (§7).
// control-plane/src/pairing/redeem.ts — FIX 3: mint + wrap the host-scoped content secret at BIND.
export function mintHostContentSecret(agentPubkey: Uint8Array): Promise<Uint8Array>
// Generate a per-host 32-byte CSPRNG secret and SEAL/WRAP it to the host's enrolled Ed25519 agent_pubkey
// (host-scoped — NOT a raw account-wide secret). The returned wrapped blob is EnrollResult.hostContentSecret;
// P2 unwraps it locally with the enrollment private key (never leaves the host, INV4). The control plane
// persists ONLY the wrapped blob (a per-host, per-wrap artifact the CP can invalidate on host/device
// revocation, INV12) — the raw secret is NEVER stored, NEVER logged (INV5/INV9). Feeds §4.4 deriveContentKey.
```
- **TDD**:
- **Happy path**: valid unexpired unredeemed code → `bindHost` runs, subdomain assigned, `redeemed_at`
set, `EnrollResult` returned with `cert`/`caChain` as PEM strings, a cert whose subject pubkey ==
`agentPubkey`, and a non-empty `hostContentSecret` (frozen §4.5 fields all present).
- **hostContentSecret mint/wrap (FIX 3, security)**: `EnrollResult.hostContentSecret` is present and is
the **wrapped** blob (grep PG/logs → the raw 32-byte secret NEVER appears, INV5/INV9); two enrollments
(or two hosts) yield **distinct** wrapped secrets (per-host, per-wrap → per-host revocable, INV12);
the wrap targets the enrolled `agent_pubkey` (P2 can unwrap with the matching private key, no other host can).
- **Double-spend (security)**: two concurrent `redeemPairingCode` with the same code → **exactly one**
succeeds, the other → `already_redeemed` (CAS on `redeemed_at`).
- **Expired** code → reject; **unknown** code → reject; **CSR pubkey ≠ body `agentPubkey`** → reject.
- **CSR proof-of-possession (security)**: a CSR whose PKCS#10 **self-signature does not validate**
against its **own embedded pubkey****reject**, *independent of registry state* — assert this holds
**even when that pubkey happens to be a registered, active host** (so a signature-forged CSR for a
real registered key cannot obtain a leaf). Reject uses the same error path as the INV14 gate.
- **INV14 gate**: `signHostLeaf` for a pubkey not in the registry → **throws** (assert the CA cannot be
tricked into signing an unbound key), and the KMS `sign()` is never invoked when any check fails.
- **INV5**: redemption matches by **hash** of the presented code, never a stored raw code.
- **Lockout (security)**: after `pairingMaxRedeemAttempts` (default 5) failed redemptions against the
**same `code_hash`**, the next attempt — **even with the correct code** — is refused as locked
(`too_many_attempts`), and the counter increment is atomic under concurrency (no attempt escapes the
count via a race). Assert lockout is per-`code_hash`, not per-account/IP.
- **Security**: INV5, INV14 (registry-gated signing incl. CSR proof-of-possession), INV8 (BIND writes
immutable host row), INV3 (account taken from the pairing row, not from the agent's request).
Redemption is **atomic + single-use** with a **code-scoped brute-force lockout** (INDEX §4.5).
---
## 6. CP3 — live routing table & node coordination
### T9 · Redis routing table + relay-node identity `[v0.9]`
- **Owns**: `control-plane/src/routing/table.ts`, `control-plane/src/node-auth/identity.ts`
- **Depends**: T2, T4 · **Cross-plan**: **read by P1** (data plane splices browser → node holding tunnel);
relay-node **service certs** issued/rotated by **P5/P1** — see **OQ5**
- **Contract** (INDEX §4.2 Redis `route:{host_id}`, INV7):
```ts
// control-plane/src/node-auth/identity.ts — the relay-node↔control-plane trust boundary (analog of INV3)
export interface NodeIdentity { readonly nodeId: string } // derived from the mTLS client-cert SUBJECT
export function nodeIdentityFromRequest(req: FastifyRequest): NodeIdentity
// reads the VERIFIED relay-node mTLS client-cert subject (SPIFFE-style node id) from the TLS session;
// THROWS 401 if the connection is not mutually authenticated. The `nodeId` is NEVER taken from a body/
// query/header field — a request that carries a `nodeId` payload has it IGNORED for identity.
// control-plane/src/routing/table.ts — every mutation is scoped to the AUTHENTICATED node identity:
export function upsertRoute(caller: NodeIdentity, hostId: string, entry: RouteEntry, ttlSec: number): Promise<void>
// entry.relayNodeId MUST equal caller.nodeId (a node can only route hosts to ITSELF); mismatch → reject
export function heartbeatRoute(caller: NodeIdentity, hostId: string, ttlSec: number): Promise<void> // refresh TTL (~<15s)
export function resolveRoute(hostId: string): Promise<RouteEntry | null> // Redis lookup; null ⇒ offline
export function dropRoute(caller: NodeIdentity, hostId: string): Promise<void> // only the holding node (or drain/T13)
```
- **TDD** (Testcontainers Redis): `upsertRoute` then `resolveRoute` returns the entry; after `ttlSec`
with no heartbeat → `resolveRoute` null (heartbeat-TTL expiry = liveness, INV7); `heartbeatRoute` from
a `caller.nodeId` that is **not** the current route holder is rejected/no-ops (a stale/foreign node
can't hijack a tenant's live tunnel); **node-identity (security)**: `upsertRoute` whose
`entry.relayNodeId ≠ caller.nodeId`**reject**, and `nodeIdentityFromRequest` on a
non-mutually-authenticated connection → **401** (assert a caller cannot inject a route for a
`hostId`/`nodeId` it is not the authenticated identity for, even if it guesses the ids); `dropRoute`
removes it.
- **Security**: **INV7** — Redis holds only *location*, never ownership or secrets; a missing/expired key
fails **closed** (host treated offline, not "route anywhere"). Postgres remains ownership truth. The
`nodeId` is **derived from the caller's authenticated mTLS identity, never a request field** — the
relay-node↔control-plane analog of INV3, closing the "any reachable caller can redirect a tunnel" gap.
### T10 · relay-node registry + graceful drain `[v0.9]`
- **Owns**: `control-plane/src/routing/nodes.ts`
- **Depends**: T9 · **Cross-plan**: requests **P1** `GOAWAY` (INDEX §4.1) by PUBLISHING a `KillSignal` on
the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4 — the **SAME** channel T13 revoke uses); P1 nodes
subscribe, emit the §4.1 `GOAWAY`, agents reconnect. **OQ4 RESOLVED.**
- **Contract** (EXPLORE §3 "graceful drain"). Every mutation is scoped to the caller's authenticated
`NodeIdentity` (T9) — a node can only register/heartbeat/drain **itself**; admin-initiated drain arrives
through the T11 admin API (capability-token authorized), not as a bare `nodeId` field:
```ts
export type NodeStatus = 'active' | 'draining'
export function registerNode(caller: NodeIdentity, addr: string): Promise<void> // nodeId = caller.nodeId
export function nodeHeartbeat(caller: NodeIdentity): Promise<void>
export function beginDrain(caller: NodeIdentity): Promise<{ readonly hostIds: readonly string[] }>
// SELF-drain only: marks caller.nodeId 'draining', enumerates its live routes; then a KillSignal is
// published on the frozen relay:revocations bus (INDEX §4.2 FIX 4) so P1 emits GOAWAY(lastStreamId,
// reason) for those streams. A caller cannot drain a node other than its own authenticated identity.
export function completeDrain(caller: NodeIdentity): Promise<void> // after routes re-homed → deregister self
// Operator-initiated drain (an admin draining a named node for maintenance) is a T11 admin route: it
// runs its own capability-token authz (rights ⊇ 'manage') and THEN invokes the same drain path with a
// server-derived NodeIdentity — the untrusted request never supplies a bare nodeId to this module.
```
- **TDD**: `beginDrain` flips status to `draining` and returns exactly the host_ids routed to that node;
a drained node stops receiving new `upsertRoute` targeting it; **node-identity (security)** — a
`registerNode`/`nodeHeartbeat`/self-`beginDrain` whose `nodeId` is not the caller's authenticated
identity (and not a `manage`-scoped admin) is **rejected** (a forged/guessed `nodeId` cannot drain or
re-register another node); **PTY-survival assertion** — draining a node does not touch any Postgres
host/session row (the running task lives on the customer machine, EXPLORE §3); `completeDrain`
deregisters only after routes moved.
- **Security**: INV7 (stateless data plane — drain loses nothing durable). Drain uses the frozen §4.1
`GOAWAY` semantics; this module **does not** encode the frame (that is P1) — it only decides *which*
hosts drain and PUBLISHES a `KillSignal` on the **frozen `relay:revocations` bus** (INDEX §4.2 FIX 4,
the SAME channel T13 uses) that every P1 node subscribes to and translates into the §4.1 `GOAWAY`.
**OQ4 RESOLVED** — the channel is now a FROZEN SHARED CONTRACT in the INDEX, not an assumed dispatcher.
`nodeId` in every call is the authenticated mTLS identity (Finding-2), never a trusted request field.
---
## 7. CP4 — provisioning API & metering
### T11 · provisioning / deprovisioning HTTP API `[v0.9]`
- **Owns**: `control-plane/src/api/provision.ts`, `control-plane/src/api/authz.ts`,
`control-plane/src/deprovision/deprovision.ts`, `control-plane/src/main.ts`
- **Depends**: T3, T4, T6, T7, T9 · **Cross-plan**: authz consumes **P5** `verifyCapabilityToken` (§4.3)
- **Phasing note (Finding-7):** the v0.9 `DELETE /hosts/:id` is **self-contained in v0.9** — it calls the
**v0.9 minimal deprovision** in `deprovision/deprovision.ts` (`deprovisionHost`: `setHostStatus(hostId,
'revoked')` via T4 + `dropRoute` via T9), which is enough to tear the host down for accounting/UX. It
does **NOT** depend on T13 (v0.10). The **fast, within-seconds tunnel-kill + cert-stop** hardening
(INV12) is an **additive upgrade** delivered by T13 in v0.10; when T13 lands, `DELETE /hosts/:id`
delegates to `revokeHost` for the seconds-latency path. No v0.9 task depends on a v0.10 task.
```ts
// control-plane/src/deprovision/deprovision.ts — v0.9 minimal tear-down (superseded by T13 fast path)
export function deprovisionHost(principal: AdminPrincipal, hostId: string): Promise<void>
// requires ownsHost(principal.accountId, hostId); setHostStatus('revoked') (T4 versioned) + dropRoute (T9).
// Best-effort tunnel drop only; NOT the INV12 within-seconds guarantee (that is T13/v0.10).
```
- **Contract**:
```ts
// control-plane/src/api/authz.ts
export interface AdminPrincipal { readonly accountId: string; readonly rights: readonly CapabilityRight[] }
export function principalFromRequest(req: FastifyRequest): AdminPrincipal
// derives accountId from the VERIFIED capability token / authenticated session ONLY (INV3);
// any body/query field named account_id/tenant_id is IGNORED for authz decisions.
// control-plane/src/api/provision.ts (all routes deny-by-default; scope every op to principal.accountId)
// POST /accounts -> create account (admin-scoped)
// POST /accounts/:id/pairing-codes -> issuePairingCode(principal.accountId) (T7)
// POST /accounts/:id/status -> suspend/reactivate (T3)
// GET /accounts/:id/hosts -> listHosts(principal.accountId) (T4, ownership-scoped)
// DELETE /hosts/:hostId -> v0.9: deprovisionHost (T11) ; v0.10: revokeHost (T13 fast path)
// — both require ownsHost(principal.accountId, hostId)
// POST /enroll -> redeemPairingCode (T8) — agent leg, NOT capability-token gated
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync
```
- **TDD**:
- **INV3/INV1 (security, the headline test)**: request authenticated as account **A** with body
`{ account_id: B }` targeting a host owned by **B****403**; the forged `account_id` is never read.
- `principalFromRequest` with a missing/expired/foreign-`aud` token → 401 (delegates to
`verifyCapabilityToken`); scope `attach` cannot hit a `manage`/`kill` route (rights subset check).
- `DELETE /hosts/:id` where `!ownsHost(principal.accountId, id)` → 403 (not 404-leak vs 403 — pick 403
per INDEX INV1 tripwire wording).
- Every route validates its body with **Zod** at the boundary; malformed → 400.
- **Security**: **INV3** (principal-derived account), **INV6** (deny-by-default; every route re-checks
ownership), **INV1** (ownership join on host-targeting routes). No route resolves a host by
user-supplied address. `/enroll` is the one unauthenticated-by-capability-token route (it *is* the
auth-bootstrap) but is guarded by the single-use pairing code (T8).
### T12 · billing-metering hooks `[v0.9]`
- **Owns**: `control-plane/src/metering/collect.ts`
- **Depends**: T4, T9 · **Cross-plan**: **P1** relay nodes POST viewer-count samples (they alone see
live streams)
- **Contract** (EXPLORE §6 — meter **paired hosts + concurrent viewers**, NOT bandwidth). The sample's
origin `nodeId` is the caller's **authenticated mTLS identity** (T9 `NodeIdentity`), never a body field,
and the sample's `accountId` is **re-derived server-side** from the host registry — the node-supplied
`accountId` is treated as untrusted and validated, not trusted (INV1/INV3):
```ts
export interface MeteringSample { // pushed by relay nodes; immutable append
readonly hostId: string
readonly concurrentViewers: number; readonly sampledAt: string
// NO client/node-supplied accountId or nodeId — both are derived server-side (see below)
}
export function ingestSample(caller: NodeIdentity, sample: MeteringSample): Promise<void>
// sets nodeId = caller.nodeId; derives accountId via ownsHost/getHost(sample.hostId); append-only
// INSERT (INV8) — never updates. A hostId the caller cannot substantiate against the registry is rejected.
export function pairedHostCount(accountId: string): Promise<number> // from host registry (non-revoked)
export function rollupUsage(accountId: string, from: string, to: string): Promise<{
readonly pairedHostPeak: number; readonly viewerPeak: number; readonly viewerHours: number
}>
```
- **TDD**: `ingestSample` inserts a new immutable row (no `UPDATE`) with `nodeId` set to the authenticated
caller and `accountId` derived from the host registry; `pairedHostCount` excludes `status:'revoked'`
hosts; `rollupUsage` computes peak concurrent viewers + viewer-hours over a window; **forged-attribution
(security)**: a sample for a `hostId` whose derived owner differs from any node-hinted account, or from
an unauthenticated caller, is **rejected** (metering can't be forged or attributed cross-tenant, INV1;
a node cannot mint usage against another tenant); malformed sample → Zod 400.
- **Security**: INV8 (append-only immutable samples), INV1 (attribution derived from ownership, never
node-asserted), INV3-analog (nodeId from authenticated identity). Zero terminal payload in samples
(INV10-adjacent) — counts and timestamps only.
---
## 8. CP5 — hardening (v0.10)
### T13 · revocation (global + per-host) `[v0.10]`
- **Owns**: `control-plane/src/revoke/revoke.ts`
- **Depends**: T4, T9, T10 · **Cross-plan**: shares Redis `revoked:{jti}` with **P5**; the **within-seconds
tunnel-kill** step PUBLISHES a `KillSignal` on the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4) via
`RevocationBus.publish` — the **SAME named channel** T10's drain uses; every P1 node subscribes and
injects the §4.1 `CLOSE`+`RST` within `REVOCATION_PUSH_BUDGET_MS` (2000ms). **P5 also publishes** on this
bus (token revocation); **P3 owns** host/account revocation publishes. **OQ4 RESOLVED.**
- **Contract** (INDEX §4.2 `revoked:{jti}`, INV12):
```ts
// revoke.ts IMPORTS KillSignal / RevocationScope / RevocationBus / RELAY_REVOCATIONS_CHANNEL /
// REVOCATION_PUSH_BUDGET_MS from relay-contracts (INDEX §4.2 FIX 4) — NONE redefined locally. It
// constructs the RevocationBus over the Redis pub/sub client and only PUBLISHES (P1 owns the subscriber).
// KillSignal.reason is metadata only (INV10, zero payload).
export function revokeHost(hostId: string): Promise<void>
// 1) host status -> 'revoked' + revoked_at (T4, versioned snapshot) 2) dropRoute (T9)
// 3) bus.publish({ scope: { kind: 'host', hostId }, at, reason }) on RELAY_REVOCATIONS_CHANNEL
// ('relay:revocations') — P1 subscribers inject the §4.1 CLOSE+RST (immediate, distinct from the
// graceful GOAWAY) within REVOCATION_PUSH_BUDGET_MS (2000ms) 4) stop future cert signing for its
// pubkey (T8 CA gate + T15 renew gate now refuse) — all within seconds (INV12)
export function revokeAccount(accountId: string): Promise<void>
// revoke every host of the account; bus.publish({ scope: { kind: 'account', accountId }, at, reason })
// — one account-scoped KillSignal; P1 tears down all of the account's live streams (§4.2 FIX 4)
export function revokeToken(jti: string, expUnix: number): Promise<void> // SET revoked:{jti} EX (exp-now)
export function isTokenRevoked(jti: string): Promise<boolean>
```
- **TDD** (security): after `revokeHost`, `resolveRoute(hostId)` → null AND a **`KillSignal { scope:{ kind:
'host', hostId } }` is published on `relay:revocations`** (assert a test subscriber receives it, so a P1
node would tear the tunnel down within `REVOCATION_PUSH_BUDGET_MS`) AND the host's next reconnect is
refused (status `revoked`) AND `signHostLeaf` refuses its pubkey (T8 gate) — all observable within a
bounded time; `revokeAccount` publishes one **account-scoped** `KillSignal` and cascades to all hosts;
`revokeToken` → `isTokenRevoked` true and stops validating (INV12); a revoked host's capability tokens
stop authorizing (via `revoked:{jti}` + status). Assert the published `KillSignal.reason` carries **no**
terminal payload (INV10).
- **Security**: **INV12** (fast tunnel-killing revocation), **INV8** (revocation writes an immutable
snapshot, doesn't mutate). Revocation is idempotent.
### T14 · immutable audit log (zero payload) `[v0.10]`
- **Owns**: `control-plane/src/audit/log.ts`
- **Depends**: T2 · **Cross-plan**: **imported by P5** for attach/manage/kill security events (P3 owns the
*substrate + control-plane events*; P5 owns *connect-time event semantics + cross-tenant alerts*)
- **Contract** (INV10):
```ts
export type AuditAction = 'account.create' | 'account.suspend' | 'host.bind' | 'host.revoke'
| 'pairing.issue' | 'pairing.redeem' | 'subdomain.assign' | 'node.drain'
| 'attach' | 'manage' | 'kill' | 'revoke' // the last four written by P5 through this API
export interface AuditEntry {
readonly action: AuditAction; readonly principalId: string; readonly accountId: string
readonly hostId: string | null; readonly ts: string; readonly meta: Readonly<Record<string, string>>
// NO terminal payload, NO keystrokes/output, NO secret — metadata only (INV10)
}
export function writeAuditEvent(entry: AuditEntry): Promise<void> // append-only INSERT; immutable
export function queryAudit(accountId: string, from: string, to: string): Promise<readonly AuditEntry[]>
```
- **TDD** (security): a `writeAuditEvent` call carrying a `meta` value that looks like shell output is
**rejected/stripped** by a payload guard (assert no field can hold >N bytes of opaque data / a known
plaintext marker never lands in `audit_log`); entries are append-only (no `UPDATE`/`DELETE` path
exists); every control-plane mutation in T3/T4/T6/T7/T8/T10/T13 emits exactly one audit entry.
- **Security**: **INV10** — immutable, **zero-payload**, metadata-only. The writer is the single funnel
P5 uses so cross-tenant alerts (P5-owned) sit atop the same append-only store.
### T15 · cert-rotation coordination + secret hardening `[v0.10]`
- **Owns**: `control-plane/src/ca/rotate.ts` (renewal), `control-plane/src/boot/ca-wiring.ts`
(CA-key/secret-manager bootstrap + KMS-signer factory). **Disjoint from T8** (`ca/sign.ts`) **and T11**
(`main.ts`): `main.ts` merely *imports* `boot/ca-wiring.ts`; T15 edits **no** file owned by another task.
The KMS-signer factory in `boot/ca-wiring.ts` is the shared primitive both `ca/sign.ts` (T8) and
`ca/rotate.ts` (T15) call — neither loads a raw key (§3.1).
- **Depends**: T8, T1 · **Cross-plan**: rotation *cadence + mTLS verify* is **P5/INV14**; P3 provides the
registry-gated **signing** only
- **Contract**:
```ts
// control-plane/src/boot/ca-wiring.ts
export interface CaSigner { sign(tbsCert: Uint8Array): Promise<Uint8Array> } // wraps KMS sign(); no raw key
export function buildCaSigner(env: ControlPlaneEnv): Promise<CaSigner>
// resolves caIntermediateKmsKeyRef; FAILS FAST if the KMS key is unresolvable OR its policy does not
// restrict `sign` to the control-plane service principal (§3.1 startup check, INV9).
// control-plane/src/ca/rotate.ts
export function renewHostLeaf(hostId: string, csr: Uint8Array): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
// re-sign a SHORT-TTL leaf for an already-bound, non-revoked host under the CURRENT intermediate.
// Same guards as signHostLeaf (T8): (1) verify the CSR PKCS#10 SELF-SIGNATURE (proof-of-possession)
// against its embedded pubkey; (2) that pubkey == the host's registered agent_pubkey; (3) host is
// active/non-revoked. Any failure → same reject path. Signing = CaSigner.sign() (KMS), never a raw key.
```
- **TDD**: `renewHostLeaf` for an active host with a valid CSR → fresh short-TTL cert (same subject
pubkey, signed under the current intermediate); a CSR whose **self-signature does not validate** →
reject even for an active host (proof-of-possession, Finding-1); for a **revoked** host → throws (a
drained/revoked host cannot renew, closing the INV12+INV14 loop); `buildCaSigner` **fails fast** at
startup if `caIntermediateKmsKeyRef` is unresolvable OR its KMS policy is broader than the
control-plane service principal (§3.1, INV9); assert renewal invokes `CaSigner.sign()` and never loads
a raw private key.
- **Security**: **INV14** (short-lived, registry-gated leaf with CSR proof-of-possession; CA never signs
an unbound/revoked pubkey), **INV9** (CA intermediate key held in KMS/HSM by reference, signing via
`sign()` call, validated + policy-checked at startup, never logged, never at rest in code — §3.1).
---
## 9. SECURITY section (per-plan summary)
| INV | How this plan enforces it | Owning task(s) / test |
|-----|---------------------------|------------------------|
| **INV1** | Host is resolvable **only** via `host_id`/`subdomain` bound to an account; `ownsHost` is the sole ownership predicate; no address/port/hostname resolution path exists. | T4 `ownsHost`, T6 subdomain validation, T11 403-on-forged-account |
| **INV3** | `account_id` derived from the authenticated principal / pairing row / host row — **never** a client field. **Relay-node analog:** `nodeId` in T9/T10/T12 is derived from the caller's authenticated mTLS identity, never a request field. | T5, T7, T11 `principalFromRequest`; T9 `nodeIdentityFromRequest`, T10/T12 node-auth |
| **INV4** | DB stores **public** Ed25519 keys only; private key generated on the host (P2), never received. | T2 schema, T4 `bindHost`, T8 CSR check |
| **INV5** | Pairing codes + v0.8 tokens hashed at rest; no raw secret, no shell bytes stored. | T0, T2, T7 |
| **INV6** | Deny-by-default: every admin route + reattach re-validates ownership; default reject. | T4 `ownsHost`, T5 `sessionAccount`, T11 |
| **INV7** | Redis routing = *location only*, heartbeat-TTL, fails closed; Postgres = ownership truth; nodes hold no durable state. | T9, T10 |
| **INV8** | Immutable lifecycle fields versioned via `*_status_versions` companion tables + atomic pointer-swap (prior snapshot readable); liveness fields are advisory caches (Redis is truth, INV7); append-only metering/audit. | T2, T3, T4, T5, T12, T14 |
| **INV9** | Secrets from env/secret-manager, Zod-validated at startup, fail-fast, never logged. **CA intermediate key held in KMS/HSM (non-exportable); signing via `sign()` call, never loaded raw; startup key-policy check.** | T1, T15 (§3.1) |
| **INV10** | Append-only, metadata-only audit writer with a payload guard; single funnel for P5 events. | T14 |
| **INV12** | v0.9 `deprovisionHost` (status→revoked + dropRoute) for tear-down; v0.10 `revokeHost/Account/Token` drop route + kill tunnel by publishing a `KillSignal` on the frozen `relay:revocations` bus (INDEX §4.2 FIX 4) + stop cert signing within seconds. | T11, T13, T15 |
| **INV14 (registry half)** | CA verifies **CSR proof-of-possession (PKCS#10 self-signature)** then signs **only** pubkeys active in the host registry; refuses revoked/absent. | T8, T15 |
**Not owned here (delegated, cited to avoid overlap):** capability-token **signing key** + WS-upgrade
enforcement + per-tenant **rate-limit enforcement** + the **CI cross-tenant tripwire** + human Passkey
auth = **P5**; the mux frame codec + `GOAWAY` wire encoding = **P1**; E2E crypto = **P4**.
---
## 10. VERIFICATION
```bash
# unit + integration (Testcontainers spins ephemeral Postgres + Redis)
cd control-plane && npm test # full P3 suite
npx vitest run registry # T3T5 immutable-record + ownership-join tests
npx vitest run pairing # T7 ≥128-bit entropy + T8 atomic single-use / double-spend / CSR proof-of-possession / code-scoped lockout
npx vitest run routing # T9 heartbeat-TTL liveness + node-identity (forged nodeId rejected) + T10 drain (PTY-survival)
npx vitest run api/provision # T11 INV3/INV1 403-on-forged-account (headline security test)
npx vitest run metering # T12 append-only + cross-tenant attribution reject
npx vitest run revoke # T13 revoke → route dropped + KillSignal on relay:revocations + reconnect refused (INV12)
npx vitest run audit # T14 zero-payload guard + append-only
npm run typecheck # relay-contracts §4 imported read-only; no local redefinition
npx vitest run --coverage # 80%+ (coding-style/testing.md)
# at-rest secret/plaintext scans (INV5/INV9/INV10 tripwires — run in CI)
scripts/scan-at-rest.sh # grep PG/Redis/disk dumps for raw pairing codes, private keys, shell markers → MUST be empty
```
**Manual / cross-plan integration (v0.9 gate):** issue a pairing code (T7) → redeem from a real agent
(P2) → `route:{host_id}` appears in Redis (T9, written under the node's authenticated mTLS identity) → P1
resolves it → `DELETE /hosts/:id` deprovisions (T11 minimal path: status→revoked + dropRoute) → reconnect
refused. **NOTE:** graceful node drain (T10) and the within-seconds tunnel-kill (T13) both PUBLISH a
`KillSignal` on the now-**frozen** `relay:revocations` bus (INDEX §4.2 FIX 4); the end-to-end integration
asserts a P1 subscriber tears the live tunnel down within `REVOCATION_PUSH_BUDGET_MS` (2000ms) — **OQ4
RESOLVED**, no longer a gate. **v0.10 gate:** `renewHostLeaf` rotates a live cert with no downtime; a
revoked host cannot renew; audit log shows every lifecycle event with **zero** terminal payload.
---
## 11. OPEN QUESTIONS (return to orchestrator — do NOT guess, per CLAUDE.md)
- **OQ1 — TS shapes for `AccountRecord`/`SessionRecord`/`PairingCodeRecord`.** INDEX §4.2 freezes the
**SQL** for these but gives TS interfaces only for `HostRecord`/`RouteEntry`. P5/P6/metering will want
the account/session TS shapes too. **Should they be promoted into `relay-contracts/` §4.2 (via the
INDEX) rather than mirrored locally in `control-plane/src/model/records.ts`?** Until decided, this plan
mirrors them locally, matching the frozen SQL column-for-column.
- **OQ2 — mTLS cert authority ownership split.** INDEX §5 assigns the "mTLS cert authority" to **P3**,
but INV14's primary owners are listed as **P5, P2**. This plan takes the defensible split: **P3 owns
the registry-gated *signing* (bind + renew), P5 owns rotation cadence + handshake verification.**
Confirm this boundary so T8/T15 and P5 don't both implement rotation.
- **OQ3 — Concurrent-viewer sampling source.** Metering (T12) needs live viewer counts that only the
**P1** data plane observes. Confirm the push contract (relay node → `POST /metering/samples`) and its
cadence lives in P1's charter, with P3 only ingesting.
- **OQ4 — RESOLVED (INDEX §4.2 FIX 4).** The control-plane→relay-node teardown channel is now a **FROZEN
SHARED CONTRACT**: the Redis pub/sub channel `RELAY_REVOCATIONS_CHANNEL = 'relay:revocations'` carrying a
`KillSignal { scope, at, reason }` (`RevocationScope = { kind:'host' } | { kind:'account' } | { kind:
'global' }`), published via `RevocationBus.publish` and **promoted into `relay-contracts`** so P3 never
imports P5 (DAG stays acyclic). **P3/P5 publish; every P1 node subscribes** and injects the existing §4.1
`CLOSE`+`RST` (host/account scope) or connection-level `GOAWAY` (global scope) — **no new wire type** —
within `REVOCATION_PUSH_BUDGET_MS = 2000`. **T10 drain and T13 revoke use this SAME named channel** (this
plan cites it verbatim, redefining nothing). **Residual:** the frozen `RevocationScope` has no `node`
kind and no graceful-vs-immediate discriminator, so a single-node *operator* drain (T10) is expressed via
per-host `KillSignal`s (with a drain `reason`) rather than one node-scoped signal — flagged back to the
INDEX in case a `node` scope or a `mode: 'graceful' | 'immediate'` field is wanted later (an additive,
INDEX-owned §4.2 change; not blocking). *(Originally raised by review Finding-8.)*
- **OQ5 — Relay-node service-cert issuance/rotation owner (BLOCKS T9/T10/T12 node-auth).** T9's
`nodeIdentityFromRequest` derives `nodeId` from a **verified relay-node mTLS client cert**, and
T9/T10/T12 reject any bare request-supplied `nodeId`. Who **issues + rotates** those node service certs
(SPIFFE-style), and what trust bundle (`nodeMtlsTrustBundlePath`, T1) the control plane pins to verify
them, is a **P5/P1 boundary** not yet frozen. Confirm the owner and the node-cert SVID format so the
control plane's verifier and the node's presented identity agree. *(Raised by review Finding-2.)*
- **OQ6 — INV8 companion version tables must be promoted into frozen §4.2 (BLOCKS T2/T3/T4 as written).**
§4.2 freezes `accounts`/`hosts`/`sessions` as single-PK rows, which cannot carry versioned snapshots.
This plan adds `account_status_versions` / `host_status_versions` + a `status_version` pointer column to
satisfy INV8 without in-place mutation of lifecycle fields (§4 discipline). Because §4.2 is frozen,
these tables + column **must be promoted into INDEX §4.2** (like OQ1) rather than added only locally in
T2's DDL. Confirm promotion; also confirm the ruling that high-frequency liveness fields (`last_seen`,
`last_attach_at`) are **advisory caches exempt from versioning** (live truth is Redis, INV7) — so
T3/T4/T5 do NOT ship "prior snapshot readable" assertions for liveness columns. *(Raised by review
Finding-6.)*
```