# Phase 0 — single-host runnable relay (build spec) > Goal: a single `npm start` that boots an **end-to-end relay you can click through locally** — > real P4(crypto)+P5(auth) wiring, in-memory stores, self-signed TLS, agent dialing `localhost`, > `relay-web` served. No Postgres/Redis/KMS. This is the executable spec for the Phase 0 milestone > in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4. Reuses the wiring proven in `e2e/harness/`. > > **Run this build in a FRESH session** (recommend `/compact` first). Read the seam files named below > before writing — the exact interface shapes must be confirmed against the code, not assumed. ## Deliverable A new package `relay-run/` (top-level, symlink node_modules like `e2e/`) with one entry `relay-run/src/main.ts` that `npm start` runs, plus small wiring modules. Result: browser at `https://localhost:8443/` → authenticates → live terminal to a shell on the same machine, spliced through the real relay-node, E2E-encrypted (relay sees only ciphertext). ## Seam files to read FIRST (confirm exact shapes) - `term-relay/data-plane/ws-like.ts` — the `WebSocketLike` interface the relay-node consumes. - `term-relay/data-plane/agent-listener.ts` — `AgentListener`/`AgentTunnel` shapes (how an agent mux attaches). - `term-relay/data-plane/relay-node.ts` + `upgrade.ts` + `authz-port.ts` — `createRelayNode`, `authorizeUpgrade`, the `Authorizer` port (`onUpgrade`/`onReattach`). - `term-relay/mux/mux-session.ts` — `createMuxSession`, `MuxStreamHandle` (frame the agent side). - `control-plane/src/main.ts` — `buildControlPlane`/overrides (`stores`, `verifier`, `bus`, `kmsResolver`), `createMemoryStores`, `inProcessCaSigner`. - `agent/src/cli.ts` + `agent/src/e2e/hostEndpoint.ts` + `transport/dial.ts` — how the agent dials + runs the E2E host endpoint + loopback splice. - `e2e/harness/world.ts` — the REFERENCE wiring: it already composes real P5 `onUpgrade` + P4 handshake + fakes. Lift the `EnforceDeps`/registry/bucket/audit construction from here (and `e2e/harness/fakes.ts`). ## Build steps (order) 1. **Scaffold `relay-run/`**: `package.json` (type module, `"start":"tsx src/main.ts"`, dep on `tsx`, `ws`), `tsconfig.json`, and `node_modules` symlinks to all relay packages + `ws`/`tsx` (mirror `e2e/`’s manual-symlink approach — avoids `npm install`). Add `relay-run/node_modules` to `.gitignore` coverage (root already ignores `node_modules/`). 2. **Self-signed TLS**: generate a localhost cert at boot (node `selfsigned` or a checked-in dev cert) for the browser WSS listener; a throwaway CA for agent mTLS. 3. **Control-plane (in-memory)**: boot `buildControlPlane` with `overrides = { stores: createMemoryStores(), verifier: , bus: in-memory, kmsResolver: inProcessCaSigner }`. Listen on `:8080`. Seed one account + issue a pairing code programmatically at boot. 4. **P5 EnforceDeps**: build the real `EnforceDeps` (hosts/sessions/revocation/buckets/audit + `stepUpPolicyFor: () => NO_STEPUP_POLICY`) backed by the in-memory control-plane registries — copy the construction from `e2e/harness`. Configure the P5 verify key (`configureVerifyKey`). 5. **`WebSocketLike` adapter**: wrap `ws` `WebSocket` → the `WebSocketLike` interface (send/close/onmessage/onclose per ws-like.ts). Browser WSS server on `:8443`; on upgrade, build the `UpgradeRequest` (extract token from subprotocol/cookie, Origin, DPoP header) and call `authorizeUpgrade` (→ real `onUpgrade`). **Close F2 here**: pass a resolved principal when the host policy requires step-up (Phase 0 default policy = not-required, so `null` is fine). 6. **`AgentListener` adapter**: mTLS `tls.Server` on `:8444`; verify the agent client cert against the dev CA (`verifyAgentCert`), attach each as an `AgentTunnel` + `createMuxSession`. Wire `createRelayNode({ config, agentListener, authorizer })` and the opaque splice. 7. **Agent**: build the CLI (`dist/cli.js`) or run `agent` in-process; enroll against control-plane `POST /enroll` with the seeded pairing code (→ SPIFFE cert + `hostContentSecret` + subdomain); dial `wss://localhost:8444` (mTLS) and run the E2E host endpoint splicing to a local shell (`LOCAL_TARGET_URL` / node-pty). 8. **relay-web**: `npm --prefix relay-web run build`; serve the bundle from the browser listener (same origin as WSS so Origin/CSWSH passes). Wire the login → P5 capability-token mint (Phase 0: a dev endpoint that mints a token after a stub human-auth, or reuse `issueCapabilityToken` directly). 9. **`main.ts`**: bring up 3→5→6→7→8 in order; print the URL + a ready-to-use token/subdomain. ## Verify - `npm start` in `relay-run/` boots without error; console prints the local URL + subdomain. - Browser: open `https://localhost:8443/?...`, authenticate, see a live shell; type `whoami` → output. - Kill/restart the agent → session survives per design (reconnect); `revoke()` → stream tears down. - Add a smoke test (Vitest) that boots the world in-process and round-trips one command E2E through the real relay-node (extend the `e2e/harness` pattern with the real `WebSocketLike` adapter). ## Explicitly NOT in Phase 0 Postgres/Redis (in-memory only), real KMS (dev in-process signer), wildcard TLS/multi-tenant subdomain routing, horizontal scaling, F6 replay-epoch transport (preview stays inert), metering. Those are Phase 1/2 in DEPLOY_RELAY.md. ## Build outcome (2026-07-04) + seam drifts found Built as `relay-run/`. **P1 achieved + verified** (4 tests green, tsc clean): an in-process integration test round-trips a sealed payload both directions through the REAL `createRelayNode` splice + REAL `createMuxSession`, authorized by the REAL relay-auth `onUpgrade` (Origin/CSWSH + capability verify + DPoP PoP + single-use jti) with REAL P4 E2E; **INV2 asserted** (relay sees only ciphertext); negative controls (foreign Origin → 401, missing DPoP → 401). **P2 achieved** (`npm start` boots self-signed TLS + browser WSS `:8443` + agent mTLS `:8444` + control-plane `:8080`, prints a minted capability token). **P3 not landed** (no agent dial / node-pty / relay-web serve). No audited package modified. **Two real integration blockers surfaced by actually wiring it (reported, not hacked):** 1. **DpopContext shape drift** — term-relay's `authz-port` `DpopContext` is `{proof, publicKeyThumbprint}` but relay-auth needs `{proofJws, htu, htm}`. Reconciled in `relay-run/src/wiring/authorizer.ts` by deriving `htu`/`htm` from the relay's own authority (`expectedAud`) — spec-faithful (RFC 9449), the DPoP binding stays fully enforced. Consider aligning the port type upstream. 2. **control-plane `CapabilityVerifier.verify` is SYNC, relay-auth `verifyCapabilityToken` is ASYNC** (WebCrypto). The real P5 verifier therefore cannot be injected into the control-plane admin routes → **programmatic account+pairing seeding is blocked in Phase 0** (control-plane boots on its default fail-closed verifier). Fix: widen `CapabilityVerifier.verify` to return `Promise` (1-line additive in `control-plane/src/api/authz.ts`), then inject the real async verifier. **Next steps to a full browser click-through:** (a) the async-verify widen above; (b) a browser-usable DPoP carrier on the WS upgrade (subprotocol/cookie/preflight — browsers can't set a `dpop` header); (c) run the agent (enroll → mTLS dial → E2E host endpoint → node-pty); (d) swap the Phase-0 `mtls.verifyPeer` stub for the registry-gated `verifyAgentCert` and serve the built `relay-web` bundle. ## Security notes (keep even in dev) - Real Origin/CSWSH check on the WSS upgrade (don’t stub it off for localhost — set allowedOrigins to the dev origin). Real capability-token verify + DPoP (no bypass). Self-signed certs are dev-only; never reuse the dev CA anywhere real. hostContentSecret 0600, never logged.