feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts

RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass):
- A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script.
- B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3).
- B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14).
- B3: store-backed RouteResolver (subdomain->hostId).
- B4: Redis relay:revocations subscriber -> tunnel teardown (INV12).
- B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint.
- B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol.
- C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps.
- D1: same-origin static serve of relay-web/public from the browser WSS.
- E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md.
Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on
browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2);
scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
This commit is contained in:
Yaojia Wang
2026-07-06 16:13:34 +02:00
parent 95b9cccf07
commit aa1912b962
40 changed files with 4783 additions and 19 deletions

View File

@@ -12,6 +12,7 @@ import { APP_SUBPROTOCOL } from 'relay-contracts'
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
import { serveStatic } from './static-web.js'
export interface BrowserServerOptions {
readonly certPath: string
@@ -20,6 +21,20 @@ export interface BrowserServerOptions {
readonly bindPort: number
readonly node: RelayNode
readonly landingHtml: string
/**
* When set, the HTTP handler serves the built relay-web bundle from this directory (D1), SAME
* ORIGIN as the WSS (so Origin/CSP stay aligned). When unset, `landingHtml` is served — the
* Phase-0 dev fallback. The WS upgrade is unaffected either way (it rides the `upgrade` event).
*/
readonly staticRoot?: string
/**
* Optional pre-router (B5): consulted BEFORE `staticRoot`/`landingHtml` on every non-upgrade HTTP
* request. Return `true` to claim the request (the hook owns the response — it may finish it
* asynchronously); return `false` to fall through to the static/landing behavior below. Default
* (undefined) preserves D1's behavior exactly. WS upgrades never reach this hook (they ride the
* `upgrade` event), so same-origin `POST /auth/mint` can coexist with the WSS.
*/
readonly onRequest?: (req: IncomingMessage, res: ServerResponse) => boolean
readonly onListening?: () => void
readonly onError?: (e: unknown) => void
}
@@ -56,7 +71,24 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
export function startBrowserServer(opts: BrowserServerOptions): Server {
const server = createServer(
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
(_req: IncomingMessage, res: ServerResponse) => {
(req: IncomingMessage, res: ServerResponse) => {
// Pre-router (B5): a claimed request is fully owned by the hook (e.g. POST /auth/mint) and
// must NOT fall through to static/landing (which would double-write the response).
if (opts.onRequest !== undefined && opts.onRequest(req, res)) return
// Static-bundle mode (Phase 1): serve relay-web from `staticRoot`, SAME-ORIGIN as the WSS.
// Non-upgrade HTTP requests only — WS upgrades never reach this handler (see `upgrade` event).
if (opts.staticRoot !== undefined) {
const file = serveStatic(opts.staticRoot, req.url ?? '/')
if (file) {
res.writeHead(file.status, file.headers)
res.end(file.body)
} else {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
res.end('Not Found')
}
return
}
// Phase-0 fallback: a single landing page.
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(opts.landingHtml)
},