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.
62 KiB
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 followPLAN.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.mdis 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/PlanTierimported fromrelay-contracts/). - Pairing issuance + redemption + BIND (INDEX §4.5) — atomic single-use
redeemed_atCAS. - Per-tenant subdomain assignment (stable across reconnects).
- Live routing table — Redis
route:{host_id} → RouteEntrywith 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 200–400 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. T1–T2 scaffold in v0.8, Postgres DDL lands v0.9. T3–T12 = v0.9.
T13–T15 = 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 + wrapshostContentSecret(FIX 3, INDEX §4.5) to the enrolledagent_pubkeyand returns it inEnrollResult; 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 identicalK_content(§4.4deriveContentKey) for replay/preview decrypt. - T9/T10/T12 authenticate the calling relay node by its mTLS client-cert identity (the
nodeId/hostIdin 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}; obeysGOAWAY). - T10/T13 tear down live tunnels over the FROZEN control→data-plane bus (INDEX §4.2 FIX 4): P3
PUBLISHES a
KillSignalon the Redis pub/sub channelRELAY_REVOCATIONS_CHANNEL('relay:revocations') viaRevocationBus.publish; every P1 relay node SUBSCRIBES and injects the existing §4.1CLOSE+RST/GOAWAYwire (no new frame type) withinREVOCATION_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_fpris 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
provisionFlatstores hashes only (grep row → no raw token, INV5);verifyAgentTokentrue 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 badpgUrlthrows; a valid map parses;pairingTtlSec/pairingMaxRedeemAttemptsdefault to 600/5 when unset; CA-policy startup check — boot fails fast if the KMS key referenced bycaIntermediateKmsKeyRefcannot be resolved OR its key policy does not restrictsignto 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 KMSsign()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 thesignoperation 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.tsprovides the KMS-signer factory;ca/rotate.tsre-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-ownedrelay_nodes(node_id pk, addr, status, last_seen),metering_samples,audit_log(§4/§7), plus the INV8 companion version tablesaccount_status_versions(account_id, version, status, changed_at, changed_by, PRIMARY KEY(account_id, version))andhost_status_versions(host_id, version, status, revoked_at, changed_at, changed_by, PRIMARY KEY(host_id, version)), and a monotonicstatus_version int NOT NULL DEFAULT 1current-pointer column added toaccounts/hosts(§4 INV8 discipline; pending INDEX promotion, OQ6).host_id/account_id/session_id=uuiddefaultgen_random_uuid();subdomain UNIQUE;agent_pubkey bytea; lifecycle/business columns follow the versioned-pointer swap (no bareUPDATEof a business field without a matching*_status_versionsinsert 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;
subdomainUNIQUE rejects a dup insert;pairing_codes.code_hashis PK; a raw string in place of a$nparam is impossible (wrapper signature forbids interpolation). - Security: parameterized queries only (no string concat → no SQLi);
agent_pubkeyis a public key (INV4); pairing code stored hashed (INV5). Enforces INV4, INV5.
4. CP1 — registries (immutable repositories)
INV8 discipline (all of T3–T5) — 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 tablesaccount_status_versions(account_id, version, status, changed_at, changed_by)andhost_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)thenUPDATE base SET status/revoked_at = new, status_version = next— the single-row pointerUPDATEis 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*_versionstable (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 Postgreslast_seen/last_attach_atcolumns 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:
createAccountreturns unguessable uuid +status:'active'(writesstatus_version=1+ the seedaccount_status_versionsrow);setAccountStatusperforms the single-txnINSERT account_status_versions(next) + UPDATE accounts SET status,status_version=nextand returns the new record while the prior(status, version)remains readable fromaccount_status_versions(assert old snapshot retrievable from the versions table, not a duplicate base row); concurrentsetAccountStatus→ one whole-or-nothing winner, versions strictly monotonic, no torn read (INV8). - Security: INV8.
accountIdunguessable + 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:
bindHoststores only the public key (grep row → no private material, INV4);enroll_fpr= fingerprint ofagent_pubkey;ownsHost(A, hostOwnedByB)→ false (the INV1 ownership fact this plan supplies to P5's tripwire);getHostBySubdomainresolves the stable name;setHostStatus('revoked')performs the single-txnINSERT host_status_versions(next) + UPDATE hosts SET status='revoked', revoked_at=now(), status_version=next— prior status is still readable fromhost_status_versions(feeds INV12). Assert livenesslast_seenupdates do not create a version row (advisory cache, INV7). - Security: INV1 (host is resolvable only via
host_id/subdomainbound to an account — no raw address/port/hostname resolution path exists in this module), INV4 (pubkey only), INV6 (ownsHostis 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:
recordSessionderivesaccount_idfromhosts(ignores any caller-passed account); a reattach lookup for asession_idowned by another account resolves to that account's id (so P5's reattach check denies) — assertsessionAccountnever echoes a client-supplied value; unknownsession_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 // 1–63 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);assignSubdomainis atomic under concurrency (two callers, same requested → exactly one wins); host-header-confusion inputs (a.b,A LICE,../) rejected byisValidSubdomain. - 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 —
accountIdfrom 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 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-1234shape); display grouping round-trips to the canonical 130-bit value; DB row storescode_hashonly (grep → no raw code, INV5) withredeem_attempts = 0;expires_at= now +pairingTtlSecand 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 3–5):
// 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 →
bindHostruns, subdomain assigned,redeemed_atset,EnrollResultreturned withcert/caChainas PEM strings, a cert whose subject pubkey ==agentPubkey, and a non-emptyhostContentSecret(frozen §4.5 fields all present). - hostContentSecret mint/wrap (FIX 3, security):
EnrollResult.hostContentSecretis 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 enrolledagent_pubkey(P2 can unwrap with the matching private key, no other host can). - Double-spend (security): two concurrent
redeemPairingCodewith the same code → exactly one succeeds, the other →already_redeemed(CAS onredeemed_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:
signHostLeaffor a pubkey not in the registry → throws (assert the CA cannot be tricked into signing an unbound key), and the KMSsign()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 samecode_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.
- Happy path: valid unexpired unredeemed code →
- 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):
upsertRoutethenresolveRoutereturns the entry; afterttlSecwith no heartbeat →resolveRoutenull (heartbeat-TTL expiry = liveness, INV7);heartbeatRoutefrom acaller.nodeIdthat 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):upsertRoutewhoseentry.relayNodeId ≠ caller.nodeId→ reject, andnodeIdentityFromRequeston a non-mutually-authenticated connection → 401 (assert a caller cannot inject a route for ahostId/nodeIdit is not the authenticated identity for, even if it guesses the ids);dropRouteremoves 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
nodeIdis 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 aKillSignalon the FROZENrelay:revocationsbus (INDEX §4.2 FIX 4 — the SAME channel T13 revoke uses); P1 nodes subscribe, emit the §4.1GOAWAY, 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 barenodeIdfield:
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:
beginDrainflips status todrainingand returns exactly the host_ids routed to that node; a drained node stops receiving newupsertRoutetargeting it; node-identity (security) — aregisterNode/nodeHeartbeat/self-beginDrainwhosenodeIdis not the caller's authenticated identity (and not amanage-scoped admin) is rejected (a forged/guessednodeIdcannot 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);completeDrainderegisters only after routes moved. - Security: INV7 (stateless data plane — drain loses nothing durable). Drain uses the frozen §4.1
GOAWAYsemantics; this module does not encode the frame (that is P1) — it only decides which hosts drain and PUBLISHES aKillSignalon the frozenrelay:revocationsbus (INDEX §4.2 FIX 4, the SAME channel T13 uses) that every P1 node subscribes to and translates into the §4.1GOAWAY. OQ4 RESOLVED — the channel is now a FROZEN SHARED CONTRACT in the INDEX, not an assumed dispatcher.nodeIdin 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/:idis self-contained in v0.9 — it calls the v0.9 minimal deprovision indeprovision/deprovision.ts(deprovisionHost:setHostStatus(hostId, 'revoked')via T4 +dropRoutevia 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/:iddelegates torevokeHostfor 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 B → 403; the forgedaccount_idis never read. principalFromRequestwith a missing/expired/foreign-audtoken → 401 (delegates toverifyCapabilityToken); scopeattachcannot hit amanage/killroute (rights subset check).DELETE /hosts/:idwhere!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.
- INV3/INV1 (security, the headline test): request authenticated as account A with body
- 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.
/enrollis 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
nodeIdis the caller's authenticated mTLS identity (T9NodeIdentity), never a body field, and the sample'saccountIdis re-derived server-side from the host registry — the node-suppliedaccountIdis 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:
ingestSampleinserts a new immutable row (noUPDATE) withnodeIdset to the authenticated caller andaccountIdderived from the host registry;pairedHostCountexcludesstatus:'revoked'hosts;rollupUsagecomputes peak concurrent viewers + viewer-hours over a window; forged-attribution (security): a sample for ahostIdwhose 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 aKillSignalon the FROZENrelay:revocationsbus (INDEX §4.2 FIX 4) viaRevocationBus.publish— the SAME named channel T10's drain uses; every P1 node subscribes and injects the §4.1CLOSE+RSTwithinREVOCATION_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 aKillSignal { scope:{ kind: 'host', hostId } }is published onrelay:revocations(assert a test subscriber receives it, so a P1 node would tear the tunnel down withinREVOCATION_PUSH_BUDGET_MS) AND the host's next reconnect is refused (statusrevoked) ANDsignHostLeafrefuses its pubkey (T8 gate) — all observable within a bounded time;revokeAccountpublishes one account-scopedKillSignaland cascades to all hosts;revokeToken→isTokenRevokedtrue and stops validating (INV12); a revoked host's capability tokens stop authorizing (viarevoked:{jti}+ status). Assert the publishedKillSignal.reasoncarries 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
writeAuditEventcall carrying ametavalue 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 inaudit_log); entries are append-only (noUPDATE/DELETEpath 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.tsmerely importsboot/ca-wiring.ts; T15 edits no file owned by another task. The KMS-signer factory inboot/ca-wiring.tsis the shared primitive bothca/sign.ts(T8) andca/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:
renewHostLeaffor 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);buildCaSignerfails fast at startup ifcaIntermediateKmsKeyRefis unresolvable OR its KMS policy is broader than the control-plane service principal (§3.1, INV9); assert renewal invokesCaSigner.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 # T3–T5 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 forHostRecord/RouteEntry. P5/P6/metering will want the account/session TS shapes too. Should they be promoted intorelay-contracts/§4.2 (via the INDEX) rather than mirrored locally incontrol-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 aKillSignal { scope, at, reason }(RevocationScope = { kind:'host' } | { kind:'account' } | { kind: 'global' }), published viaRevocationBus.publishand promoted intorelay-contractsso P3 never imports P5 (DAG stays acyclic). P3/P5 publish; every P1 node subscribes and injects the existing §4.1CLOSE+RST(host/account scope) or connection-levelGOAWAY(global scope) — no new wire type — withinREVOCATION_PUSH_BUDGET_MS = 2000. T10 drain and T13 revoke use this SAME named channel (this plan cites it verbatim, redefining nothing). Residual: the frozenRevocationScopehas nonodekind and no graceful-vs-immediate discriminator, so a single-node operator drain (T10) is expressed via per-hostKillSignals (with a drainreason) rather than one node-scoped signal — flagged back to the INDEX in case anodescope or amode: '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
nodeIdentityFromRequestderivesnodeIdfrom a verified relay-node mTLS client cert, and T9/T10/T12 reject any bare request-suppliednodeId. 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/sessionsas single-PK rows, which cannot carry versioned snapshots. This plan addsaccount_status_versions/host_status_versions+ astatus_versionpointer 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.)