# 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://.` 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 ` 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 `.` → `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://.` → 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:///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).