# 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: <real P5 verifyCapabilityToken bound to the dev P5 pubkey>, 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<CapabilityToken>`
   (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.
