feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)

Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

117
relay-web/src/api-client.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* T2 — Zod-validated control-plane HTTP client.
*
* INV3 (owned): the request builders have NO parameter for account/tenant; every call carries only
* the same-origin cookie/passkey session (`credentials: 'include'`). The account is derived
* server-side. Every response is parsed through a Zod schema before return; a parse failure throws
* a typed {@link ApiError} rather than returning a torn object.
*/
import { z } from 'zod'
import type { RelayWebConfig } from './config'
import { ApiError } from './errors'
import {
CapabilityTokenResponseSchema,
HostListWireSchema,
HostRecordWireSchema,
HostStatusResponseSchema,
PairingCodeResponseSchema,
type CapabilityRight,
type HostRecord,
type HostStatus,
} from './api-schemas'
/** v0.8 surface — ships against the flat SQLite table + cookie session. No token issuance. */
export interface ApiClientV08 {
listHosts(): Promise<readonly HostRecord[]>
getHost(hostId: string): Promise<HostRecord>
requestPairingCode(): Promise<{ readonly code: string; readonly expiresAt: string }>
hostStatus(hostId: string): Promise<HostStatus>
}
/** v0.9+ surface — GROWS once P5 capability-token issuance exists (INDEX §1). */
export interface ApiClient extends ApiClientV08 {
issueCapabilityToken(hostId: string, rights: readonly CapabilityRight[]): Promise<string>
}
const JSON_HEADERS: Readonly<Record<string, string>> = { Accept: 'application/json' }
/** Map a non-OK HTTP status to a typed ApiError kind (no silent swallow). */
function errorForStatus(status: number, path: string): ApiError {
if (status === 401) return new ApiError('unauthenticated', `unauthenticated at ${path}`, status)
if (status === 403) return new ApiError('forbidden', `forbidden at ${path}`, status)
return new ApiError('http', `request to ${path} failed with status ${status}`, status)
}
export function createApiClient(cfg: RelayWebConfig, fetchImpl: typeof fetch = fetch): ApiClient {
const base = cfg.apiBase
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
let res: Response
try {
res = await fetchImpl(`${base}${path}`, {
credentials: 'include', // cookie/passkey session — the ONLY identity (INV3)
headers: JSON_HEADERS,
...init,
})
} catch (cause) {
throw new ApiError('network', `network error calling ${path}`, null)
}
if (!res.ok) throw errorForStatus(res.status, path)
try {
return await res.json()
} catch {
throw new ApiError('parse', `invalid JSON body from ${path}`, res.status)
}
}
/** Parse `body` with `schema`, converting a ZodError into a typed ApiError('parse'). */
function validate<S extends z.ZodTypeAny>(schema: S, body: unknown, path: string): z.infer<S> {
const parsed = schema.safeParse(body)
if (!parsed.success) {
throw new ApiError('parse', `malformed response from ${path}: ${parsed.error.message}`)
}
return parsed.data
}
function postJson(path: string, payload: unknown): Promise<unknown> {
return requestJson(path, {
method: 'POST',
headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
return {
async listHosts(): Promise<readonly HostRecord[]> {
const path = '/api/hosts'
return validate(HostListWireSchema, await requestJson(path), path)
},
async getHost(hostId: string): Promise<HostRecord> {
const path = `/api/hosts/${encodeURIComponent(hostId)}`
return validate(HostRecordWireSchema, await requestJson(path), path)
},
async requestPairingCode(): Promise<{ readonly code: string; readonly expiresAt: string }> {
const path = '/api/pairing-codes'
// Body is empty: the account is derived from the session, NEVER sent (INV3).
return validate(PairingCodeResponseSchema, await postJson(path, {}), path)
},
async hostStatus(hostId: string): Promise<HostStatus> {
const path = `/api/hosts/${encodeURIComponent(hostId)}/status`
const parsed = validate(HostStatusResponseSchema, await requestJson(path), path)
return parsed.status
},
async issueCapabilityToken(
hostId: string,
rights: readonly CapabilityRight[],
): Promise<string> {
const path = `/api/hosts/${encodeURIComponent(hostId)}/capability-token`
// Only the rights subset is sent; host+account scope is bound server-side from the session.
const parsed = validate(CapabilityTokenResponseSchema, await postJson(path, { rights }), path)
return parsed.token
},
}
}