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

View File

@@ -0,0 +1,116 @@
/**
* T6 (v0.9) — add-machine onboarding (pairing) from the browser's side (§4.5 ISSUE).
*
* `start()` requests a single-use, short-TTL pairing code (P3), displays the copy-paste command
* `npx web-terminal-agent pair <code>` and a countdown to `expiresAt`, then polls `listHosts` until
* a new host flips `●online` → fires `onPaired` ONCE. KPI: first-shell-in-under-2-minutes
* (EXPLORE §6). After `expiresAt` it shows "expired, generate a new code" and STOPS polling — the
* client never re-displays a redeemed/expired code, and never persists it (INV5: transient state).
*/
import type { ApiClient } from './api-client'
import { ApiError } from './errors'
const DEFAULT_POLL_MS = 3000
const AGENT_PAIR_CMD = 'npx web-terminal-agent pair'
export interface AddMachine {
start(): Promise<void>
dispose(): void
}
export interface AddMachineOpts {
readonly pollMs?: number
readonly onPaired?: (hostId: string) => void
/** Injectable clock for deterministic expiry tests; defaults to `Date.now`. */
readonly now?: () => number
}
export function mountAddMachine(root: HTMLElement, api: ApiClient, opts: AddMachineOpts = {}): AddMachine {
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS
const now = opts.now ?? (() => Date.now())
const cmd = document.createElement('code')
cmd.className = 'pair-command'
const status = document.createElement('p')
status.className = 'pair-status'
status.setAttribute('role', 'status')
const error = document.createElement('p')
error.className = 'pair-error'
error.setAttribute('role', 'alert')
root.append(cmd, status, error)
let timer: ReturnType<typeof setInterval> | null = null
let disposed = false
let paired = false
let knownHostIds: ReadonlySet<string> = new Set()
function stopPolling(): void {
if (timer) {
clearInterval(timer)
timer = null
}
}
async function poll(expiresAtMs: number): Promise<void> {
if (disposed || paired) return
if (now() >= expiresAtMs) {
stopPolling()
status.textContent = 'Code expired — generate a new code.'
cmd.textContent = '' // never re-display an expired/redeemed code
return
}
try {
const hosts = await api.listHosts()
if (disposed || paired) return
const fresh = hosts.find((h) => h.status === 'online' && !knownHostIds.has(h.hostId))
if (fresh) {
paired = true
stopPolling()
status.textContent = `Paired: ${fresh.subdomain} is online.`
opts.onPaired?.(fresh.hostId)
}
} catch (err) {
// A transient poll error is non-fatal; the loop retries until expiry (no infinite hang).
error.textContent =
err instanceof ApiError ? `Status check failed (${err.kind}).` : 'Status check failed.'
}
}
async function start(): Promise<void> {
error.textContent = ''
// Snapshot existing hosts so we detect the NEW one that pairs in.
try {
knownHostIds = new Set((await api.listHosts()).map((h) => h.hostId))
} catch {
knownHostIds = new Set()
}
let code: string
let expiresAt: string
try {
const issued = await api.requestPairingCode()
code = issued.code
expiresAt = issued.expiresAt
} catch (err) {
error.textContent =
err instanceof ApiError ? `Could not issue a code (${err.kind}).` : 'Could not issue a code.'
return
}
cmd.textContent = `${AGENT_PAIR_CMD} ${code}` // textContent-only, no injection
status.textContent = 'Waiting for the host to pair…'
const expiresAtMs = Date.parse(expiresAt)
stopPolling()
timer = setInterval(() => void poll(expiresAtMs), pollMs)
void poll(expiresAtMs)
}
return {
start,
dispose(): void {
disposed = true
stopPolling()
},
}
}

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
},
}
}

View File

@@ -0,0 +1,72 @@
/**
* T2 — the frozen CLIENT-contract surface. Re-exports the `relay-contracts` §4.2/§4.3 shapes
* VERBATIM (never redefines them) and adds ONLY response envelopes needed to parse HTTP JSON.
*
* INV3: there is NO `account_id`/`tenant_id` REQUEST field anywhere here — identity is always the
* cookie/passkey session the browser already holds; the server derives the account.
*
* Wire note: over JSON, `HostRecord.agentPubkey` arrives base64url-encoded (a Uint8Array cannot
* ride JSON). The wire schema decodes it back to `Uint8Array` so callers receive a genuine
* `HostRecord` — validate-at-boundary (coding-style.md "Input Validation").
*/
import { z } from 'zod'
import {
HostStatusSchema,
decodeBase64UrlBytes,
type HostRecord,
type HostStatus,
type CapabilityRight,
} from 'relay-contracts'
// Re-export the frozen types verbatim so P6 modules import them from one place (the src/types.ts
// discipline: a new shared field is changed in relay-contracts, never redeclared locally).
export type { HostRecord, HostStatus, CapabilityRight }
export { HostStatusSchema }
/** Wire form of a HostRecord: `agentPubkey` is base64url text, decoded to bytes on parse. */
export const HostRecordWireSchema = z
.object({
hostId: z.string().uuid(),
accountId: z.string().uuid(),
subdomain: z.string().min(1),
agentPubkey: z.string().min(1),
enrollFpr: z.string().min(1),
status: HostStatusSchema,
lastSeen: z.string().min(1),
createdAt: z.string().min(1),
revokedAt: z.string().min(1).nullable(),
})
.strict()
.transform(
(w): HostRecord => ({
hostId: w.hostId,
accountId: w.accountId,
subdomain: w.subdomain,
// Copy into a fresh ArrayBuffer-backed Uint8Array so the type matches HostRecord exactly.
agentPubkey: new Uint8Array(decodeBase64UrlBytes(w.agentPubkey)),
enrollFpr: w.enrollFpr,
status: w.status,
lastSeen: w.lastSeen,
createdAt: w.createdAt,
revokedAt: w.revokedAt,
}),
)
/** `GET /api/hosts` → array of HostRecord. */
export const HostListWireSchema = z.array(HostRecordWireSchema).readonly()
/** `GET /api/hosts/:id/status` → the host's current status. */
export const HostStatusResponseSchema = z.object({ status: HostStatusSchema }).strict()
/** `POST /api/pairing-codes` → single-use short-TTL code + its expiry (§4.5 ISSUE). */
export const PairingCodeResponseSchema = z
.object({
code: z.string().min(1),
expiresAt: z.string().min(1),
})
.strict()
.readonly()
export type PairingCodeResponse = z.infer<typeof PairingCodeResponseSchema>
/** `POST /api/hosts/:id/capability-token` → opaque raw §4.3 token (v0.9+; never decoded to trust). */
export const CapabilityTokenResponseSchema = z.object({ token: z.string().min(1) }).strict()

53
relay-web/src/config.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* T1 — runtime config derived from `location` (no host/IP knob exists in the client, so the
* browser can never be pointed at another tenant's origin — supports INV1 upstream).
*
* The tenant origin is `<subdomain>.term.<domain>`. `wsUrl` is ALWAYS same-origin and
* scheme-following (`wss:` on HTTPS, else `ws:`) — the M6 guarantee that avoids mixed-content
* blocking on TLS/Tailscale deploys.
*/
import { ConfigError } from './errors'
/** The label that marks the tenant zone: `<subdomain>.term.<domain>`. */
const TENANT_ZONE_LABEL = 'term'
export interface RelayWebConfig {
/** left-most hostname label, e.g. 'alice' in alice.term.example.com */
readonly subdomain: string
/** scheme-following, SAME-ORIGIN ws URL builder (M6); a path cannot inject a foreign host */
readonly wsUrl: (path: string) => string
/** same-origin API base ('' — every control-plane call is relative) */
readonly apiBase: string
}
/** Parse the tenant subdomain from a hostname, or throw (fail-fast, never default to a tenant). */
function parseSubdomain(hostname: string): string {
const labels = hostname.split('.')
const first = labels[0]
// Require the `<sub>.term.<...>` shape: a non-empty left label with `term` as the 2nd label.
if (labels.length < 3 || labels[1] !== TENANT_ZONE_LABEL || first === undefined || first === '') {
throw new ConfigError(
`hostname '${hostname}' has no tenant subdomain (expected '<subdomain>.${TENANT_ZONE_LABEL}.<domain>')`,
)
}
return first
}
/**
* Derive the client runtime config from `location`. Throws {@link ConfigError} on a host that is
* not a valid tenant subdomain — it never silently falls back to another tenant's subdomain.
*/
export function readConfig(loc: Pick<Location, 'protocol' | 'host' | 'hostname'>): RelayWebConfig {
const subdomain = parseSubdomain(loc.hostname)
const wsScheme = loc.protocol === 'https:' ? 'wss' : 'ws'
const host = loc.host
const wsUrl = (path: string): string => {
// Always anchor to our own host; normalize to a leading '/' so a caller-supplied
// `//evil.com` becomes a PATH on our host, never a protocol-relative foreign origin.
const normalized = path.startsWith('/') ? path : `/${path}`
return `${wsScheme}://${host}${normalized}`
}
return Object.freeze({ subdomain, wsUrl, apiBase: '' })
}

105
relay-web/src/dashboard.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* T5 (v0.9) — dashboard host list with `●online/offline/draining/revoked` status.
*
* Renders `api.listHosts()` as cards (subdomain, status dot, last_seen, an "open" action routing to
* the terminal view for that host_id) and polls for live `●online` flips. The UI reflects
* server-authorized hosts only — there is NO input path to name a foreign host_id (INV1/INV3
* defense-in-depth; the real gate is server-side). A `revoked` host is non-interactive (INV12 UI).
*
* All dynamic text is set via `textContent` (never `innerHTML`) — XSS discipline.
*/
import type { ApiClient } from './api-client'
import type { HostRecord, HostStatus } from './api-schemas'
import { ApiError } from './errors'
const DEFAULT_POLL_MS = 5000
export interface Dashboard {
refresh(): Promise<void>
dispose(): void
}
export interface DashboardOpts {
readonly pollMs?: number
/** Routes to the terminal view for a host; injected so the module owns no navigation policy. */
readonly onOpen?: (hostId: string) => void
}
/** Statuses that must NOT expose an "open" action in the UI (INV12 defense-in-depth). */
const NON_INTERACTIVE: ReadonlySet<HostStatus> = new Set<HostStatus>(['revoked'])
function buildCard(host: HostRecord, opts: DashboardOpts): HTMLElement {
const card = document.createElement('div')
card.className = 'host-card'
card.dataset['hostId'] = host.hostId
card.dataset['status'] = host.status
const dot = document.createElement('span')
dot.className = `status-dot status-${host.status}`
dot.setAttribute('aria-label', host.status)
dot.textContent = '●'
const name = document.createElement('span')
name.className = 'host-subdomain'
name.textContent = host.subdomain
const seen = document.createElement('span')
seen.className = 'host-last-seen'
seen.textContent = host.lastSeen
card.append(dot, name, seen)
if (NON_INTERACTIVE.has(host.status)) {
const blocked = document.createElement('span')
blocked.className = 'host-blocked'
blocked.textContent = 'revoked'
card.append(blocked)
} else {
const open = document.createElement('button')
open.className = 'host-open'
open.type = 'button'
open.textContent = 'open'
open.addEventListener('click', () => opts.onOpen?.(host.hostId))
card.append(open)
}
return card
}
export function mountDashboard(root: HTMLElement, api: ApiClient, opts: DashboardOpts = {}): Dashboard {
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS
const list = document.createElement('div')
list.className = 'host-list'
const banner = document.createElement('p')
banner.className = 'dashboard-error'
banner.setAttribute('role', 'alert')
root.append(banner, list)
let timer: ReturnType<typeof setInterval> | null = null
let disposed = false
async function refresh(): Promise<void> {
try {
const hosts = await api.listHosts()
if (disposed) return
banner.textContent = ''
list.replaceChildren(...hosts.map((h) => buildCard(h, opts)))
} catch (err) {
if (disposed) return
// Zod drift or a rejection surfaces as a banner; polling continues (never renders undefined).
banner.textContent =
err instanceof ApiError ? `Could not load hosts (${err.kind}).` : 'Could not load hosts.'
}
}
void refresh()
timer = setInterval(() => void refresh(), pollMs)
return {
refresh,
dispose(): void {
disposed = true
if (timer) clearInterval(timer)
},
}
}

207
relay-web/src/e2e-socket.ts Normal file
View File

@@ -0,0 +1,207 @@
/**
* T8 (v0.10) — browser-side E2E transport. Implements the SAME `TerminalTransport` (T4) so
* `terminal-view.ts` is unchanged (the drop-in swap). Consumes the FROZEN §4.4 surface
* (`E2ESession`/`DirectionalKeys`/`sealFrame`/`openFrame`) — cited via injected `relay-e2e` impls,
* NEVER re-implemented here, and NOT a single sessionKey via raw SubtleCrypto AES-GCM.
*
* Trust root (closes the first-connect TOFU gap): `expectedFpr` is the caller's
* `api.getHost(hostId).enrollFpr` — the P5-authenticated HTTPS control-plane record, out-of-band of
* the relay. On ANY disagreement between the relay-forwarded host fingerprint and `expectedFpr` —
* INCLUDING THE VERY FIRST CONNECTION — `onPinMismatch(...,'api')` fires (default `'abort'`), the
* handshake never completes, and NO session key material is derived. `HostPinStore` is only a
* cache/audit trail of that authenticated value.
*
* Any `open`/`openFrame` throw (AEAD-tag / seq / direction-confusion failure, INV13) TEARS THE
* SOCKET DOWN — a bad frame is never rendered or swallowed.
*
* INTEGRATION POINT: the concrete §4.4 handshake message framing over the relay (client_hello /
* host_hello wire) and the crypto impls are injected via {@link E2ETransportDeps.runHandshake}
* (wired to P4 `relay-e2e` + P2's agent-side counterpart in production). This module owns the WS
* lifecycle, capability-token attachment (§4.3), the fingerprint-pin gate, and the seal/open
* teardown — all independently testable with a loopback relay-e2e session pair.
*/
import { APP_SUBPROTOCOL, encodeTokenSubprotocol, type AeadAlg } from 'relay-contracts'
import type { AuthorizedDeviceContext, E2ESession } from 'relay-contracts'
import type { RelayWebConfig } from './config'
import type { HostPinStore } from './host-pin-store'
import type { TerminalTransport, WebSocketCtor, WebSocketLike } from './ws-transport'
export type PinMismatchSource = 'api' | 'cache'
export type FprDecision = 'trust' | 'abort'
/** Raw byte channel the handshake driver uses to exchange client_hello / host_hello opaquely. */
export interface E2EHandshakeIo {
sendRaw(bytes: Uint8Array): void
onRaw(cb: (bytes: Uint8Array) => void): void
}
export interface RunHandshakeArgs {
readonly hostId: string
readonly aeadOffer: readonly AeadAlg[]
readonly context: AuthorizedDeviceContext
/** API-sourced host signing pubkey (P3 registry, out-of-band of the relay). */
readonly agentPubkey: Uint8Array
/**
* Gate called with the host's OFFERED fingerprint BEFORE any key material is derived. Returning
* `'abort'` MUST make `runHandshake` reject without deriving keys (no MITM key material ever).
*/
verifyOfferedFpr(offeredFpr: string): Promise<FprDecision>
}
export interface E2ETransportDeps {
/**
* Drives the §4.4 handshake through `io` and returns an established `E2ESession`, or rejects if
* `verifyOfferedFpr` returned `'abort'`. Wired to relay-e2e (`buildClientHandshake` →
* `ClientHandshake.start`/`.onHostHello` → `createE2ESession('client', result)`) in production.
*/
runHandshake(io: E2EHandshakeIo, args: RunHandshakeArgs): Promise<E2ESession>
readonly context: AuthorizedDeviceContext
readonly agentPubkey: Uint8Array
/** AEAD offer; lists `aes-256-gcm` FIRST (fast native-adjacent path); host makes the final choice. */
readonly aeadOffer?: readonly AeadAlg[]
readonly wsCtor?: WebSocketCtor
}
const DEFAULT_AEAD_OFFER: readonly AeadAlg[] = ['aes-256-gcm', 'xchacha20-poly1305']
function toBytes(data: unknown): Uint8Array {
if (data instanceof Uint8Array) return data
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (ArrayBuffer.isView(data)) {
const v = data as ArrayBufferView
return new Uint8Array(v.buffer, v.byteOffset, v.byteLength)
}
if (typeof data === 'string') return new TextEncoder().encode(data)
return new Uint8Array(0)
}
export function createE2ETransport(
cfg: RelayWebConfig,
capabilityToken: string,
hostId: string,
expectedFpr: string,
pins: HostPinStore,
onPinMismatch: (
expected: string,
offered: string,
source: PinMismatchSource,
) => Promise<FprDecision>,
deps: E2ETransportDeps,
): TerminalTransport {
const Ctor: WebSocketCtor = deps.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
const url = cfg.wsUrl('/term') // SAME-ORIGIN; token rides the subprotocol, NEVER the URL (§4.3)
const protocols: readonly string[] = [APP_SUBPROTOCOL, encodeTokenSubprotocol(capabilityToken)]
let ws: WebSocketLike | null = null
let session: E2ESession | null = null
let rawCb: ((bytes: Uint8Array) => void) | null = null // handshake-phase sink
let userMessageCb: ((bytes: Uint8Array) => void) | null = null
let closeCb: ((reason: string) => void) | null = null
let torn = false
function tearDown(reason: string): void {
if (torn) return
torn = true
if (ws) ws.close()
if (closeCb) closeCb(reason)
}
/** The fingerprint-pin gate — the anti-MITM trust decision (API value is authoritative). */
async function verifyOfferedFpr(offeredFpr: string): Promise<FprDecision> {
if (offeredFpr !== expectedFpr) {
// Relay-forwarded fingerprint disagrees with the authenticated API value — first connect too.
if ((await onPinMismatch(expectedFpr, offeredFpr, 'api')) === 'abort') return 'abort'
}
const cached = pins.get(hostId)
if (cached !== null && cached !== expectedFpr) {
// Rotation: cache disagrees with a fresh API value. Advisory — API remains authoritative.
if ((await onPinMismatch(expectedFpr, cached, 'cache')) === 'abort') return 'abort'
}
return 'trust'
}
function openWs(): Promise<void> {
return new Promise((resolve, reject) => {
const socket = new Ctor(url, protocols)
socket.binaryType = 'arraybuffer'
ws = socket
socket.onopen = () => {
// Echo rule (§4.3): accept ONLY the app subprotocol back (bearer-leak guard).
if (socket.protocol !== APP_SUBPROTOCOL) {
socket.close(4400, 'bad-subprotocol')
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
return
}
resolve()
}
socket.onmessage = (ev) => {
const bytes = toBytes(ev.data)
if (session) {
// DATA phase: decrypt; ANY openFrame throw tears the socket down (INV13).
let plain: Uint8Array
try {
plain = session.open(bytes)
} catch {
tearDown('e2e-verify-failed')
return
}
if (userMessageCb) userMessageCb(plain)
} else if (rawCb) {
rawCb(bytes) // handshake phase
}
}
socket.onclose = (ev) => {
const reason = ev.code === 4403 ? 'forbidden' : ev.reason && ev.reason.length > 0 ? ev.reason : 'closed'
if (!torn) {
torn = true
if (closeCb) closeCb(reason)
}
}
socket.onerror = () => reject(new Error('websocket error'))
})
}
async function open(): Promise<void> {
await openWs()
const io: E2EHandshakeIo = {
sendRaw: (bytes) => ws?.send(bytes),
onRaw: (cb) => {
rawCb = cb
},
}
let established: E2ESession
try {
established = await deps.runHandshake(io, {
hostId,
aeadOffer: deps.aeadOffer ?? DEFAULT_AEAD_OFFER,
context: deps.context,
agentPubkey: deps.agentPubkey,
verifyOfferedFpr,
})
} catch (err) {
// Abort (fingerprint mismatch) or any handshake failure → no session, socket closed.
tearDown('e2e-handshake-failed')
throw err
}
session = established
// Cache the API-VERIFIED fingerprint (audit/drift trail) — NOT a handshake-derived value.
pins.record(hostId, expectedFpr)
}
return {
open,
send(bytes: Uint8Array): void {
if (!ws || !session) throw new Error('e2e transport not open')
ws.send(session.seal(bytes)) // plaintext never leaves un-AEAD'd (INV2)
},
onMessage(cb: (bytes: Uint8Array) => void): void {
userMessageCb = cb
},
onClose(cb: (reason: string) => void): void {
closeCb = cb
},
close(): void {
tearDown('closed')
},
}
}

View File

@@ -0,0 +1,24 @@
/**
* dashboard.html entry (v0.9) — host ●status list. No inline script (strict CSP).
*/
import { readConfig } from '../config'
import { createApiClient } from '../api-client'
import { mountDashboard } from '../dashboard'
function boot(): void {
const cfg = readConfig(window.location)
const root = document.getElementById('dashboard')
if (!root) return
const api = createApiClient(cfg)
mountDashboard(root, api, {
onOpen: (hostId) => {
window.location.assign(`/term.html?host=${encodeURIComponent(hostId)}`)
},
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot)
} else {
boot()
}

View File

@@ -0,0 +1,30 @@
/**
* index.html entry — v0.8 password gate → terminal view over the passthrough transport.
* No inline script (strict-CSP friendly): this bundle is loaded via <script type="module">.
*/
import { readConfig } from '../config'
import { mountPasswordLogin } from '../login-password'
import { createPassthroughTransport } from '../ws-transport'
import { mountTerminalView } from '../terminal-view'
function boot(): void {
const cfg = readConfig(window.location)
const loginRoot = document.getElementById('login')
const termRoot = document.getElementById('terminal')
if (!loginRoot || !termRoot) return
mountPasswordLogin(loginRoot, cfg, {
onSuccess: () => {
loginRoot.hidden = true
termRoot.hidden = false
const transport = createPassthroughTransport(cfg)
void transport.open().then(() => mountTerminalView(termRoot, transport))
},
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot)
} else {
boot()
}

View File

@@ -0,0 +1,38 @@
/**
* manage.html entry (v0.10) — CLIENT-SIDE preview grid. Server previews die under E2E; the
* key-holding browser decrypts a ciphertext replay and renders read-only xterms. No inline script.
*
* The §4.4 replay crypto is imported from relay-e2e (P4) and injected into the grid — cited
* verbatim, never re-implemented.
*
* INTEGRATION POINT: `loadReplay` must fetch the host's ciphertext ring-buffer replay + the §4.5
* `hostContentSecret` (delivered via P5 after auth/step-up). Those endpoints are owned by P5/P1/P3;
* this glue wires the shape and throws until they land, so a card shows "unavailable" rather than a
* fabricated screen.
*/
import { deriveContentKey, openReplayCiphertext } from 'relay-e2e'
import { readConfig } from '../config'
import { createApiClient } from '../api-client'
import { mountPreviewGrid } from '../preview-grid'
import type { ReplaySource } from '../preview-client'
async function loadReplay(
_hostId: string,
): Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }> {
// TODO(P5/P1): fetch ciphertext replay + hostContentSecret (post auth/step-up). Not yet available.
throw new Error('replay source not yet wired (pending P5/P1 endpoints)')
}
function boot(): void {
const cfg = readConfig(window.location)
const root = document.getElementById('manage')
if (!root) return
const api = createApiClient(cfg)
mountPreviewGrid(root, api, loadReplay, { deriveContentKey, openReplayCiphertext })
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot)
} else {
boot()
}

View File

@@ -0,0 +1,23 @@
/**
* pair.html entry (v0.9) — add-machine onboarding. No inline script (strict CSP).
*/
import { readConfig } from '../config'
import { createApiClient } from '../api-client'
import { mountAddMachine } from '../add-machine'
function boot(): void {
const cfg = readConfig(window.location)
const root = document.getElementById('pair')
if (!root) return
const api = createApiClient(cfg)
const add = mountAddMachine(root, api, {
onPaired: () => window.location.assign('/dashboard.html'),
})
void add.start()
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot)
} else {
boot()
}

41
relay-web/src/errors.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* relay-web local error taxonomy. Typed, explicit errors (coding-style.md "Error Handling")
* so callers never swallow failures or render partial objects.
*/
/** Base class for every relay-web boundary error. */
export class RelayWebError extends Error {
constructor(message: string) {
super(message)
this.name = 'RelayWebError'
}
}
/** Thrown by readConfig when the origin is not a valid tenant subdomain (fail-fast, T1). */
export class ConfigError extends RelayWebError {
constructor(message: string) {
super(message)
this.name = 'ConfigError'
}
}
/** Discriminated kinds for control-plane API failures (T2). */
export type ApiErrorKind =
| 'unauthenticated' // 401 — session missing/expired; surface re-login
| 'forbidden' // 403 — INV1/INV6 upstream denial (foreign host_id etc.)
| 'http' // other non-2xx
| 'parse' // response failed Zod validation (never return a torn object)
| 'network' // fetch itself rejected
/** Control-plane HTTP client error. `kind` drives the UI response (re-login, error banner, …). */
export class ApiError extends RelayWebError {
readonly kind: ApiErrorKind
readonly status: number | null
constructor(kind: ApiErrorKind, message: string, status: number | null = null) {
super(message)
this.name = 'ApiError'
this.kind = kind
this.status = status
}
}

View File

@@ -0,0 +1,57 @@
/**
* T8 (v0.10) — `HostPinStore`: a CACHE / AUDIT TRAIL of the API-sourced host fingerprint per
* `host_id`, NOT an independent trust root.
*
* It never establishes trust from a relay-forwarded handshake — `record` only ever stores a value
* the caller already verified against the authenticated `HostRecord.enrollFpr` (§4.2, fetched
* out-of-band of the relay over the P5-authenticated HTTPS API). Drift between a freshly-sourced
* API fingerprint and the cached one is surfaced for review, but the API value is authoritative.
*
* Only the PUBLIC fingerprint (`enroll_fpr`) is persisted — never a key, secret, or plaintext
* (INV5/INV9): a fingerprint is safe at rest and is exactly what enables first-connect MITM defense.
*/
const KEY_PREFIX = 'relay-web:hostpin:'
export interface HostPinStore {
/** last cached API-sourced enroll_fpr for this host, or null if none cached yet */
get(hostId: string): string | null
/** cache an API-VERIFIED fingerprint (audit/drift detection) — caller must have verified it */
record(hostId: string, apiSourcedFpr: string): void
}
/**
* In-memory fallback for non-DOM/test contexts (keeps the store usable everywhere). The store only
* ever calls `getItem`/`setItem`, so only those are implemented (cast to the Storage interface).
*/
function memoryStorage(): Storage {
const map = new Map<string, string>()
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => {
map.set(k, v)
},
} as unknown as Storage
}
function defaultStorage(): Storage {
try {
if (typeof localStorage !== 'undefined') return localStorage
} catch {
// localStorage can throw in sandboxed contexts; fall back to memory.
}
return memoryStorage()
}
export function createHostPinStore(storage: Storage = defaultStorage()): HostPinStore {
const keyFor = (hostId: string): string => `${KEY_PREFIX}${hostId}`
return {
get(hostId: string): string | null {
return storage.getItem(keyFor(hostId))
},
record(hostId: string, apiSourcedFpr: string): void {
// Store only the API-verified value; this method never derives trust from a handshake.
storage.setItem(keyFor(hostId), apiSourcedFpr)
},
}
}

49
relay-web/src/index.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* relay-web — public barrel (P6). Browser bundle served from the tenant subdomain. Imports
* relay-contracts read-only; never touches base `public/` or `src/`. See docs/PLAN_RELAY_FRONTEND.md.
*/
// Errors + runtime config (T1)
export * from './errors'
export { readConfig, type RelayWebConfig } from './config'
// Control-plane API client + schemas (T2)
export { createApiClient, type ApiClient, type ApiClientV08 } from './api-client'
export * from './api-schemas'
// v0.8 password gate (T3)
export { mountPasswordLogin, type PasswordLogin } from './login-password'
// Terminal view + transport seam (T4)
export {
createPassthroughTransport,
type TerminalTransport,
type PassthroughOpts,
type WebSocketLike,
type WebSocketCtor,
} from './ws-transport'
export { mountTerminalView, type TerminalLike, type TerminalViewDeps } from './terminal-view'
export * from './protocol'
// v0.9 dashboard + onboarding (T5/T6)
export { mountDashboard, type Dashboard } from './dashboard'
export { mountAddMachine, type AddMachine } from './add-machine'
// v0.10 passkey + step-up (T7)
export { createWebAuthnClient, type WebAuthnClient } from './webauthn'
export { mountPasskeyLogin, type PasskeyLogin } from './login-passkey'
// v0.10 browser-side E2E (T8)
export {
createHostPinStore,
type HostPinStore,
} from './host-pin-store'
export {
createE2ETransport,
type E2ETransportDeps,
type PinMismatchSource,
} from './e2e-socket'
// v0.10 client-side preview (T9)
export { mountPreviewClient, type PreviewClient, type ReplaySource } from './preview-client'
export { mountPreviewGrid } from './preview-grid'

View File

@@ -0,0 +1,88 @@
/**
* T7 (v0.10) — Passkey/WebAuthn login + step-up-BEFORE-a-session (EXPLORE §4e / §0-5).
*
* `login()`: fetch a challenge from P5 → `wa.authenticate` → post the assertion (P5 sets the
* session). `stepUp(hostId)` runs a FRESH WebAuthn assertion immediately before a session is opened
* — a stolen cookie alone cannot open a session (INV15 step-up). Passkey is primary and
* phishing-resistant; there is NO SMS/OTP path (INDEX/EXPLORE §0-5: never SMS).
*
* `rpId` is server-authoritative: it arrives inside the challenge from P5 (resolved to the stable
* ACCOUNT-LEVEL domain per §8 Open Question #5) and is passed straight through — the client never
* hard-wires it to `location.hostname`, so one passkey spans all of an account's host subdomains.
*
* INTEGRATION POINT (BLOCKED item, §8 Q#5): the challenge/verify endpoints + the resolved `rpId`
* value are owned by P5/P3. This module consumes them through the injected {@link PasskeyAuthApi}
* seam rather than growing the frozen T2 `ApiClient` surface or guessing the resolution.
*/
import type { ApiClient } from './api-client'
import { WebAuthnError, type AuthenticationResult, type WebAuthnClient } from './webauthn'
/** P5-backed challenge/verify seam (NOT part of the frozen v0.8/v0.9 ApiClient — see header note). */
export interface PasskeyAuthApi {
getLoginChallenge(): Promise<PublicKeyCredentialRequestOptions>
verifyLogin(assertion: AuthenticationResult): Promise<'ok' | 'rejected'>
getStepUpChallenge(hostId: string): Promise<PublicKeyCredentialRequestOptions>
verifyStepUp(hostId: string, assertion: AuthenticationResult): Promise<'ok' | 'rejected'>
}
export interface PasskeyLogin {
login(): Promise<'ok' | 'rejected'>
/** BEFORE opening a session, not just at login — a fresh ceremony every call. */
stepUp(hostId: string): Promise<'ok' | 'rejected'>
}
export function mountPasskeyLogin(
root: HTMLElement,
_api: ApiClient,
wa: WebAuthnClient,
authApi: PasskeyAuthApi,
): PasskeyLogin {
const button = document.createElement('button')
button.type = 'button'
button.className = 'passkey-login'
button.textContent = 'Sign in with a passkey'
const error = document.createElement('p')
error.className = 'passkey-error'
error.setAttribute('role', 'alert')
root.append(button, error)
/** Run one assertion ceremony; a user-cancel (NotAllowedError) becomes a clean 'rejected'. */
async function assert(
getChallenge: () => Promise<PublicKeyCredentialRequestOptions>,
verify: (a: AuthenticationResult) => Promise<'ok' | 'rejected'>,
): Promise<'ok' | 'rejected'> {
let challenge: PublicKeyCredentialRequestOptions
try {
challenge = await getChallenge()
} catch {
error.textContent = 'Could not start the passkey ceremony.'
return 'rejected'
}
let assertion: AuthenticationResult
try {
assertion = await wa.authenticate(challenge) // rpId comes from the server challenge, not location
} catch (err) {
if (err instanceof WebAuthnError && err.kind === 'cancelled') return 'rejected'
error.textContent = 'Passkey authentication failed.'
return 'rejected'
}
return verify(assertion)
}
const login = (): Promise<'ok' | 'rejected'> =>
assert(
() => authApi.getLoginChallenge(),
(a) => authApi.verifyLogin(a),
)
const stepUp = (hostId: string): Promise<'ok' | 'rejected'> =>
assert(
() => authApi.getStepUpChallenge(hostId),
(a) => authApi.verifyStepUp(hostId, a),
)
button.addEventListener('click', () => void login())
// Surface EXACTLY the two capabilities — no phone/SMS/OTP field exists (structural, INV: never SMS).
return { login, stepUp }
}

View File

@@ -0,0 +1,87 @@
/**
* T3 — v0.8 shared-`clientToken` password gate → signed cookie.
*
* The auth the base app never had (EXPLORE §5). Posts `clientToken` once over TLS to the relay
* edge (P5/relay), which sets an `HttpOnly; Secure; SameSite` signed cookie; on `'ok'` the app
* routes to the terminal view. Replaced by Passkey (T7) in v0.10 behind the same route.
*
* Security: the token is NEVER stored in `localStorage` and NEVER logged; all error text is set via
* `textContent` (never `innerHTML`); empty input disables submit (fail-fast at the boundary).
*/
import type { RelayWebConfig } from './config'
export interface PasswordLogin {
submit(clientToken: string): Promise<'ok' | 'rejected'>
}
export interface PasswordLoginDeps {
readonly fetchImpl?: typeof fetch
/** Invoked once on a successful login so the shell wrapper can route to the terminal view. */
readonly onSuccess?: () => void
}
const LOGIN_PATH = '/api/login'
export function mountPasswordLogin(
root: HTMLElement,
cfg: RelayWebConfig,
deps: PasswordLoginDeps = {},
): PasswordLogin {
const fetchImpl = deps.fetchImpl ?? fetch
const form = document.createElement('form')
form.className = 'login-form'
const input = document.createElement('input')
input.type = 'password'
input.autocomplete = 'current-password'
input.placeholder = 'Access token'
input.className = 'login-input'
const button = document.createElement('button')
button.type = 'submit'
button.textContent = 'Connect'
button.disabled = true // empty input → disabled (fail-fast)
const error = document.createElement('p')
error.className = 'login-error'
error.setAttribute('role', 'alert')
input.addEventListener('input', () => {
button.disabled = input.value.length === 0
error.textContent = '' // clear stale error on edit
})
async function submit(clientToken: string): Promise<'ok' | 'rejected'> {
if (clientToken.length === 0) return 'rejected'
let res: Response
try {
res = await fetchImpl(`${cfg.apiBase}${LOGIN_PATH}`, {
method: 'POST',
credentials: 'include', // relay sets the signed cookie on the response
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ clientToken }), // shared password, NOT an account_id (INV3)
})
} catch {
error.textContent = 'Network error — please retry.'
return 'rejected'
}
if (!res.ok) {
error.textContent = 'Invalid access token.'
return 'rejected'
}
error.textContent = ''
if (deps.onSuccess) deps.onSuccess()
return 'ok'
}
form.addEventListener('submit', (ev) => {
ev.preventDefault()
void submit(input.value)
})
form.append(input, button, error)
root.append(form)
return { submit }
}

View File

@@ -0,0 +1,140 @@
/**
* T9 (v0.10) — client-side preview rendering. Under E2E the relay CANNOT render a screen it cannot
* read (server previews die); the authorized, key-holding browser decrypts a CIPHERTEXT replay and
* renders a READ-ONLY xterm.
*
* Ring-buffer replay survives a reload because the agent (P2) sealed each stored frame with
* `sealReplayFrame` under the RECOVERABLE content key `K_content` — NOT the ephemeral live
* `DirectionalKeys` (which are re-derived per handshake and lost on reload). The browser re-derives
* the SAME `K_content` via `deriveContentKey({ hostContentSecret, sessionId, alg })` (§4.4 FIX 3;
* `hostContentSecret` obtained via P5 after auth/step-up) and decrypts each payload with
* `openReplayCiphertext`. A wrong/ephemeral key throws (AEAD tag) → the card shows "unavailable",
* never a torn/garbled screen (cross-host/session isolation, INV1).
*
* The §4.4 crypto (`deriveContentKey`/`openReplayCiphertext`) is imported from `relay-e2e` and
* injected — cited verbatim, never re-implemented. `hostContentSecret`/`K_content` are transient in
* memory: NEVER persisted or logged (INV5/INV9). The preview has NO input wiring (read-only).
*/
import type { AeadAlg, AeadKey, ReplayKeyParams } from 'relay-contracts'
/** Ciphertext ring-buffer replay for one host/session. */
export interface ReplaySource {
readonly sessionId: string
readonly alg: AeadAlg // negotiated aead (matches how P2 sealed the replay)
readonly frames: readonly Uint8Array[] // stored DATA payloads, each a sealReplayFrame envelope
}
/** Read-only terminal surface — NO `onData`/input path exists (structural read-only guarantee). */
export interface ReadonlyTerminalLike {
open(container: HTMLElement): void
write(data: string): void
dispose(): void
}
/** Injected §4.4 replay crypto (from relay-e2e) + a read-only terminal factory. */
export interface PreviewDeps {
deriveContentKey(p: ReplayKeyParams): AeadKey
openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array
createTerminal?: (dims: { cols: number; rows: number }) => ReadonlyTerminalLike
}
export interface PreviewClient {
render(): Promise<void>
dispose(): void
}
const decoder = new TextDecoder()
async function defaultReadonlyTerminal(dims: {
cols: number
rows: number
}): Promise<ReadonlyTerminalLike> {
const { Terminal } = await import('@xterm/xterm')
// disableStdin: the preview is read-only — it can never become a covert input channel.
const term = new Terminal({ cols: dims.cols, rows: dims.rows, disableStdin: true })
return {
open: (el) => term.open(el),
write: (data) => term.write(data),
dispose: () => term.dispose(),
}
}
export function mountPreviewClient(
card: HTMLElement,
replay: ReplaySource,
hostContentSecret: Uint8Array,
dims: { cols: number; rows: number },
deps: PreviewDeps,
): PreviewClient {
let term: ReadonlyTerminalLike | null = null
let disposed = false
function showUnavailable(): void {
const msg = document.createElement('p')
msg.className = 'preview-unavailable'
msg.textContent = 'unavailable'
card.replaceChildren(msg)
}
async function render(): Promise<void> {
// Derive K_content ONCE, fresh from the host-scoped secret (no in-memory ephemeral key) —
// this is why replay survives a reload where the forward-secret live keys cannot.
let kContent: AeadKey
try {
kContent = deps.deriveContentKey({
hostContentSecret,
sessionId: replay.sessionId,
alg: replay.alg,
})
} catch {
showUnavailable()
return
}
// Decrypt every stored ciphertext BEFORE mounting a terminal; a wrong/ephemeral key (or a
// host-mismatched secret) makes openReplayCiphertext throw → "unavailable", never a torn screen.
const chunks: string[] = []
for (const frame of replay.frames) {
try {
chunks.push(decoder.decode(deps.openReplayCiphertext(kContent, frame)))
} catch {
showUnavailable()
return
}
}
if (disposed) return
const factory =
deps.createTerminal ?? ((d) => makeSyncPending(defaultReadonlyTerminal(d)))
term = factory(dims)
term.open(card)
for (const chunk of chunks) term.write(chunk)
}
return {
render,
dispose(): void {
disposed = true
if (term) term.dispose()
},
}
}
/**
* Bridge the async default terminal loader into the synchronous factory shape. The real path
* (production) awaits xterm before writing; tests always inject a synchronous mock so this branch
* is not exercised there.
*/
function makeSyncPending(pending: Promise<ReadonlyTerminalLike>): ReadonlyTerminalLike {
let resolved: ReadonlyTerminalLike | null = null
const queue: string[] = []
void pending.then((t) => {
resolved = t
for (const c of queue) t.write(c)
})
return {
open: (el) => void pending.then((t) => t.open(el)),
write: (data) => (resolved ? resolved.write(data) : void queue.push(data)),
dispose: () => void pending.then((t) => t.dispose()),
}
}

View File

@@ -0,0 +1,87 @@
/**
* T9 (v0.10) — grid of `preview-client` cards (the manage-page moved CLIENT-SIDE under E2E).
*
* Renders one read-only, locally-decrypted preview per authorized host. A host the account can't
* reach → `loadReplay` rejects → the card shows "unavailable", never another tenant's screen
* (INV1). Disposes all preview clients on unmount so no xterms/sockets leak (EXPLORE §6 preview-cost
* discipline); `hostContentSecret` is never written to `localStorage`/`console`.
*/
import type { ApiClient } from './api-client'
import type { HostRecord } from './api-schemas'
import {
mountPreviewClient,
type PreviewClient,
type PreviewDeps,
type ReplaySource,
} from './preview-client'
const DEFAULT_DIMS = { cols: 80, rows: 24 } as const
export interface PreviewGridOpts {
readonly dims?: { cols: number; rows: number }
}
export function mountPreviewGrid(
root: HTMLElement,
api: ApiClient,
loadReplay: (
hostId: string,
) => Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }>,
deps: PreviewDeps,
opts: PreviewGridOpts = {},
): { dispose(): void } {
const dims = opts.dims ?? DEFAULT_DIMS
const clients: PreviewClient[] = []
let disposed = false
function unavailableCard(host: HostRecord): HTMLElement {
const card = document.createElement('div')
card.className = 'preview-card'
card.dataset['hostId'] = host.hostId
const msg = document.createElement('p')
msg.className = 'preview-unavailable'
msg.textContent = 'unavailable'
card.append(msg)
return card
}
async function build(): Promise<void> {
let hosts: readonly HostRecord[]
try {
hosts = await api.listHosts()
} catch {
const err = document.createElement('p')
err.className = 'preview-grid-error'
err.textContent = 'Could not load previews.'
root.append(err)
return
}
if (disposed) return
for (const host of hosts) {
const card = document.createElement('div')
card.className = 'preview-card'
card.dataset['hostId'] = host.hostId
root.append(card)
try {
const { replay, hostContentSecret } = await loadReplay(host.hostId)
if (disposed) return
const client = mountPreviewClient(card, replay, hostContentSecret, dims, deps)
clients.push(client)
await client.render()
} catch {
// A host the account can't reach (403) or a decrypt failure → "unavailable" (INV1).
card.replaceWith(unavailableCard(host))
}
}
}
void build()
return {
dispose(): void {
disposed = true
for (const c of clients) c.dispose() // no leaked xterms/sockets
},
}
}

60
relay-web/src/protocol.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Base-app WebSocket protocol shapes, as the browser bundle SPEAKS them end-to-end.
*
* The agent (P2) forwards each logical stream to the UNCHANGED web-terminal at `127.0.0.1:3000`,
* which speaks this exact JSON protocol (TECH_DOC §4 / src/protocol.ts). relay-web is a legitimate
* consumer of that contract, so it declares the minimal shapes locally — it does NOT import the
* base `src/` (that stays byte-for-byte untouched; verification greps `src` clean).
*
* These frames are the PLAINTEXT that the E2E layer (T8) later seals; in v0.8 they ride the
* passthrough transport unencrypted.
*/
/** client → server (agent → base app). `attach` MUST be the first message. */
export type ClientMessage =
| { readonly type: 'attach'; readonly sessionId: string | null; readonly cwd?: string }
| { readonly type: 'input'; readonly data: string }
| { readonly type: 'resize'; readonly cols: number; readonly rows: number }
/** server → client. */
export type ServerMessage =
| { readonly type: 'attached'; readonly sessionId: string }
| { readonly type: 'output'; readonly data: string }
| { readonly type: 'exit'; readonly code: number; readonly reason?: string }
const encoder = new TextEncoder()
const decoder = new TextDecoder()
/** Encode a client message as UTF-8 JSON bytes for `TerminalTransport.send`. */
export function encodeClientMessage(msg: ClientMessage): Uint8Array {
return encoder.encode(JSON.stringify(msg))
}
/**
* Decode UTF-8 JSON bytes into a ServerMessage. NEVER throws (mirrors base invariant #3): an
* unparseable / unknown frame yields `null` so the caller drops it rather than crashing the view.
*/
export function decodeServerMessage(bytes: Uint8Array): ServerMessage | null {
let parsed: unknown
try {
parsed = JSON.parse(decoder.decode(bytes))
} catch {
return null
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
const obj = parsed as Record<string, unknown>
const type = obj['type']
if (type === 'attached' && typeof obj['sessionId'] === 'string') {
return { type: 'attached', sessionId: obj['sessionId'] }
}
if (type === 'output' && typeof obj['data'] === 'string') {
return { type: 'output', data: obj['data'] }
}
if (type === 'exit' && typeof obj['code'] === 'number') {
const reason = obj['reason']
return typeof reason === 'string'
? { type: 'exit', code: obj['code'], reason }
: { type: 'exit', code: obj['code'] }
}
return null
}

View File

@@ -0,0 +1,124 @@
/**
* T4 — mount xterm + FitAddon and drive it through an abstract `TerminalTransport`.
*
* Reproduces the base byte semantics against the SEAM (not `public/`): Enter → `\r` (0x0D, handled
* by xterm's own onData), `resize` as its OWN protocol frame (triggering `TIOCSWINSZ`/SIGWINCH so
* full-screen TUIs redraw), and `fit()` only after the container has real dimensions (calling it
* while `display:none` yields NaN — base Gotcha).
*
* The terminal is injected (DI seam) so tests run in jsdom without a real canvas-backed xterm; the
* default factory wires `@xterm/xterm` + `@xterm/addon-fit`.
*/
import { encodeClientMessage, decodeServerMessage } from './protocol'
import type { TerminalTransport } from './ws-transport'
/** Minimal xterm surface this view needs (keeps tests mock-able, avoids canvas in jsdom). */
export interface TerminalLike {
readonly cols: number
readonly rows: number
open(container: HTMLElement): void
write(data: string): void
onData(cb: (data: string) => void): void
fit(): void
dispose(): void
}
export interface TerminalViewDeps {
/** Factory for the terminal; default lazily loads xterm + FitAddon. */
readonly createTerminal?: () => TerminalLike
}
/** True only when the element has real, non-zero layout dimensions (guards NaN fit — Gotcha). */
function hasRealDimensions(el: HTMLElement): boolean {
return el.clientWidth > 0 && el.clientHeight > 0
}
async function defaultTerminal(): Promise<TerminalLike> {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
])
const term = new Terminal({ convertEol: false })
const fit = new FitAddon()
term.loadAddon(fit)
return {
get cols() {
return term.cols
},
get rows() {
return term.rows
},
open: (container) => term.open(container),
write: (data) => term.write(data),
onData: (cb) => {
term.onData(cb)
},
fit: () => fit.fit(),
dispose: () => term.dispose(),
}
}
/**
* Mount the terminal view. Returns `{ dispose }`. When `deps.createTerminal` is omitted the real
* xterm is loaded asynchronously; tests always inject a mock for deterministic, synchronous setup.
*/
export function mountTerminalView(
root: HTMLElement,
transport: TerminalTransport,
deps: TerminalViewDeps = {},
): { dispose(): void } {
let term: TerminalLike | null = null
let disposed = false
const send = (bytes: Uint8Array): void => {
try {
transport.send(bytes)
} catch {
// Socket not open / torn down — drop; onClose surfaces the reason to the UI.
}
}
const sendResize = (): void => {
if (!term || !hasRealDimensions(root)) return
term.fit()
send(encodeClientMessage({ type: 'resize', cols: term.cols, rows: term.rows }))
}
const wire = (t: TerminalLike): void => {
if (disposed) {
t.dispose()
return
}
term = t
t.open(root)
// keypress → xterm onData → transport.send (Enter → '\r' comes from xterm itself).
t.onData((data) => send(encodeClientMessage({ type: 'input', data })))
// incoming DATA → decode base ServerMessage → xterm.write (output only; attached/exit handled).
transport.onMessage((bytes) => {
const msg = decodeServerMessage(bytes)
if (msg && msg.type === 'output') t.write(msg.data)
})
// attach MUST be the first client message (base invariant); sessionId=null → fresh session.
send(encodeClientMessage({ type: 'attach', sessionId: null }))
if (hasRealDimensions(root)) sendResize()
}
const created = deps.createTerminal
if (created) {
wire(created())
} else {
void defaultTerminal().then(wire)
}
return {
dispose(): void {
disposed = true
if (term) term.dispose()
transport.close()
},
}
}

124
relay-web/src/webauthn.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* T7 (v0.10) — thin `navigator.credentials` wrapper: base64url ↔ ArrayBuffer plumbing + error
* mapping, kept small and pure-ish. No credential material is ever logged (INV9).
*
* The server (P5) is authoritative for `rpId` — it lives in the challenge options this module
* passes straight through to the authenticator; the client NEVER derives `rpId` from
* `location.hostname` (see §8 Open Question #5). This module only encodes/decodes and invokes the
* ceremony; the account is established server-side from the resulting assertion (INV3).
*/
import { decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
/** WebAuthn ceremony error, kind-tagged so callers can map cancellation → 'rejected' cleanly. */
export class WebAuthnError extends Error {
readonly kind: 'cancelled' | 'unsupported' | 'malformed' | 'failed'
constructor(kind: WebAuthnError['kind'], message: string) {
super(message)
this.name = 'WebAuthnError'
this.kind = kind
}
}
export interface RegistrationResult {
readonly id: string
readonly rawId: string // base64url
readonly type: string
readonly response: {
readonly clientDataJSON: string // base64url
readonly attestationObject: string // base64url
}
}
export interface AuthenticationResult {
readonly id: string
readonly rawId: string // base64url
readonly type: string
readonly response: {
readonly clientDataJSON: string // base64url
readonly authenticatorData: string // base64url
readonly signature: string // base64url
readonly userHandle: string | null // base64url or null
}
}
export interface WebAuthnClient {
register(challenge: PublicKeyCredentialCreationOptions): Promise<RegistrationResult>
authenticate(challenge: PublicKeyCredentialRequestOptions): Promise<AuthenticationResult>
}
/** ArrayBuffer → base64url (lossless). */
export function bufferToBase64Url(buf: ArrayBuffer): string {
return encodeBase64UrlBytes(new Uint8Array(buf))
}
/** base64url → ArrayBuffer (lossless inverse of {@link bufferToBase64Url}). */
export function base64UrlToBuffer(value: string): ArrayBuffer {
const bytes = decodeBase64UrlBytes(value)
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
/** Map a thrown ceremony error to a typed WebAuthnError (user cancel → 'cancelled'). */
function mapCeremonyError(err: unknown): WebAuthnError {
if (err instanceof WebAuthnError) return err
const name = err instanceof Error ? err.name : ''
if (name === 'NotAllowedError' || name === 'AbortError') {
return new WebAuthnError('cancelled', 'the passkey prompt was dismissed')
}
return new WebAuthnError('failed', err instanceof Error ? err.message : 'webauthn ceremony failed')
}
export function createWebAuthnClient(creds?: CredentialsContainer): WebAuthnClient {
const container = creds ?? (typeof navigator !== 'undefined' ? navigator.credentials : undefined)
if (!container) {
// Fail fast at construction so the UI can offer a fallback (never a silent no-op).
throw new WebAuthnError('unsupported', 'WebAuthn is not available in this environment')
}
return {
async register(challenge: PublicKeyCredentialCreationOptions): Promise<RegistrationResult> {
let cred: Credential | null
try {
cred = await container.create({ publicKey: challenge })
} catch (err) {
throw mapCeremonyError(err)
}
if (!cred) throw new WebAuthnError('failed', 'no credential returned')
const pk = cred as PublicKeyCredential
const resp = pk.response as AuthenticatorAttestationResponse
return {
id: pk.id,
rawId: bufferToBase64Url(pk.rawId),
type: pk.type,
response: {
clientDataJSON: bufferToBase64Url(resp.clientDataJSON),
attestationObject: bufferToBase64Url(resp.attestationObject),
},
}
},
async authenticate(
challenge: PublicKeyCredentialRequestOptions,
): Promise<AuthenticationResult> {
let cred: Credential | null
try {
cred = await container.get({ publicKey: challenge })
} catch (err) {
throw mapCeremonyError(err)
}
if (!cred) throw new WebAuthnError('failed', 'no assertion returned')
const pk = cred as PublicKeyCredential
const resp = pk.response as AuthenticatorAssertionResponse
return {
id: pk.id,
rawId: bufferToBase64Url(pk.rawId),
type: pk.type,
response: {
clientDataJSON: bufferToBase64Url(resp.clientDataJSON),
authenticatorData: bufferToBase64Url(resp.authenticatorData),
signature: bufferToBase64Url(resp.signature),
userHandle: resp.userHandle ? bufferToBase64Url(resp.userHandle) : null,
},
}
},
}
}

View File

@@ -0,0 +1,129 @@
/**
* T4 — `TerminalTransport` seam + `PassthroughTransport` (v0.8, no E2E).
*
* The abstraction that lets browser-side E2E (T8) "drop in" without reshaping the view: both the
* passthrough and the E2E transport implement `TerminalTransport`, so `terminal-view.ts` never
* changes. In v0.8 there is NO capability token (INDEX §1) — the same-origin signed cookie (T3)
* authenticates the upgrade and the base-app Origin/CSWSH check (M6) is retained.
*
* Token attachment (v0.9+): the §4.3 capability token rides the `Sec-WebSocket-Protocol`
* subprotocol list (frozen §4.3 wire format via `encodeTokenSubprotocol`), NEVER the URL/query
* string (which would leak the bearer credential into proxy/access logs, history, and `Referer`).
* After open, the client asserts the echoed subprotocol is exactly `APP_SUBPROTOCOL` and tears the
* socket down otherwise (echo rule).
*/
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
import type { RelayWebConfig } from './config'
/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
export interface TerminalTransport {
open(): Promise<void>
send(bytes: Uint8Array): void
onMessage(cb: (bytes: Uint8Array) => void): void
onClose(cb: (reason: string) => void): void
close(): void
}
/** Minimal structural WebSocket surface (so tests can inject a mock, no jsdom WS dependency). */
export interface WebSocketLike {
binaryType: string
protocol: string
send(data: ArrayBufferView | ArrayBufferLike | string): void
close(code?: number, reason?: string): void
onopen: ((ev: unknown) => void) | null
onmessage: ((ev: { data: unknown }) => void) | null
onclose: ((ev: { code?: number; reason?: string }) => void) | null
onerror: ((ev: unknown) => void) | null
}
export type WebSocketCtor = new (url: string, protocols?: string | readonly string[]) => WebSocketLike
export interface PassthroughOpts {
/** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
readonly capabilityToken?: string
/** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
readonly wsCtor?: WebSocketCtor
}
/** Map a WS close event to a stable reason string the UI can render (no silent swallow). */
function closeReason(ev: { code?: number; reason?: string }): string {
if (ev.code === 4403) return 'forbidden' // INV1/INV6 upgrade denial mirror
if (ev.code === 4401) return 'unauthenticated'
if (ev.reason && ev.reason.length > 0) return ev.reason
return 'closed'
}
/** Coerce a WS message payload (ArrayBuffer | ArrayBufferView | string) to bytes. */
function toBytes(data: unknown): Uint8Array {
if (data instanceof Uint8Array) return data
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (ArrayBuffer.isView(data)) {
const view = data as ArrayBufferView
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
}
if (typeof data === 'string') return new TextEncoder().encode(data)
return new Uint8Array(0)
}
export function createPassthroughTransport(
cfg: RelayWebConfig,
opts: PassthroughOpts = {},
): TerminalTransport {
const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
const token = opts.capabilityToken
const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)
let ws: WebSocketLike | null = null
let messageCb: ((bytes: Uint8Array) => void) | null = null
let closeCb: ((reason: string) => void) | null = null
// App subprotocol FIRST; token entry (v0.9+) second — the frozen §4.3 order.
const protocols: readonly string[] =
token === undefined ? [APP_SUBPROTOCOL] : [APP_SUBPROTOCOL, encodeTokenSubprotocol(token)]
function open(): Promise<void> {
return new Promise((resolve, reject) => {
const socket = new Ctor(url, protocols)
socket.binaryType = 'arraybuffer'
ws = socket
socket.onopen = () => {
// Echo rule (v0.9+): with a token attached, accept ONLY the app subprotocol back — a relay
// echoing the token entry, an empty value, or a foreign one is rejected (bearer-leak guard).
if (token !== undefined && socket.protocol !== APP_SUBPROTOCOL) {
socket.close(4400, 'bad-subprotocol')
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
return
}
resolve()
}
socket.onmessage = (ev) => {
if (messageCb) messageCb(toBytes(ev.data))
}
socket.onclose = (ev) => {
if (closeCb) closeCb(closeReason(ev))
}
socket.onerror = () => {
// Surface as a close so callers never hang; open() rejection is handled by onclose too.
reject(new Error('websocket error'))
}
})
}
return {
open,
send(bytes: Uint8Array): void {
if (!ws) throw new Error('transport not open')
ws.send(bytes)
},
onMessage(cb: (bytes: Uint8Array) => void): void {
messageCb = cb
},
onClose(cb: (reason: string) => void): void {
closeCb = cb
},
close(): void {
if (ws) ws.close()
},
}
}