RELAY-PHASE1 Wave A (2/3) + E1 infra: - A1: createPgStores() Postgres adapter for all 9 P3 store ports + runMigrations(); writable-CTE atomic INV8 status swaps; 0002_routes.sql. 23/23 pg tests, 96.72% cov. - A3: real capability verifier delegating to relay-auth verifyCapabilityToken; sync->async seam across authz/provision/main. Full CP suite 15 files/101 pass, tsc clean. - E1: deploy/docker-compose.yml (Postgres16+Redis7, loopback-only) + .env.example. - docs: PLAN_RELAY_PHASE1.md file-level execution spec; PROGRESS_LOG RELAY-PHASE1 section.
13 KiB
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:443via 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 §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 (
loadReplaystays fail-closed — not on the terminal path), WebAuthn/passkey step-up (staging usesNO_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:
https://<sub>.<BASE_DOMAIN>serves the relay-web bundle (same origin as the WSS endpoint).- 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.
- Agent enrolled once via a pairing code → holds a SPIFFE mTLS cert → dials
wss://…:AGENT_PORT. - 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).
- 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): Storesimplementing all 9 ports (store/ports.ts) with byte-for-byte semantics parity withmemory.ts: INV8 status-version+pointer swaps in one transaction,casRedeemsingle-winner,registerFailureincrement,reservesingle-winner on thesubdomain UNIQUEconstraint, TTL on routes (storeexpires_at, filter on read — Postgres has no key TTL). Map snake_case columns ↔ camelCase records. Migration runner appliesdb/migrations/*.sqlidempotently. Verify: TDD against a real Postgres (Testcontainers or a Dockerpostgres:16on127.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(startscript).loadEnv(process.env)→createPgPool(PG_URL)+createQuery→createPgStores→ migrate →ioredisclient →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 /accountsthenPOST /accounts/:id/pairing-codesround-trips. - A3 · F2 capability verifier (async).
Owns: control-plane/src/api/authz.ts(makeCapabilityVerifier.verifyreturnPromise, await at call sites),control-plane/src/boot/verifier.ts,control-plane/src/main.ts(default +overrides.verifierplumb + callloadVerifyKeyFromEnv). Real verifier delegates to relay-authverifyCapabilityToken(raw, expectedAud, now); configure the verify key fromCAPABILITY_SIGN_PUBKEY_B64at 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'sHostRegistryPort,SessionRegistryPort,RevocationStore,TokenBucketStore(Redis token bucket),AuditSink(append toaudit_log) over the SAME PG/Redis as P3. Replacememory-stores.tsin the Phase-1 path. - B2 · registry-backed
MtlsVerifier.Owns: relay-run/src/wiring/mtls-verifier.ts. Wrap relay-authverifyAgentCert(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 reuseterm-relay/data-plane/revocation-subscriber.ts). ioredis SUBSCRIBErelay:revocations→ parseKillSignal→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): bind0.0.0.0;BASE_DOMAIN+ computedallowedOrigins(port-less on :443, exact-match atonUpgrade.ts:102); real LE cert/key paths for browser WSS; separate private agent-CA bundle for mTLS;loadVerifyKeyFromEnvat startup; compose B1–B4; keepNO_STEPUP_POLICY(staging). Leave the Phase-0main.tsuntouched for dev.
Wave C — agent buildable + runnable (P2)
- C1 · agent build.
Owns: agent/tsconfig.build.json,agent/package.json(build). Emitdist/cli.js(esbuild bundle ortscemit; keepsrctsconfignoEmitfor typecheck). - C2 · CLI bootstrap +
runTunnel.Owns: agent/src/main.ts(shebang + argv →runCli),agent/src/transport/runTunnel.ts,agent/src/cli/deps.ts(CliDepsfactory). AssembledialRelay→holdTunnel→createStreamRouter→dialLoopback+ heartbeat/backoff, porting the proven wiring fromtest/acceptance/cafeDemo.test.ts. Verify:web-terminal-agent pair <CODE>thenweb-terminal-agent runenrolls + 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). Serverelay-web/public/(built). Addnpm --prefix relay-web run buildto the deploy step.loadReplaystays 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 to127.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→ agentpair+runon the laptop → browser openshttps://<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_B64and relay envRELAY_AUTH_VERIFY_PUBKEYMUST 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 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).