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

62 KiB
Raw Blame History

P3 — Control Plane & Accounts — Implementation Plan

Plan of the 6 (see 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 §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_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"):
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:
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 verbatimaccounts, 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:
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:
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):
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:
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:
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):
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 defaultpairingTtlSec 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):
// 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 pubkeyreject, 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):
// 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.nodeIdreject, 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:
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.
// 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:
// 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 B403; 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):
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):
// 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; revokeTokenisTokenRevoked 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):
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:
// 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

# 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 KillSignals (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.)