# PLAN_RELAY_PHASE1 — deploy the native rendezvous-relay to a single VPS (staging, durable)

> **Decision (2026-07-06):** Build **Phase 1 "1b — full durable staging"** on cloud VPS `8.138.1.192`
> (Alibaba Cloud, mainland). Real **Postgres + Redis via Docker Compose on the VPS**; real **TLS on
> `:443` via Let's Encrypt** against an **already ICP-filed domain**; agent runs on the operator's own
> machine and dials OUT. This plan is the file-level execution spec derived from a full code audit of
> the 7 relay packages. It supersedes the high-level phasing in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4
> for the concrete build; that doc still holds for the *why* and the security runbook (§7).
>
> **Scope OUT (→ Phase 2):** real KMS custody of the CA (dev in-process signer is accepted for
> staging), F6 recoverable-replay transport (`loadReplay` stays fail-closed — not on the terminal path),
> WebAuthn/passkey step-up (staging uses `NO_STEPUP_POLICY` — single operator), wildcard multi-tenant
> subdomains, metering/alerting. Single tenant, single host, single relay node.

---

## 0. Target (what "done" means)

From the operator's laptop (agent dialing out) and any browser:

1. `https://<sub>.<BASE_DOMAIN>` serves the relay-web bundle (same origin as the WSS endpoint).
2. Operator logs in, picks the host, and gets a live shell **spliced by the real relay-node** — the
   relay only sees ciphertext (INV2); E2E is browser↔agent.
3. Agent enrolled once via a pairing code → holds a SPIFFE mTLS cert → dials `wss://…:AGENT_PORT`.
4. **Restart-safe:** bouncing the relay/control-plane process does NOT lose the host registration or
   kill the operator's PTY (state is in Postgres/Redis; INV7).
5. Revoking the host tears the tunnel down within the INV12 budget (Redis `relay:revocations`).

---

## 1. Code audit summary — what exists vs. what must be built

Security-critical *logic* is written and injectable across all 7 packages. Phase 1 = build the
integration + infra layer they were designed to receive. Grounded gaps:

| Area | State (file evidence) | Task |
|---|---|---|
| P3 Postgres adapter | **absent** — `store/pg.ts` does not exist; only `db/pool.ts` (`createPgPool`/`createQuery`) + full `db/migrations/0001_init.sql`. `memory.ts` is the semantics reference (INV8 versioning, CAS). | **A1** |
| P3 migration runner | none | **A1** |
| P3 server entrypoint | **absent** — no `.listen()`, no `start`; only `buildControlPlane(env, overrides)` factory (`main.ts:60`). | **A2** |
| P3 Redis revocation bus | `createRedisRevocationBus(RedisPublisher)` exists (`routing/bus.ts:42`) but no `ioredis` client instantiated; `main.ts:93` leaves `bus` inert. | **A2** |
| P3 F2 capability verifier | throwing stub `refuseAllVerifier` (`main.ts:44`). Must inject relay-auth `verifyCapabilityToken`. **Impedance:** CP's `CapabilityVerifier.verify` is **sync** (`api/authz.ts:24`), relay-auth's is **async** → seam must go async. | **A3** |
| relay EnforceDeps over shared store | relay-run uses its own in-RAM fakes (`relay-run/src/wiring/memory-stores.ts`), a **separate world** from the CP. Must implement `HostRegistryPort`/`SessionRegistryPort`/`RevocationStore`/`TokenBucketStore`/`AuditSink` over the **same** Postgres/Redis. | **B1** |
| relay `MtlsVerifier` | stub returns seeded host for any cert (`relay-world.ts:149`). Must use relay-auth `verifyAgentCert(leaf, caChain, now, hosts)` against the shared host registry + pinned agent CA. | **B2** |
| relay `RouteResolver` | one-entry in-RAM map (`data-plane.ts:110`). Must resolve subdomain→hostId from `hosts.getBySubdomain`. | **B3** |
| relay revocation subscriber | not wired. Subscribe `relay:revocations` → `killsScope` → `closeStream`. | **B4** |
| relay-run Phase-1 entry | `main.ts` hardcodes `127.0.0.1:8443`, `baseDomain='term.localhost'`, self-signed certs, dials no agent, `NO_STEP_UP`. | **B5** |
| agent build | `noEmit:true`, no `build` script, `dist/cli.js` never produced. | **C1** |
| agent runnable entry | `cli.ts` exports `parseArgs`/`runCli` but has **no `main()`/argv bootstrap, no `CliDeps` factory, no concrete `runTunnel`** (only assembled in `test/acceptance/cafeDemo.test.ts`). | **C2** |
| relay-web static serve | `build.mjs` emits `public/build/`; **no HTTP server** ships. Must serve `public/` same-origin as WSS. | **D1** |
| Infra | no Dockerfiles/compose/systemd anywhere. | **E** |

---

## 2. Build order (waves by dependency)

```
A (P3 durable + serving)  ──▶  B (relay data-plane on shared store)  ──▶  E (infra + wire-up)
                          └──▶  C (agent buildable + runnable)  ──────────▶
                          └──▶  D (relay-web served)  ────────────────────▶
```

A is the foundation (both planes read the same store). B/C/D can proceed once A's store contract is
green. E stands up infra and does the end-to-end enroll→dial→click-through.

### Wave A — control-plane durable + serving
- **A1 · PG store adapter + migration runner.** `Owns: control-plane/src/store/pg.ts`,
  `control-plane/src/db/migrate.ts`, `control-plane/test/store/pg.test.ts`.
  `createPgStores(query: QueryFn): Stores` implementing all 9 ports (`store/ports.ts`) with **byte-for-byte
  semantics parity with `memory.ts`**: INV8 status-version+pointer swaps in one transaction, `casRedeem`
  single-winner, `registerFailure` increment, `reserve` single-winner on the `subdomain UNIQUE`
  constraint, TTL on routes (store `expires_at`, filter on read — Postgres has no key TTL). Map
  snake_case columns ↔ camelCase records. Migration runner applies `db/migrations/*.sql` idempotently.
  **Verify:** TDD against a real Postgres (Testcontainers or a Docker `postgres:16` on
  `127.0.0.1:5432`); run the SAME behavioral suite the memory store passes.
- **A2 · P3 server entrypoint + Redis wiring.** `Owns: control-plane/src/server.ts`,
  `control-plane/src/boot/redis.ts`, `package.json` (`start` script).
  `loadEnv(process.env)` → `createPgPool(PG_URL)`+`createQuery` → `createPgStores` → migrate →
  `ioredis` client → `createRedisRevocationBus(redis)` → `buildControlPlane(env, {stores, bus,
  verifier, caChainDer})` → `app.listen({host:'0.0.0.0', port})`. Graceful SIGTERM. **Verify:** boots
  against Docker PG+Redis; `POST /accounts` then `POST /accounts/:id/pairing-codes` round-trips.
- **A3 · F2 capability verifier (async).** `Owns: control-plane/src/api/authz.ts` (make
  `CapabilityVerifier.verify` return `Promise`, await at call sites), `control-plane/src/boot/verifier.ts`,
  `control-plane/src/main.ts` (default + `overrides.verifier` plumb + call `loadVerifyKeyFromEnv`).
  Real verifier delegates to relay-auth `verifyCapabilityToken(raw, expectedAud, now)`; configure the
  verify key from `CAPABILITY_SIGN_PUBKEY_B64` at boot. **Verify:** a token signed by the matching key
  verifies; a foreign/expired token 401s; unblocks programmatic account/pairing seeding.

### Wave B — relay data-plane on the shared store (P1 via relay-run)
- **B1 · shared-store EnforceDeps.** `Owns: relay-run/src/wiring/stores-pg.ts`,
  `relay-run/test/stores-pg.test.ts`. Implement relay-auth's `HostRegistryPort`, `SessionRegistryPort`,
  `RevocationStore`, `TokenBucketStore` (Redis token bucket), `AuditSink` (append to `audit_log`) over
  the SAME PG/Redis as P3. Replace `memory-stores.ts` in the Phase-1 path.
- **B2 · registry-backed `MtlsVerifier`.** `Owns: relay-run/src/wiring/mtls-verifier.ts`. Wrap
  relay-auth `verifyAgentCert(leafPem, caChainPem, now, hosts)`; pin the agent CA bundle; return
  `{hostId, accountId}` only for enrolled+unrevoked hosts (INV4/INV14).
- **B3 · store-backed `RouteResolver`.** `Owns: relay-run/src/wiring/route-resolver.ts`.
  `resolve(subdomain)` → `hosts.getBySubdomain` → hostId (or null).
- **B4 · revocation subscriber.** `Owns: relay-run/src/wiring/revocation-subscriber.ts` (or reuse
  `term-relay/data-plane/revocation-subscriber.ts`). ioredis SUBSCRIBE `relay:revocations` → parse
  `KillSignal` → `killsScope(signal, hostAccountId, hostId)` → `node.closeStream(...)`.
- **B5 · relay-run Phase-1 entry.** `Owns: relay-run/src/main-phase1.ts`, `relay-run/package.json`
  (`start:phase1`). Env-driven (no hardcoding, CLAUDE §Config): bind `0.0.0.0`; `BASE_DOMAIN` +
  computed `allowedOrigins` (port-less on :443, exact-match at `onUpgrade.ts:102`); real LE cert/key
  paths for browser WSS; **separate** private agent-CA bundle for mTLS; `loadVerifyKeyFromEnv` at
  startup; compose B1–B4; keep `NO_STEPUP_POLICY` (staging). Leave the Phase-0 `main.ts` untouched for dev.

### Wave C — agent buildable + runnable (P2)
- **C1 · agent build.** `Owns: agent/tsconfig.build.json`, `agent/package.json` (`build`). Emit
  `dist/cli.js` (esbuild bundle or `tsc` emit; keep `src` tsconfig `noEmit` for typecheck).
- **C2 · CLI bootstrap + `runTunnel`.** `Owns: agent/src/main.ts` (shebang + argv → `runCli`),
  `agent/src/transport/runTunnel.ts`, `agent/src/cli/deps.ts` (`CliDeps` factory). Assemble
  `dialRelay`→`holdTunnel`→`createStreamRouter`→`dialLoopback` + heartbeat/backoff, porting the proven
  wiring from `test/acceptance/cafeDemo.test.ts`. **Verify:** `web-terminal-agent pair <CODE>` then
  `web-terminal-agent run` enrolls + dials against a local Phase-1 relay.

### Wave D — serve relay-web (P6)
- **D1 · same-origin static server.** `Owns: relay-run/src/servers/static-web.ts` (fold into the
  browser HTTPS server so the bundle is served from the SAME origin/cert as the WSS, keeping
  Origin/CSP aligned). Serve `relay-web/public/` (built). Add `npm --prefix relay-web run build` to the
  deploy step. `loadReplay` stays fail-closed (Phase 2).

### Wave E — infra + integration on 8.138.1.192
- **E1 · Docker Compose** `Owns: deploy/docker-compose.yml`, `deploy/.env.example`. `postgres:16` +
  `redis:7`, volumes, bound to `127.0.0.1` (not public). Health checks.
- **E2 · DNS + TLS.** A-record `<sub>.<BASE_DOMAIN>` → `8.138.1.192`; Let's Encrypt cert
  (HTTP-01 on :80 or DNS-01) for that subdomain → `TLS_CERT_PATH`/`TLS_KEY_PATH`.
- **E3 · Enrollment CA.** Generate the private agent CA (staging may reuse the CP dev signer's CA) →
  `CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `AGENT_CA_CERT_PATH`. **Never** LE for mTLS.
- **E4 · Env + systemd + security group.** `deploy/control-plane.env`, `deploy/relay.env`,
  `deploy/*.service`; open inbound `:443` (browser) + `AGENT_PORT` (agent mTLS) in the Aliyun security
  group; keep CP admin + PG + Redis loopback-only.
- **E5 · End-to-end.** `POST /accounts` → `POST /accounts/:id/pairing-codes` → agent `pair`+`run` on
  the laptop → browser opens `https://<sub>.<BASE_DOMAIN>` → click through to the shell. Confirm
  restart-safety + revocation teardown.

---

## 3. Environment / config reference (Phase 1, this VPS)

**control-plane** (`control-plane/src/env.ts`, all required unless defaulted):
`PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (32-byte Ed25519, base64), `CA_INTERMEDIATE_KMS_KEY_REF`
(dev signer accepts any ref), `CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`;
defaulted `HEARTBEAT_TTL_SEC`=15, `PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5.

**relay-run Phase 1** (`term-relay/data-plane/config.ts` + new): `BASE_DOMAIN`, `BIND_HOST`=0.0.0.0,
`BIND_PORT`=443, `TLS_CERT_PATH`, `TLS_KEY_PATH`, `AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`,
`AGENT_CA_CHAIN_PATH`, `RELAY_NODE_ID`, `RELAY_AUTH_VERIFY_PUBKEY` (= `CAPABILITY_SIGN_PUBKEY_B64`,
base64url), `RELAY_TRUST_DOMAIN`, `PG_URL`, `REDIS_URL`.

**agent** (`agent/src/config/agentConfig.ts`): `RELAY_URL` (`wss://…:AGENT_PORT`), `ENROLL_URL`
(`https://<cp-host>/enroll`), `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL` (`ws://127.0.0.1:3000` — the
base app), `STATE_DIR` (`~/.web-terminal-agent`).

> **Key agreement (linchpin):** the P5 capability-signing keypair — CP signs, relay verifies. CP env
> `CAPABILITY_SIGN_PUBKEY_B64` and relay env `RELAY_AUTH_VERIFY_PUBKEY` MUST be the SAME public key.
> The **agent enrollment CA** (mTLS) and the **browser LE cert** are two independent trust chains.

---

## 4. Invariants to preserve (do not regress)
INV2 opaque splice (relay sees only ciphertext) · INV3 accountId only from authenticated material ·
INV7 PTY≠WS, relay nodes stateless/restart-safe · INV8 immutable versioned status · INV10 zero-payload
audit · INV12 revocation teardown budget · INV14 registry-gated mTLS. Origin/CSWSH exact-match on every
browser upgrade. Config via env only — no hardcoded hosts/ports/secrets.

---

## 5. Progress
Tracked in [PROGRESS_LOG.md](./PROGRESS_LOG.md) under a `RELAY-PHASE1` heading. Task IDs: `A1–A3`,
`B1–B5`, `C1–C2`, `D1`, `E1–E5`. Orchestrator appends one entry per task on completion (status, files,
verification command+result, deviations, next).
