diff --git a/docs/PLAN_RELAY_RUN_PHASE0.md b/docs/PLAN_RELAY_RUN_PHASE0.md index 2348301..18a1e42 100644 --- a/docs/PLAN_RELAY_RUN_PHASE0.md +++ b/docs/PLAN_RELAY_RUN_PHASE0.md @@ -46,6 +46,33 @@ Postgres/Redis (in-memory only), real KMS (dev in-process signer), wildcard TLS/ 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; diff --git a/relay-run/package.json b/relay-run/package.json new file mode 100644 index 0000000..992a300 --- /dev/null +++ b/relay-run/package.json @@ -0,0 +1,16 @@ +{ + "name": "relay-run", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Phase 0 single-host runnable rendezvous-relay. Composes the REAL P1 (term-relay data-plane), P4 (relay-e2e), and P5 (relay-auth) exports through in-memory control-plane stores + self-signed TLS, so a browser can click through to a local shell E2E-spliced by the real relay-node. Dev only. See docs/PLAN_RELAY_RUN_PHASE0.md.", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "tsx src/main.ts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + } +} diff --git a/relay-run/src/certs/dev-certs.ts b/relay-run/src/certs/dev-certs.ts new file mode 100644 index 0000000..ca7fc9c --- /dev/null +++ b/relay-run/src/certs/dev-certs.ts @@ -0,0 +1,77 @@ +/** + * Dev-only self-signed TLS material, generated at boot via the system `openssl`. NEVER for + * production: a throwaway CA + a browser server cert + an agent-facing server cert signed by that + * CA. Private keys are written 0600 and never logged (INV9). The dev CA must never be reused + * anywhere real. + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, chmodSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export interface DevCerts { + readonly dir: string + readonly browserCertPath: string + readonly browserKeyPath: string + readonly agentServerCertPath: string + readonly agentServerKeyPath: string + readonly caCertPath: string + readonly caCertPem: string + cleanup(): void +} + +function openssl(args: readonly string[], cwd: string): void { + execFileSync('openssl', args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] }) +} + +/** + * Generate the dev PKI. Uses Ed25519 keys (OpenSSL 3). Throws if `openssl` is unavailable or a + * step fails — the caller fails fast rather than booting without TLS. + */ +export function generateDevCerts(browserCn: string): DevCerts { + const dir = mkdtempSync(join(tmpdir(), 'relay-run-certs-')) + const p = (name: string): string => join(dir, name) + + // Throwaway CA (agent mTLS trust anchor). + openssl( + ['req', '-x509', '-newkey', 'ed25519', '-keyout', 'ca.key', '-out', 'ca.crt', '-days', '7', + '-nodes', '-subj', '/CN=relay-run-dev-ca'], + dir, + ) + + // Browser-facing server cert (self-signed; the user accepts it once). + openssl( + ['req', '-x509', '-newkey', 'ed25519', '-keyout', 'browser.key', '-out', 'browser.crt', + '-days', '7', '-nodes', '-subj', `/CN=${browserCn}`, + '-addext', `subjectAltName=DNS:${browserCn},DNS:localhost,IP:127.0.0.1`], + dir, + ) + + // Agent-facing server cert, signed by the dev CA (the agent pins this CA). + openssl( + ['req', '-new', '-newkey', 'ed25519', '-keyout', 'agent-server.key', '-out', 'agent-server.csr', + '-nodes', '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'], + dir, + ) + openssl( + ['x509', '-req', '-in', 'agent-server.csr', '-CA', 'ca.crt', '-CAkey', 'ca.key', + '-CAcreateserial', '-out', 'agent-server.crt', '-days', '7', '-copy_extensions', 'copy'], + dir, + ) + + for (const key of ['ca.key', 'browser.key', 'agent-server.key']) chmodSync(p(key), 0o600) + + return { + dir, + browserCertPath: p('browser.crt'), + browserKeyPath: p('browser.key'), + agentServerCertPath: p('agent-server.crt'), + agentServerKeyPath: p('agent-server.key'), + caCertPath: p('ca.crt'), + caCertPem: readFileSync(p('ca.crt'), 'utf8'), + cleanup() { + rmSync(dir, { recursive: true, force: true }) + }, + } +} diff --git a/relay-run/src/main.ts b/relay-run/src/main.ts new file mode 100644 index 0000000..2c3b3d6 --- /dev/null +++ b/relay-run/src/main.ts @@ -0,0 +1,135 @@ +/** + * Phase 0 live process — `npm start`. Boots the single-host rendezvous-relay with REAL security: + * • self-signed TLS (dev CA + browser + agent-facing certs), + * • the REAL relay-node data plane spliced between a browser WSS listener (:8443) and an agent + * mTLS listener (:8444) — every upgrade runs the real Origin/CSWSH + capability verify + DPoP + * path (P5 `onUpgrade`), + * • an in-memory control-plane (:8080, best-effort). + * Prints a ready URL + a freshly-minted browser capability token + the tenant subdomain. + * + * A FULL browser click-through is NOT expected in Phase 0: browsers can't attach the DPoP header + * to a WS upgrade, and no agent tunnel is dialed here yet (P3). This process proves the servers + * boot and the authz/splice pipeline is wired end to end. + */ +import { createAuthorizer } from './wiring/authorizer.js' +import { buildDataPlane, makeDataPlaneConfig } from './wiring/data-plane.js' +import { buildPhase0World } from './wiring/relay-world.js' +import { generateDevCerts } from './certs/dev-certs.js' +import { makeAgentTlsServerFactory } from './servers/agent-tls.js' +import { startBrowserServer } from './servers/browser-server.js' +import { bootControlPlane } from './servers/control-plane-boot.js' + +const BIND_HOST = '127.0.0.1' +const BROWSER_PORT = 8443 +const AGENT_PORT = 8444 +const CONTROL_PLANE_PORT = 8080 +// Capability tokens are deliberately short-lived (relay-auth caps connect TTL at 60s). +const TOKEN_TTL_SEC = 60 + +function landingHtml(subdomain: string): string { + return `relay-run (Phase 0) +

rendezvous-relay · Phase 0 (dev)

+

Tenant subdomain: ${subdomain}

+

This dev relay boots the REAL data-plane splice + P5 authz. A full terminal click-through needs +a dialed agent tunnel and the browser DPoP transport (Phase 3).

` +} + +async function main(): Promise { + const now = () => Math.floor(Date.now() / 1000) + const world = await buildPhase0World(now()) + const subdomain = world.host.subdomain + const browserCn = `${subdomain}.${world.baseDomain}` + + const certs = generateDevCerts(browserCn) + + // A real browser's Origin includes the listener port, so the CSWSH allow-list must too + // (the world's default origin is port-less for the in-process test). + const allowedOrigins = [`https://${browserCn}:${BROWSER_PORT}`, ...world.allowedOrigins] + + // Real-time authz (token exp / DPoP freshness advance with the wall clock). + const authorizer = createAuthorizer({ + deps: world.deps, + allowedOrigins, + now, + }) + + const config = makeDataPlaneConfig({ + baseDomain: world.baseDomain, + bindHost: BIND_HOST, + bindPort: BROWSER_PORT, + agentBindPort: AGENT_PORT, + }) + + // Boots the agent-facing mTLS server as part of the listener construction. + const dp = buildDataPlane({ + config, + authorizer, + resolver: world.resolver, + mtls: world.mtls, + now, + caBundle: [Buffer.from(certs.caCertPem)], + onError: (e) => console.error('[data-plane]', e), + tlsServerFactory: makeAgentTlsServerFactory({ + serverCertPath: certs.agentServerCertPath, + serverKeyPath: certs.agentServerKeyPath, + bindHost: BIND_HOST, + bindPort: AGENT_PORT, + onListening: () => console.log(`[agent-mtls] listening wss://${BIND_HOST}:${AGENT_PORT}`), + onError: (e) => console.error('[agent-mtls]', e), + }), + }) + + const browserServer = startBrowserServer({ + certPath: certs.browserCertPath, + keyPath: certs.browserKeyPath, + bindHost: BIND_HOST, + bindPort: BROWSER_PORT, + node: dp.node, + landingHtml: landingHtml(subdomain), + onListening: () => console.log(`[browser-wss] listening https://${BIND_HOST}:${BROWSER_PORT}`), + onError: (e) => console.error('[browser-wss]', e), + }) + + let controlPlane: { close(): Promise } | null = null + try { + controlPlane = await bootControlPlane({ + publicRaw: world.publicRaw, + baseDomain: world.baseDomain, + caCertPath: certs.caCertPath, + bindHost: BIND_HOST, + bindPort: CONTROL_PLANE_PORT, + }) + console.log(`[control-plane] listening http://${BIND_HOST}:${CONTROL_PLANE_PORT} (admin authz fail-closed — sync-verify seam)`) + } catch (e) { + console.warn('[control-plane] boot skipped:', e instanceof Error ? e.message : e) + } + + // Mint a demo browser capability token bound to a fresh DPoP key (the raw token is a bearer + // secret — dev only; never log real tokens). + const cap = await world.issueCap({ now: now(), ttl: TOKEN_TTL_SEC }) + + console.log('\n=== relay-run Phase 0 READY ===') + console.log(`Origin : https://${browserCn}:${BROWSER_PORT}`) + console.log(`Add to /etc/hosts: 127.0.0.1 ${browserCn}`) + console.log(`Subdomain : ${subdomain} hostId: ${world.host.hostId}`) + console.log(`Browser cap : ${cap.raw}`) + console.log(`DPoP proof : ${cap.dpop.proofJws}`) + console.log('Note: no agent tunnel is dialed yet → browser upgrades resolve but 1013 (agent offline).') + console.log('Ctrl-C to stop.\n') + + const shutdown = async (): Promise => { + console.log('\nshutting down…') + browserServer.close() + dp.listener.close() + if (controlPlane) await controlPlane.close().catch(() => {}) + certs.cleanup() + process.exit(0) + } + process.on('SIGINT', () => void shutdown()) + process.on('SIGTERM', () => void shutdown()) +} + +main().catch((e) => { + console.error('fatal:', e) + process.exit(1) +}) diff --git a/relay-run/src/servers/agent-tls.ts b/relay-run/src/servers/agent-tls.ts new file mode 100644 index 0000000..e8ba6da --- /dev/null +++ b/relay-run/src/servers/agent-tls.ts @@ -0,0 +1,49 @@ +/** + * Real agent-facing mTLS server for `main.ts`. A `TlsServerFactory` that stands up an https server + * (`requestCert: true`, `rejectUnauthorized: true`, pinned dev CA) with a `ws` WebSocketServer over + * it, so an agent dials `wss://` under mTLS. The TLS layer rejects a no-cert / non-chaining peer + * BEFORE `attach()` runs (Finding-4 two-tier defense); `verifyPeer` (P5) then binds the cert to the + * registry. Every connection's DER cert is handed to the relay's `onPeer`. + */ +import { readFileSync } from 'node:fs' +import { createServer } from 'node:https' +import type { TLSSocket } from 'node:tls' +import type { IncomingMessage } from 'node:http' +import { WebSocketServer, type WebSocket as WsWebSocket } from 'ws' +import type { TlsServerFactory, TlsServerLike } from 'term-relay/data-plane/agent-listener.js' +import { wsToWebSocketLike } from '../wiring/socket-pipe.js' + +export interface AgentTlsOptions { + readonly serverCertPath: string + readonly serverKeyPath: string + readonly bindHost: string + readonly bindPort: number + readonly onListening?: () => void + readonly onError?: (e: unknown) => void +} + +export function makeAgentTlsServerFactory(opts: AgentTlsOptions): TlsServerFactory { + return (tlsOpts, onPeer): TlsServerLike => { + const server = createServer({ + cert: readFileSync(opts.serverCertPath), + key: readFileSync(opts.serverKeyPath), + ca: tlsOpts.ca.map((b) => Buffer.from(b)), + requestCert: tlsOpts.requestCert, + rejectUnauthorized: tlsOpts.rejectUnauthorized, + }) + const wss = new WebSocketServer({ server }) + wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => { + const peer = (req.socket as TLSSocket).getPeerCertificate(true) + const der = peer && peer.raw ? new Uint8Array(peer.raw) : new Uint8Array() + onPeer(wsToWebSocketLike(ws), der) + }) + server.on('error', (e) => opts.onError?.(e)) + server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.()) + return { + close: () => { + wss.close() + server.close() + }, + } + } +} diff --git a/relay-run/src/servers/browser-server.ts b/relay-run/src/servers/browser-server.ts new file mode 100644 index 0000000..18e4756 --- /dev/null +++ b/relay-run/src/servers/browser-server.ts @@ -0,0 +1,77 @@ +/** + * Browser-facing WSS server for `main.ts`. Terminates HTTPS on the tenant origin and, on WS + * upgrade, builds the P1 `UpgradeRequest` from the raw request headers and hands it to the REAL + * relay-node (→ real `authorizeUpgrade` → P5 `onUpgrade`: Origin/CSWSH + capability verify + DPoP). + * The handshake echoes ONLY `APP_SUBPROTOCOL` — never the token entry (INV15). + */ +import { readFileSync } from 'node:fs' +import { createServer, type Server } from 'node:https' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { WebSocketServer, type WebSocket as WsWebSocket } from 'ws' +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' + +export interface BrowserServerOptions { + readonly certPath: string + readonly keyPath: string + readonly bindHost: string + readonly bindPort: number + readonly node: RelayNode + readonly landingHtml: string + readonly onListening?: () => void + readonly onError?: (e: unknown) => void +} + +function parseCookies(header: string | undefined): Record { + const out: Record = {} + if (!header) return out + for (const part of header.split(';')) { + const idx = part.indexOf('=') + if (idx === -1) continue + out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim() + } + return out +} + +function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest { + const proto = req.headers['sec-websocket-protocol'] + const subprotocols = (typeof proto === 'string' ? proto.split(',') : []) + .map((v) => v.trim()) + .filter((v) => v.length > 0) + const dpopHeader = req.headers['dpop'] + return { + host: req.headers.host ?? '', + origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined, + url: req.url ?? '/', + subprotocols, + cookies: parseCookies(req.headers.cookie), + remoteAddr: req.socket.remoteAddress ?? '', + dpop: { proof: typeof dpopHeader === 'string' ? dpopHeader : null, publicKeyThumbprint: null }, + activeSessionCount: 0, + } +} + +export function startBrowserServer(opts: BrowserServerOptions): Server { + const server = createServer( + { cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) }, + (_req: IncomingMessage, res: ServerResponse) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(opts.landingHtml) + }, + ) + const wss = new WebSocketServer({ + server, + handleProtocols: (protocols: Set) => + protocols.has(APP_SUBPROTOCOL) ? APP_SUBPROTOCOL : false, + }) + wss.on('connection', (ws: WsWebSocket, req: IncomingMessage) => { + opts.node + .handleBrowserUpgrade(buildUpgradeRequest(req), wsToWebSocketLike(ws)) + .catch((e) => opts.onError?.(e)) + }) + server.on('error', (e) => opts.onError?.(e)) + server.listen(opts.bindPort, opts.bindHost, () => opts.onListening?.()) + return server +} diff --git a/relay-run/src/servers/control-plane-boot.ts b/relay-run/src/servers/control-plane-boot.ts new file mode 100644 index 0000000..6615812 --- /dev/null +++ b/relay-run/src/servers/control-plane-boot.ts @@ -0,0 +1,41 @@ +/** + * Best-effort control-plane (P3) boot for `main.ts`: in-memory stores + the dev in-process CA + * signer, listening for admin/enroll HTTP. Wrapped so a failure degrades gracefully (the data-plane + * is the security-critical surface and boots independently). + * + * KNOWN SEAM (reported, not hacked): P3's injected `CapabilityVerifier.verify` is SYNCHRONOUS, but + * relay-auth's real `verifyCapabilityToken` is ASYNC (WebCrypto). The real P5 verifier therefore + * cannot be injected without an additive change to control-plane, so the admin routes run on the + * default fail-closed verifier here (deny-by-default, never bypassed). Programmatic account/pairing + * seeding through those routes is consequently BLOCKED in Phase 0 — see the report. + */ +import { buildControlPlane } from 'control-plane' +import { loadEnv } from 'control-plane/src/env.js' +import { createMemoryStores } from 'control-plane/src/store/memory.js' + +export interface ControlPlaneBootOptions { + readonly publicRaw: Uint8Array + readonly baseDomain: string + readonly caCertPath: string + readonly bindHost: string + readonly bindPort: number +} + +export interface ControlPlaneHandle { + close(): Promise +} + +export async function bootControlPlane(opts: ControlPlaneBootOptions): Promise { + const env = loadEnv({ + PG_URL: 'postgres://localhost:5432/relay_run_dev', + REDIS_URL: 'redis://localhost:6379', + CAPABILITY_SIGN_PUBKEY_B64: Buffer.from(opts.publicRaw).toString('base64'), + CA_INTERMEDIATE_KMS_KEY_REF: 'dev-inprocess-signer', + CA_INTERMEDIATE_CERT_PATH: opts.caCertPath, + NODE_MTLS_TRUST_BUNDLE_PATH: opts.caCertPath, + BASE_DOMAIN: opts.baseDomain, + }) + const { app } = await buildControlPlane(env, { stores: createMemoryStores() }) + await app.listen({ port: opts.bindPort, host: opts.bindHost }) + return { close: () => app.close() } +} diff --git a/relay-run/src/wiring/authorizer.ts b/relay-run/src/wiring/authorizer.ts new file mode 100644 index 0000000..11fef63 --- /dev/null +++ b/relay-run/src/wiring/authorizer.ts @@ -0,0 +1,85 @@ +/** + * Authorizer adapter — implements the term-relay (P1) `Authorizer` port by DELEGATING every verdict + * to the REAL relay-auth (P5) `onUpgrade`/`onReattach`. P1 holds no authz logic; this file only + * reconciles two structurally-drifted-but-equivalent seam shapes: + * + * 1. DpopContext. P1's port carries `{ proof, publicKeyThumbprint }`; P5 needs `{ proofJws, htu, + * htm }`. The relay derives `htu`/`htm` from the request's OWN authority (`expectedAud` = the + * resolved subdomain), never from client-supplied claims — the spec-faithful DPoP binding. + * 2. AuthzOutcome. P5's `principal` is a full AuthenticatedPrincipal; P1's port only needs a + * string, so we surface `principal.accountId` (P1's adapter reads only `hostId`/`jti`). + * + * F2 (step-up principal wiring): Phase 0 uses a not-required step-up policy, so P5 resolves identity + * from the signed token (INV3) and a null session-principal is correct. `resolvePrincipal` is the + * seam a later phase injects to pass an authenticated principal for step-up-required hosts. + */ +import { + onUpgrade as p5OnUpgrade, + onReattach as p5OnReattach, + type EnforceDeps, + type AuthenticatedPrincipal, + type AuthzOutcome as P5AuthzOutcome, +} from 'relay-auth' +import type { + Authorizer, + UpgradeContext as PortUpgradeContext, + AuthzOutcome as PortAuthzOutcome, +} from 'term-relay/data-plane/authz-port.js' +import { DPOP_HTM, htuFor } from './capability.js' + +export interface AuthorizerDeps { + readonly deps: EnforceDeps + readonly allowedOrigins: readonly string[] + readonly now: () => number + /** F2 seam: resolve the authenticated session principal for step-up. Phase 0 default → null. */ + readonly resolvePrincipal?: (ctx: PortUpgradeContext) => AuthenticatedPrincipal | null +} + +function toP5Context( + ctx: PortUpgradeContext, + resolvePrincipal: (ctx: PortUpgradeContext) => AuthenticatedPrincipal | null, +) { + return { + capabilityRaw: ctx.capabilityRaw, + originHeader: ctx.originHeader, + expectedAud: ctx.expectedAud, + requestedHostId: ctx.requestedHostId, + requiredRight: ctx.requiredRight, + remoteAddrHash: ctx.remoteAddrHash, + activeSessionCount: ctx.activeSessionCount, + // Reconstruct the full DPoP context; htu/htm come from the relay's authority, not the client. + dpop: { proofJws: ctx.dpop.proof ?? '', htu: htuFor(ctx.expectedAud), htm: DPOP_HTM }, + principal: resolvePrincipal(ctx), + } +} + +function toPortOutcome(outcome: P5AuthzOutcome): PortAuthzOutcome { + return outcome.ok + ? { ok: true, hostId: outcome.hostId, principal: outcome.principal.accountId, jti: outcome.jti } + : { ok: false, status: outcome.status } +} + +/** Build the P1 `Authorizer` port backed by the real P5 enforcement pipeline. */ +export function createAuthorizer(deps: AuthorizerDeps): Authorizer { + const resolvePrincipal = deps.resolvePrincipal ?? (() => null) + return { + async onUpgrade(ctx, now) { + const outcome = await p5OnUpgrade( + toP5Context(ctx, resolvePrincipal), + deps.deps, + deps.allowedOrigins, + now, + ) + return toPortOutcome(outcome) + }, + async onReattach(ctx, now) { + const outcome = await p5OnReattach( + { ...toP5Context(ctx, resolvePrincipal), sessionId: ctx.sessionId }, + deps.deps, + deps.allowedOrigins, + now, + ) + return toPortOutcome(outcome) + }, + } +} diff --git a/relay-run/src/wiring/capability.ts b/relay-run/src/wiring/capability.ts new file mode 100644 index 0000000..32626d2 --- /dev/null +++ b/relay-run/src/wiring/capability.ts @@ -0,0 +1,111 @@ +/** + * P5 signing-key setup + capability-token / DPoP bundle builder for Phase 0. Wires the REAL + * relay-auth issue + DPoP primitives (deep-imported test/internal helpers — relay-auth has no + * `exports` map, so subpath imports resolve through the relay-run node_modules symlink). Lifted + * from `e2e/harness/dpop.ts`. + * + * The DPoP `htu`/`htm` are LOAD-BEARING: the browser signs them into the proof and the relay + * re-derives them from the request's own authority (never from client claims). `htuFor` is the + * SINGLE source of truth used by BOTH sides so issuance and verification agree. + */ +import { randomUUID } from 'node:crypto' +import { issueCapabilityToken, type DpopContext } from 'relay-auth' +import type { CapabilityRight } from 'relay-contracts' +import { + configureVerifyKey, + resetVerifyKeyForTest, +} from 'relay-auth/src/config/keys.js' +import { + generateEd25519KeyPair, + exportEd25519PublicRaw, +} from 'relay-auth/src/crypto/ed25519.js' +import { buildDpopProof, resetDpopCacheForTest } from 'relay-auth/src/capability/verify.js' +import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js' +import { principal } from './memory-stores.js' + +export { resetDpopCacheForTest } + +/** DPoP HTTP method the browser binds into every proof (WS upgrade is a GET). */ +export const DPOP_HTM = 'GET' as const + +/** + * Canonical DPoP `htu` for a subdomain/aud. The relay derives this from the resolved subdomain + * (its own authority) and the browser signs the identical value — the ONE place the string is + * defined, so the two never drift. + */ +export function htuFor(aud: string): string { + return `https://${aud}/ws` +} + +export interface P5Key { + readonly signingKey: CryptoKey + readonly publicRaw: Uint8Array +} + +/** Configure the P5 verifying key globally and return the matching private signing key. */ +export async function setupP5SigningKey(): Promise { + resetVerifyKeyForTest() + const pair = await generateEd25519KeyPair() + configureVerifyKey(pair.publicKey) + const publicRaw = await exportEd25519PublicRaw(pair.publicKey) + return { signingKey: pair.privateKey, publicRaw } +} + +interface Ephemeral { + readonly privateKey: CryptoKey + readonly publicRaw: Uint8Array +} + +async function makeEphemeral(): Promise { + const pair = await generateEd25519KeyPair() + return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) } +} + +export interface IssueOpts { + readonly accountId: string + readonly host: string + readonly aud: string + readonly rights?: readonly CapabilityRight[] + readonly ttl?: number + readonly now: number +} + +/** + * A capability token bundled with a matching DPoP proof. `newDpop(at?)` mints a FRESH DPoP proof + * (new jti) bound to the SAME ephemeral key — needed to re-present the same token under a distinct + * DPoP without tripping the DPoP replay cache. + */ +export interface CapBundle { + readonly raw: string + readonly dpop: DpopContext + readonly newDpop: (at?: number) => Promise +} + +export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise { + const eph = await makeEphemeral() + const cnfJkt = await jwkThumbprint(eph.publicRaw) + const raw = await issueCapabilityToken( + { + principal: principal(o.accountId), + aud: o.aud, + host: o.host, + rights: o.rights ?? ['attach'], + ttlSeconds: o.ttl ?? 45, + cnfJkt, + }, + signingKey, + o.now, + ) + const htu = htuFor(o.aud) + const newDpop = async (at: number = o.now): Promise => ({ + proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, { + htu, + htm: DPOP_HTM, + jti: randomUUID(), + iat: at, + }), + htu, + htm: DPOP_HTM, + }) + return { raw, dpop: await newDpop(o.now), newDpop } +} diff --git a/relay-run/src/wiring/data-plane.ts b/relay-run/src/wiring/data-plane.ts new file mode 100644 index 0000000..dc1b236 --- /dev/null +++ b/relay-run/src/wiring/data-plane.ts @@ -0,0 +1,119 @@ +/** + * Data-plane composition — assembles the REAL term-relay (P1) `createRelayNode` + + * `createAgentListener` and routes every browser upgrade through the REAL `authorizeUpgrade` + * adapter (→ our P5 `Authorizer`). The relay-node performs the OPAQUE byte splice (INV2): it never + * decodes DATA. This is the one composition both the integration test and `main.ts` reuse. + */ +import { createRelayNode, type RelayNode } from 'term-relay/data-plane/relay-node.js' +import { + createAgentListener, + type AgentListener, + type AgentTunnel, + type MtlsVerifier, + type RouteRegistrar, + type TlsServerFactory, +} from 'term-relay/data-plane/agent-listener.js' +import { + authorizeUpgrade, + type UpgradeRequest, + type UpgradeDecision, +} from 'term-relay/data-plane/upgrade.js' +import type { RouteResolver } from 'term-relay/data-plane/subdomain-router.js' +import type { DataPlaneConfig } from 'term-relay/data-plane/config.js' +import type { Authorizer } from 'term-relay/data-plane/authz-port.js' +import type { CapabilityRight } from 'relay-contracts' +import type { ScheduleHandle } from 'term-relay/mux/heartbeat.js' + +const RELAY_NODE_ID = 'relay-run-node-0' +const REMOTE_ADDR_SALT = 'relay-run-dev-salt' + +/** Build a frozen DataPlaneConfig without touching disk (cert paths are informational here — the + * live TLS servers are constructed by main.ts, not from these fields). */ +export function makeDataPlaneConfig(overrides: Partial = {}): DataPlaneConfig { + return Object.freeze({ + baseDomain: 'term.localhost', + bindHost: '127.0.0.1', + bindPort: 8443, + agentBindPort: 8444, + tlsCertPath: '(in-memory)', + tlsKeyPath: '(in-memory)', + agentCaCertPath: '(in-memory)', + agentCaChainPath: '(in-memory)', + relayNodeId: RELAY_NODE_ID, + maxFrameBytes: 1024 * 1024, + initialWindowBytes: 256 * 1024, + heartbeatIntervalMs: 15_000, + routeTtlMs: 45_000, + ...overrides, + }) +} + +/** A no-op route registrar (Phase 0 has no Redis routing table; routes live in the listener's Map). */ +export function noopRegistrar(): RouteRegistrar { + return { + register: async () => {}, + heartbeat: async () => {}, + deregister: async () => {}, + } +} + +export interface DataPlaneDeps { + readonly config: DataPlaneConfig + readonly authorizer: Authorizer + readonly resolver: RouteResolver + readonly mtls: MtlsVerifier + readonly now: () => number + readonly requiredRight?: CapabilityRight + readonly caBundle?: readonly Uint8Array[] + readonly registrar?: RouteRegistrar + readonly onTunnel?: (t: AgentTunnel) => void + /** Inject a manual scheduler to keep heartbeat timers out of unit tests. */ + readonly schedule?: (fn: () => void, ms: number) => ScheduleHandle + readonly onError?: (e: unknown) => void + /** Real agent-facing mTLS server factory (main.ts). Omitted in tests → no TLS server started. */ + readonly tlsServerFactory?: TlsServerFactory +} + +export interface DataPlane { + readonly node: RelayNode + readonly listener: AgentListener + authorize(req: UpgradeRequest): Promise +} + +export function buildDataPlane(deps: DataPlaneDeps): DataPlane { + const listener = createAgentListener({ + config: deps.config, + mtls: deps.mtls, + registrar: deps.registrar ?? noopRegistrar(), + onTunnel: deps.onTunnel ?? (() => {}), + caBundle: deps.caBundle ?? [], + ...(deps.tlsServerFactory ? { tlsServerFactory: deps.tlsServerFactory } : {}), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + ...(deps.onError ? { onError: deps.onError } : {}), + }) + + const authorize = (req: UpgradeRequest): Promise => + authorizeUpgrade(req, { + authorizer: deps.authorizer, + resolver: deps.resolver, + baseDomain: deps.config.baseDomain, + now: deps.now, + remoteAddrSalt: REMOTE_ADDR_SALT, + requiredRight: deps.requiredRight ?? 'attach', + }) + + const node = createRelayNode({ config: deps.config, listener, authorize }) + return { node, listener, authorize } +} + +/** In-memory RouteResolver: subdomain label → resolved host (P3's job in production). */ +export function memoryRouteResolver( + hosts: ReadonlyMap, +): RouteResolver { + return { + resolveSubdomain: async (subdomain) => { + const h = hosts.get(subdomain) + return h ? { hostId: h.hostId, accountId: h.accountId, subdomain } : null + }, + } +} diff --git a/relay-run/src/wiring/memory-stores.ts b/relay-run/src/wiring/memory-stores.ts new file mode 100644 index 0000000..bf81805 --- /dev/null +++ b/relay-run/src/wiring/memory-stores.ts @@ -0,0 +1,134 @@ +/** + * Phase 0 in-memory control-plane stores — the ONLY seams that stand in for the true I/O + * boundaries P1/P3 own (host registry / session registry / revocation store / token buckets / + * audit sink / revocation bus). Every security DECISION still runs through the REAL relay-auth + * exports; these fakes only supply storage. Lifted verbatim from `e2e/harness/fakes.ts` — the + * reference wiring named in docs/PLAN_RELAY_RUN_PHASE0.md. + */ +import type { HostRecord, KillSignal, RevocationBus } from 'relay-contracts' +import type { + AuthenticatedPrincipal, + AuditEvent, + AuditSink, + HostRegistryPort, + SessionRegistryPort, + RevocationStore, + TokenBucketStore, +} from 'relay-auth' + +/** An authenticated principal (the ONLY source of accountId, INV3). */ +export function principal( + accountId: string, + overrides: Partial = {}, +): AuthenticatedPrincipal { + return { + kind: 'human', + accountId, + principalId: 'cred-' + accountId, + amr: ['passkey'], + authAt: 1000, + stepUpAt: null, + ...overrides, + } +} + +/** A minimal online HostRecord for the authz registry (agentPubkey here is unused by authz). */ +export function makeHostRecord( + accountId: string, + hostId: string, + subdomain: string, + status: HostRecord['status'] = 'online', +): HostRecord { + return { + hostId, + accountId, + subdomain, + agentPubkey: new Uint8Array(32), + enrollFpr: 'fpr-' + hostId, + status, + lastSeen: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + revokedAt: null, + } +} + +export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort { + const map = new Map(hosts.map((h) => [h.hostId, h])) + return { getById: async (id) => map.get(id) ?? null } +} + +export interface MutableSessionRegistry extends SessionRegistryPort { + add(entry: { sessionId: string; hostId: string; accountId: string }): void +} + +export function fakeSessionRegistry( + seed: readonly { sessionId: string; hostId: string; accountId: string }[] = [], +): MutableSessionRegistry { + const map = new Map(seed.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }])) + return { + getById: async (id) => map.get(id) ?? null, + add: (e) => { + map.set(e.sessionId, { hostId: e.hostId, accountId: e.accountId }) + }, + } +} + +export interface RevocationFake extends RevocationStore { + readonly revoked: Set + readonly consumed: Set +} + +export function fakeRevocationStore(): RevocationFake { + const revoked = new Set() + const consumed = new Set() + return { + revoked, + consumed, + isRevoked: async (jti) => revoked.has(jti), + revokeJti: async (jti) => { + revoked.add(jti) + }, + consumeOnce: async (jti) => { + if (consumed.has(jti)) return false + consumed.add(jti) + return true + }, + } +} + +export interface TokenBucketFake extends TokenBucketStore { + readonly blocked: Set + readonly calls: string[] +} + +/** Token bucket that always allows unless a key is added to `blocked`. */ +export function fakeTokenBucket(): TokenBucketFake { + const blocked = new Set() + const calls: string[] = [] + return { + blocked, + calls, + take: async (key) => { + calls.push(key) + return !blocked.has(key) + }, + } +} + +export interface AuditFake extends AuditSink { + readonly events: AuditEvent[] +} + +export function fakeAuditSink(): AuditFake { + const events: AuditEvent[] = [] + return { events, append: async (e) => void events.push(e) } +} + +export interface RevocationBusFake extends RevocationBus { + readonly published: KillSignal[] +} + +export function fakeRevocationBus(): RevocationBusFake { + const published: KillSignal[] = [] + return { published, publish: async (s) => void published.push(s) } +} diff --git a/relay-run/src/wiring/relay-world.ts b/relay-run/src/wiring/relay-world.ts new file mode 100644 index 0000000..d85fdec --- /dev/null +++ b/relay-run/src/wiring/relay-world.ts @@ -0,0 +1,212 @@ +/** + * Phase 0 world — composes the REAL relay-auth (P5) enforcement + relay-e2e (P4) handshake through + * the in-memory stores, tailored for the live data-plane path: a host's capability-token `aud` is + * the SINGLE subdomain label (what `authorizeUpgrade` derives from the Host header), not the FQDN. + * Directly modelled on `e2e/harness/world.ts` (the reference wiring named in the build spec). + * + * Only the true I/O boundaries are faked; every security check (Origin/CSWSH, token verify, DPoP + * PoP, cross-tenant gate, single-use jti) is the production relay-auth path. + */ +import { randomUUID, randomBytes } from 'node:crypto' +import type { AeadAlg, E2ESession, HostHello } from 'relay-contracts' +import { + createClientHandshake, + createHostHandshake, + createE2ESession, + MemoryDevicePinStore, +} from 'relay-e2e' +import { + signDeviceAuthProof, + verifyDeviceProof, + type EnforceDeps, + type StepUpPolicy, +} from 'relay-auth' +import { + generateEd25519KeyPair, + exportEd25519PublicRaw, + importEd25519PublicRaw, + signEd25519, + verifyEd25519, +} from 'relay-auth/src/crypto/ed25519.js' +import type { MtlsVerifier } from 'term-relay/data-plane/agent-listener.js' +import type { Authorizer } from 'term-relay/data-plane/authz-port.js' +import type { RouteResolver } from 'term-relay/data-plane/subdomain-router.js' +import { + fakeAuditSink, + fakeHostRegistry, + fakeRevocationStore, + fakeSessionRegistry, + fakeTokenBucket, + makeHostRecord, + principal, + type AuditFake, + type MutableSessionRegistry, + type RevocationFake, + type TokenBucketFake, +} from './memory-stores.js' +import { issueCapBundle, setupP5SigningKey, resetDpopCacheForTest, type CapBundle } from './capability.js' +import { createAuthorizer } from './authorizer.js' +import { memoryRouteResolver } from './data-plane.js' + +export const DEFAULT_NOW = 1_700_000_000 +const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305' + +/** Phase 0 default: step-up never required (F2: null session-principal is correct). */ +export const NO_STEP_UP: StepUpPolicy = { + required: false, + maxAgeSeconds: Number.MAX_SAFE_INTEGER, + requiredMethod: 'passkey', +} + +/** A seeded host: authz identity (hostId/accountId/label) + §4.4 e2e identity + replay secret. */ +export interface HostFixture { + readonly label: string + readonly hostId: string + readonly accountId: string + readonly subdomain: string + readonly origin: string + readonly agentPubkey: Uint8Array + readonly hostContentSecret: Uint8Array + readonly replayAlg: AeadAlg + readonly signingPriv: CryptoKey +} + +async function makeHostFixture( + label: string, + accountId: string, + baseDomain: string, +): Promise { + const kp = await generateEd25519KeyPair() + const agentPubkey = await exportEd25519PublicRaw(kp.publicKey) + return { + label, + hostId: randomUUID(), + accountId, + subdomain: label, + origin: `https://${label}.${baseDomain}`, + agentPubkey, + hostContentSecret: new Uint8Array(randomBytes(32)), + replayAlg: REPLAY_ALG, + signingPriv: kp.privateKey, + } +} + +export interface EstablishedSessions { + readonly client: E2ESession + readonly host: E2ESession + readonly agentPubkey: Uint8Array +} + +export interface Phase0World { + readonly now: number + readonly signingKey: CryptoKey + /** Raw 32-byte Ed25519 P5 verify pubkey (for CAPABILITY_SIGN_PUBKEY_B64). */ + readonly publicRaw: Uint8Array + readonly baseDomain: string + readonly host: HostFixture + readonly allowedOrigins: readonly string[] + readonly deps: EnforceDeps + readonly audit: AuditFake + readonly revocation: RevocationFake + readonly buckets: TokenBucketFake + readonly sessions: MutableSessionRegistry + readonly resolver: RouteResolver + readonly authorizer: Authorizer + readonly mtls: MtlsVerifier + issueCap(o?: { ttl?: number; now?: number }): Promise + establishSession(opts?: { + now?: number + verifyAgainstPubkey?: Uint8Array + tamperHostHello?: (h: HostHello) => HostHello + }): Promise +} + +export async function buildPhase0World(now: number = DEFAULT_NOW): Promise { + const baseDomain = 'term.localhost' + const { signingKey, publicRaw } = await setupP5SigningKey() + resetDpopCacheForTest() + + const host = await makeHostFixture('alice', 'acct-alice', baseDomain) + const hosts = fakeHostRegistry([makeHostRecord(host.accountId, host.hostId, host.subdomain)]) + const sessions = fakeSessionRegistry([]) + const revocation = fakeRevocationStore() + const buckets = fakeTokenBucket() + const audit = fakeAuditSink() + const allowedOrigins = [host.origin] + + const deps: EnforceDeps = { + hosts, + sessions, + revocation, + buckets, + audit, + stepUpPolicyFor: () => NO_STEP_UP, + } + + const routeMap = new Map([[host.subdomain, { hostId: host.hostId, accountId: host.accountId }]]) + const resolver = memoryRouteResolver(routeMap) + const authorizer = createAuthorizer({ deps, allowedOrigins, now: () => now }) + const mtls: MtlsVerifier = { + verifyPeer: () => ({ hostId: host.hostId, accountId: host.accountId }), + } + + return { + now, + signingKey, + publicRaw, + baseDomain, + host, + allowedOrigins, + deps, + audit, + revocation, + buckets, + sessions, + resolver, + authorizer, + mtls, + issueCap(o = {}) { + return issueCapBundle(signingKey, { + accountId: host.accountId, + host: host.hostId, + aud: host.subdomain, + ttl: o.ttl, + now: o.now ?? now, + }) + }, + async establishSession(opts = {}): Promise { + const hsNow = opts.now ?? now + const client = createClientHandshake({ + aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'], + deviceAuthProofProvider: { + proofFor: (_hostId, binding) => + signDeviceAuthProof(principal(host.accountId), binding, signingKey, hsNow), + }, + verifier: { + verify: async (pub, transcript, sig) => + verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript), + }, + pinStore: new MemoryDevicePinStore(), + hostId: host.hostId, + }) + const hostHs = createHostHandshake({ + signer: { sign: (transcript) => signEd25519(host.signingPriv, transcript) }, + agentPubkey: host.agentPubkey, + supported: ['xchacha20-poly1305', 'aes-256-gcm'], + verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow), + }) + const clientHello = await client.start() + const rawHostHello = await hostHs.onClientHello(clientHello) + const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello + const clientResult = await client.onHostHello( + hostHello, + opts.verifyAgainstPubkey ?? host.agentPubkey, + ) + return { + client: createE2ESession('client', clientResult), + host: createE2ESession('host', hostHs.result!), + agentPubkey: host.agentPubkey, + } + }, + } +} diff --git a/relay-run/src/wiring/socket-pipe.ts b/relay-run/src/wiring/socket-pipe.ts new file mode 100644 index 0000000..e920c90 --- /dev/null +++ b/relay-run/src/wiring/socket-pipe.ts @@ -0,0 +1,104 @@ +/** + * `WebSocketLike` adapters. Two of them: + * - `makeSocketPair()` — an in-process duplex pair used to drive the REAL relay-node/mux in tests + * (no sockets, deterministic). Delivery is synchronous but non-reentrant (a per-endpoint + * trampoline) so a send emitted while draining the peer's inbox can't recurse unboundedly. + * - `wsToWebSocketLike()` — wraps a real `ws` WebSocket to the same interface for `main.ts`. + * Bytes are COPIED on send (the mux slices/retains buffers) so no caller can alias another's memory. + */ +import type { WebSocket as WsWebSocket } from 'ws' +import type { WebSocketLike } from 'term-relay/data-plane/ws-like.js' + +interface Endpoint extends WebSocketLike { + /** Internal: deliver bytes that the peer sent to THIS endpoint. */ + _deliver(data: Uint8Array): void + _closed(): void +} + +function makeEndpoint(): Endpoint { + let onMessage: ((data: Uint8Array) => void) | null = null + let onClose: (() => void) | null = null + let peer: Endpoint | null = null + const inbox: Uint8Array[] = [] + let draining = false + let closed = false + + const ep: Endpoint & { _link(p: Endpoint): void } = { + _link(p: Endpoint) { + peer = p + }, + send(data: Uint8Array) { + if (closed || peer === null) return + peer._deliver(new Uint8Array(data)) // copy: never alias the sender's buffer + }, + close(_code?: number) { + if (closed) return + closed = true + onClose?.() + peer?._closed() + }, + onMessage(handler) { + onMessage = handler + }, + onClose(handler) { + onClose = handler + }, + _deliver(data: Uint8Array) { + inbox.push(data) + if (draining) return + draining = true + try { + while (inbox.length > 0) { + const next = inbox.shift()! + onMessage?.(next) + } + } finally { + draining = false + } + }, + _closed() { + if (closed) return + closed = true + onClose?.() + }, + } + return ep as Endpoint & { _link(p: Endpoint): void } +} + +/** An in-process duplex `WebSocketLike` pair: `a.send` → `b`'s message handler and vice versa. */ +export function makeSocketPair(): { a: WebSocketLike; b: WebSocketLike } { + const a = makeEndpoint() as Endpoint & { _link(p: Endpoint): void } + const b = makeEndpoint() as Endpoint & { _link(p: Endpoint): void } + a._link(b) + b._link(a) + return { a, b } +} + +function toUint8(data: unknown): Uint8Array | null { + if (data instanceof Uint8Array) return data + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (Array.isArray(data)) return Buffer.concat(data.map((d) => (d instanceof Uint8Array ? d : Buffer.from(d)))) + if (Buffer.isBuffer(data)) return new Uint8Array(data) + return null +} + +/** Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only). */ +export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike { + return { + send(data: Uint8Array) { + ws.send(data, { binary: true }) + }, + close(code?: number) { + ws.close(code) + }, + onMessage(handler) { + ws.on('message', (data: unknown) => { + const bytes = toUint8(data) + if (bytes !== null) handler(bytes) + }) + }, + onClose(handler) { + ws.on('close', () => handler()) + }, + } +} diff --git a/relay-run/tests/integration.test.ts b/relay-run/tests/integration.test.ts new file mode 100644 index 0000000..5ef634a Binary files /dev/null and b/relay-run/tests/integration.test.ts differ diff --git a/relay-run/tests/smoke.test.ts b/relay-run/tests/smoke.test.ts new file mode 100644 index 0000000..281b98d --- /dev/null +++ b/relay-run/tests/smoke.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +// Cross-package resolution smoke test: prove relay-run can bare-import the REAL exports of the +// relay packages it wires — including the term-relay data-plane DEEP imports (term-relay has no +// barrel index / exports map, so subpath `.js` specifiers resolve to the `.ts` sources). +import { onUpgrade, onReattach, issueCapabilityToken, configureVerifyKey } from 'relay-auth' +import { createClientHandshake, createHostHandshake, createE2ESession } from 'relay-e2e' +import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts' +import { createRelayNode } from 'term-relay/data-plane/relay-node.js' +import { createAgentListener } from 'term-relay/data-plane/agent-listener.js' +import { authorizeUpgrade } from 'term-relay/data-plane/upgrade.js' +import { createMuxSession } from 'term-relay/mux/mux-session.js' + +describe('relay-run cross-package import smoke', () => { + it('resolves relay-auth / relay-e2e / relay-contracts + term-relay data-plane deep imports', () => { + expect(typeof onUpgrade).toBe('function') + expect(typeof onReattach).toBe('function') + expect(typeof issueCapabilityToken).toBe('function') + expect(typeof configureVerifyKey).toBe('function') + expect(typeof createClientHandshake).toBe('function') + expect(typeof createHostHandshake).toBe('function') + expect(typeof createE2ESession).toBe('function') + expect(APP_SUBPROTOCOL).toBe('term.relay.v1') + expect(typeof encodeTokenSubprotocol).toBe('function') + expect(typeof createRelayNode).toBe('function') + expect(typeof createAgentListener).toBe('function') + expect(typeof authorizeUpgrade).toBe('function') + expect(typeof createMuxSession).toBe('function') + }) +}) diff --git a/relay-run/tsconfig.json b/relay-run/tsconfig.json new file mode 100644 index 0000000..56f3dfb --- /dev/null +++ b/relay-run/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/relay-run/vitest.config.ts b/relay-run/vitest.config.ts new file mode 100644 index 0000000..b31aa2a --- /dev/null +++ b/relay-run/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + }, +})