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,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mountAddMachine } from '../src/add-machine'
import { ApiError } from '../src/errors'
import type { ApiClient } from '../src/api-client'
import type { HostRecord } from '../src/api-schemas'
function host(subdomain: string, status: HostRecord['status'] = 'online'): HostRecord {
return {
hostId: `id-${subdomain}`,
accountId: 'acc',
subdomain,
agentPubkey: new Uint8Array([1]),
enrollFpr: `fpr-${subdomain}`,
status,
lastSeen: '2026-01-01T00:00:00Z',
createdAt: '2026-01-01T00:00:00Z',
revokedAt: null,
}
}
function fakeApi(over: Partial<ApiClient>): ApiClient {
return {
listHosts: vi.fn(async () => []),
getHost: vi.fn(),
requestPairingCode: vi.fn(),
hostStatus: vi.fn(),
issueCapabilityToken: vi.fn(),
...over,
} as unknown as ApiClient
}
const flush = () => new Promise((r) => setTimeout(r, 0))
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms))
describe('mountAddMachine (T6)', () => {
let root: HTMLElement
beforeEach(() => {
root = document.createElement('div')
document.body.append(root)
})
it('happy path: shows npx command, detects a newly online host, fires onPaired once', async () => {
let call = 0
const listHosts = vi.fn(async () => (call++ === 0 ? [] : [host('newbox')]))
const requestPairingCode = vi.fn(async () => ({ code: 'ABCD-1234', expiresAt: futureIso() }))
const onPaired = vi.fn()
const api = fakeApi({ listHosts, requestPairingCode })
const add = mountAddMachine(root, api, { pollMs: 5, onPaired })
await add.start()
await flush()
await flush()
await flush()
expect(root.querySelector('.pair-command')?.textContent).toBe(
'npx web-terminal-agent pair ABCD-1234',
)
expect(onPaired).toHaveBeenCalledExactlyOnceWith('id-newbox')
add.dispose()
})
it('expired code: after expiresAt it shows "expired", clears the code, and stops polling', async () => {
let t = 1_000_000
const requestPairingCode = vi.fn(async () => ({ code: 'EXP-0001', expiresAt: new Date(t + 50).toISOString() }))
const listHosts = vi.fn(async () => [])
const api = fakeApi({ listHosts, requestPairingCode })
const add = mountAddMachine(root, api, { pollMs: 5, now: () => t })
await add.start()
t += 100 // advance past expiry
await tick(25) // let the poll interval fire and catch expiry
expect(root.querySelector('.pair-status')?.textContent).toContain('expired')
expect(root.querySelector('.pair-command')?.textContent).toBe('')
const callsAtExpiry = listHosts.mock.calls.length
await tick(25)
expect(listHosts.mock.calls.length).toBe(callsAtExpiry) // polling stopped
add.dispose()
})
it('a requestPairingCode failure surfaces an error and does not start an infinite poll', async () => {
const requestPairingCode = vi.fn(async () => {
throw new ApiError('http', 'nope', 500)
})
const listHosts = vi.fn(async () => [])
const add = mountAddMachine(root, fakeApi({ requestPairingCode, listHosts }), { pollMs: 5 })
await add.start()
await flush()
expect(root.querySelector('.pair-error')?.textContent).toContain('Could not issue a code')
add.dispose()
})
it('renders the code via textContent only (no HTML injection)', async () => {
const requestPairingCode = vi.fn(async () => ({ code: '<img src=x>', expiresAt: futureIso() }))
const add = mountAddMachine(root, fakeApi({ requestPairingCode }), { pollMs: 100000 })
await add.start()
const cmd = root.querySelector('.pair-command') as HTMLElement
expect(cmd.querySelector('img')).toBeNull()
expect(cmd.textContent).toContain('<img src=x>')
add.dispose()
})
})
function futureIso(): string {
return new Date(Date.now() + 120_000).toISOString()
}

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest'
import { encodeBase64UrlBytes } from 'relay-contracts'
import { readConfig } from '../src/config'
import { createApiClient, type ApiClient, type ApiClientV08 } from '../src/api-client'
import { ApiError } from '../src/errors'
const cfg = readConfig({
protocol: 'https:',
host: 'alice.term.example.com',
hostname: 'alice.term.example.com',
})
const validHost = {
hostId: '11111111-1111-4111-8111-111111111111',
accountId: '22222222-2222-4222-8222-222222222222',
subdomain: 'alice',
agentPubkey: encodeBase64UrlBytes(new Uint8Array([1, 2, 3, 4])),
enrollFpr: 'fpr-abc',
status: 'online',
lastSeen: '2026-01-01T00:00:00Z',
createdAt: '2026-01-01T00:00:00Z',
revokedAt: null,
}
/** Build a mock fetch returning `body` as JSON with the given status. Records every call. */
function mockFetch(body: unknown, status = 200): { fetch: typeof fetch; calls: Request[] } {
const calls: Request[] = []
const fn = vi.fn(async (url: string, init?: RequestInit) => {
calls.push(new Request(`https://alice.term.example.com${url}`, init))
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response
})
return { fetch: fn as unknown as typeof fetch, calls }
}
describe('createApiClient — Zod validation + INV3 (T2)', () => {
it('listHosts sends NO account_id/tenant_id in url, query, or body (INV3)', async () => {
const { fetch, calls } = mockFetch([validHost])
const api = createApiClient(cfg, fetch)
const hosts = await api.listHosts()
expect(hosts).toHaveLength(1)
expect(hosts[0]?.enrollFpr).toBe('fpr-abc')
expect(hosts[0]?.agentPubkey).toBeInstanceOf(Uint8Array)
const req = calls[0]!
expect(req.url).not.toMatch(/account_id|tenant_id/i)
const body = await req.text()
expect(body).not.toMatch(/account_id|tenant_id/i)
})
it('requestPairingCode posts an empty body — account derived server-side (INV3)', async () => {
const { fetch, calls } = mockFetch({ code: 'ABCD-1234', expiresAt: '2026-01-01T00:02:00Z' })
const api = createApiClient(cfg, fetch)
const out = await api.requestPairingCode()
expect(out.code).toBe('ABCD-1234')
const body = await calls[0]!.text()
expect(body).not.toMatch(/account_id|tenant_id/i)
expect(JSON.parse(body)).toEqual({})
})
it('getHost returns a Zod-validated HostRecord with a non-empty enrollFpr (T8 trust root)', async () => {
const { fetch } = mockFetch(validHost)
const api = createApiClient(cfg, fetch)
const host = await api.getHost('11111111-1111-4111-8111-111111111111')
expect(host.enrollFpr).toBe('fpr-abc')
})
it('throws ApiError(parse) on a malformed host (missing subdomain) — never a torn object', async () => {
const bad = { ...validHost, subdomain: undefined }
const { fetch } = mockFetch(bad)
const api = createApiClient(cfg, fetch)
await expect(api.getHost('h1')).rejects.toBeInstanceOf(ApiError)
await expect(api.getHost('h1')).rejects.toMatchObject({ kind: 'parse' })
})
it('throws ApiError(parse) when enrollFpr is empty (no relay-forwarded fallback for T8)', async () => {
const { fetch } = mockFetch({ ...validHost, enrollFpr: '' })
const api = createApiClient(cfg, fetch)
await expect(api.getHost('h1')).rejects.toMatchObject({ kind: 'parse' })
})
it('maps 401 → ApiError(unauthenticated) (surfaces re-login, no silent swallow)', async () => {
const { fetch } = mockFetch({}, 401)
const api = createApiClient(cfg, fetch)
await expect(api.listHosts()).rejects.toMatchObject({ kind: 'unauthenticated' })
})
it('maps 403 → ApiError(forbidden) (INV1 enforced upstream, respected here)', async () => {
const { fetch } = mockFetch({}, 403)
const api = createApiClient(cfg, fetch)
await expect(api.getHost('foreign')).rejects.toMatchObject({ kind: 'forbidden' })
})
it('v0.9+ issueCapabilityToken returns the opaque raw token', async () => {
const { fetch } = mockFetch({ token: 'v2.public.rawtoken' })
const api = createApiClient(cfg, fetch)
const token = await api.issueCapabilityToken('h1', ['attach'])
expect(token).toBe('v2.public.rawtoken')
})
it('v0.8 phasing: issueCapabilityToken is ABSENT from ApiClientV08 (structural guard)', () => {
const { fetch } = mockFetch([validHost])
const client: ApiClient = createApiClient(cfg, fetch)
const v08: ApiClientV08 = client
// @ts-expect-error — issueCapabilityToken is v0.9+, not on the v0.8 surface a v0.8 build uses.
void v08.issueCapabilityToken
})
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { readConfig } from '../src/config'
import { ConfigError } from '../src/errors'
/** Build a Location-like object from a URL string. */
function loc(url: string): Pick<Location, 'protocol' | 'host' | 'hostname'> {
const u = new URL(url)
return { protocol: u.protocol, host: u.host, hostname: u.hostname }
}
describe('readConfig — subdomain + scheme-following same-origin URL (T1, M6)', () => {
it('derives subdomain and wss: URL on https', () => {
const cfg = readConfig(loc('https://alice.term.example.com'))
expect(cfg.subdomain).toBe('alice')
expect(cfg.wsUrl('/term')).toBe('wss://alice.term.example.com/term')
expect(cfg.apiBase).toBe('')
})
it('follows the page scheme to ws: on http (no mixed-content on TLS)', () => {
const cfg = readConfig(loc('http://alice.term.localhost:3000'))
expect(cfg.subdomain).toBe('alice')
expect(cfg.wsUrl('/term')).toBe('ws://alice.term.localhost:3000/term')
})
it('throws ConfigError on a bare host with no subdomain label (fail-fast, never defaults)', () => {
expect(() => readConfig(loc('https://example.com'))).toThrow(ConfigError)
expect(() => readConfig(loc('https://localhost'))).toThrow(ConfigError)
expect(() => readConfig(loc('https://term.example.com'))).toThrow(ConfigError)
})
it('keeps wsUrl same-origin — a path cannot inject a foreign host', () => {
const cfg = readConfig(loc('https://alice.term.example.com'))
const injected = cfg.wsUrl('//evil.com')
expect(injected.startsWith('wss://alice.term.example.com/')).toBe(true)
expect(injected).not.toContain('evil.com/')
// even a scheme-looking path stays anchored to our host
expect(new URL(cfg.wsUrl('/x')).host).toBe('alice.term.example.com')
})
})

View File

@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mountDashboard } from '../src/dashboard'
import { ApiError } from '../src/errors'
import type { ApiClient } from '../src/api-client'
import type { HostRecord, HostStatus } from '../src/api-schemas'
function host(subdomain: string, status: HostStatus): HostRecord {
return {
hostId: `id-${subdomain}`,
accountId: 'acc',
subdomain,
agentPubkey: new Uint8Array([1]),
enrollFpr: `fpr-${subdomain}`,
status,
lastSeen: '2026-01-01T00:00:00Z',
createdAt: '2026-01-01T00:00:00Z',
revokedAt: status === 'revoked' ? '2026-01-02T00:00:00Z' : null,
}
}
function fakeApi(listHosts: () => Promise<readonly HostRecord[]>): ApiClient {
return {
listHosts,
getHost: vi.fn(),
requestPairingCode: vi.fn(),
hostStatus: vi.fn(),
issueCapabilityToken: vi.fn(),
} as unknown as ApiClient
}
describe('mountDashboard (T5)', () => {
let root: HTMLElement
beforeEach(() => {
root = document.createElement('div')
document.body.append(root)
})
it('renders one card per host with the correct status class', async () => {
const api = fakeApi(async () => [host('alice', 'online'), host('bob', 'offline'), host('cy', 'draining')])
const dash = mountDashboard(root, api, { pollMs: 100000 })
await dash.refresh()
const cards = root.querySelectorAll('.host-card')
expect(cards).toHaveLength(3)
expect(root.querySelector('.status-online')).not.toBeNull()
expect(root.querySelector('.status-draining')).not.toBeNull()
dash.dispose()
})
it('revoked host is non-interactive: no open action, shows blocked state (INV12 UI)', async () => {
const api = fakeApi(async () => [host('gone', 'revoked')])
const dash = mountDashboard(root, api, { pollMs: 100000 })
await dash.refresh()
const card = root.querySelector('[data-status="revoked"]') as HTMLElement
expect(card.querySelector('.host-open')).toBeNull()
expect(card.querySelector('.host-blocked')?.textContent).toBe('revoked')
dash.dispose()
})
it('open action routes with the host_id (no foreign-host input path exists — INV1/INV3)', async () => {
const onOpen = vi.fn()
const api = fakeApi(async () => [host('alice', 'online')])
const dash = mountDashboard(root, api, { pollMs: 100000, onOpen })
await dash.refresh()
;(root.querySelector('.host-open') as HTMLButtonElement).click()
expect(onOpen).toHaveBeenCalledWith('id-alice')
dash.dispose()
})
it('a listHosts rejection shows an error banner and keeps polling', async () => {
const api = fakeApi(async () => {
throw new ApiError('http', 'boom', 500)
})
const dash = mountDashboard(root, api, { pollMs: 100000 })
await dash.refresh()
expect(root.querySelector('.dashboard-error')?.textContent).toContain('Could not load hosts')
dash.dispose()
})
it('never issues a request carrying an account_id (INV3) — listHosts is argument-free', async () => {
const listHosts = vi.fn(async () => [] as HostRecord[])
const dash = mountDashboard(root, fakeApi(listHosts), { pollMs: 100000 })
await dash.refresh()
expect(listHosts).toHaveBeenCalledWith() // no args ⇒ no client-supplied tenant identity
dash.dispose()
})
})

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import { deriveContentKey, encodeEnvelope, openReplayCiphertext, randomBytes, sealReplayFrame } from 'relay-e2e'
// Mock the xterm modules so the DEFAULT (non-injected) terminal loaders run without a real canvas.
const writes: string[] = []
vi.mock('@xterm/xterm', () => ({
Terminal: class {
cols = 80
rows = 24
constructor(_opts?: unknown) {}
loadAddon(_a: unknown): void {}
open(_el: unknown): void {}
write(data: string): void {
writes.push(data)
}
onData(_cb: (d: string) => void): void {}
dispose(): void {}
},
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: class {
fit(): void {}
},
}))
import { mountTerminalView } from '../src/terminal-view'
import { mountPreviewClient, type ReplaySource } from '../src/preview-client'
import type { TerminalTransport } from '../src/ws-transport'
const tick = () => new Promise((r) => setTimeout(r, 0))
describe('default (real-xterm) loaders behind the DI seam', () => {
it('mountTerminalView loads the default xterm + FitAddon and sends attach', async () => {
const root = document.createElement('div')
Object.defineProperty(root, 'clientWidth', { value: 100 })
Object.defineProperty(root, 'clientHeight', { value: 100 })
const sent: Uint8Array[] = []
const transport: TerminalTransport = {
open: async () => {},
send: (b) => sent.push(b),
onMessage: () => {},
onClose: () => {},
close: () => {},
}
const view = mountTerminalView(root, transport) // NO createTerminal → default loader path
await tick()
await tick()
const first = JSON.parse(new TextDecoder().decode(sent[0]!))
expect(first.type).toBe('attach')
view.dispose()
})
it('mountPreviewClient renders via the default read-only xterm loader', async () => {
const secret = randomBytes(32)
const k = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' })
const replay: ReplaySource = {
sessionId: 's',
alg: 'aes-256-gcm',
frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode('DEFAULT-XTERM')))],
}
const card = document.createElement('div')
const client = mountPreviewClient(card, replay, secret, { cols: 80, rows: 24 }, {
deriveContentKey,
openReplayCiphertext,
})
await client.render()
await tick()
await tick()
expect(writes).toContain('DEFAULT-XTERM')
client.dispose()
})
})

View File

@@ -0,0 +1,340 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { importAeadKey, createE2ESession } from 'relay-e2e'
import { randomBytes } from 'relay-e2e'
import type { AeadKey, E2ESession, HandshakeResult } from 'relay-contracts'
import { readConfig } from '../src/config'
import { createHostPinStore } from '../src/host-pin-store'
import {
createE2ETransport,
type E2EHandshakeIo,
type E2ETransportDeps,
type FprDecision,
type RunHandshakeArgs,
} from '../src/e2e-socket'
import type { WebSocketLike } from '../src/ws-transport'
import { APP_SUBPROTOCOL } from 'relay-contracts'
const cfg = readConfig({
protocol: 'https:',
host: 'alice.term.example.com',
hostname: 'alice.term.example.com',
})
/** A real relay-e2e loopback: client + host sessions sharing DirectionalKeys (genuine crypto). */
function loopbackPair(): { client: E2ESession; host: E2ESession } {
const c2h: AeadKey = importAeadKey(randomBytes(32), 'aes-256-gcm', 'c2h')
const h2c: AeadKey = importAeadKey(randomBytes(32), 'aes-256-gcm', 'h2c')
const result: HandshakeResult = { keys: { c2h, h2c }, aead: 'aes-256-gcm', transcript: new Uint8Array() }
return { client: createE2ESession('client', result), host: createE2ESession('host', result) }
}
class MockWs implements WebSocketLike {
static last: MockWs | null = null
binaryType = ''
protocol = ''
readonly sent: Array<ArrayBufferView | ArrayBufferLike | string> = []
closed = false
onopen: ((ev: unknown) => void) | null = null
onmessage: ((ev: { data: unknown }) => void) | null = null
onclose: ((ev: { code?: number; reason?: string }) => void) | null = null
onerror: ((ev: unknown) => void) | null = null
constructor(
readonly url: string,
readonly protocols?: string | readonly string[],
) {
MockWs.last = this
}
send(data: ArrayBufferView | ArrayBufferLike | string): void {
this.sent.push(data)
}
close(): void {
this.closed = true
this.onclose?.({ code: 1000 })
}
fireOpen(): void {
this.protocol = APP_SUBPROTOCOL
this.onopen?.({})
}
fireMessage(data: unknown): void {
this.onmessage?.({ data })
}
}
const ctx = {
deviceAuthProofProvider: { proofFor: async () => 'proof' },
pinStore: { get: async () => null, pin: async () => {} },
hostContentSecret: new Uint8Array(32),
}
/**
* Build a transport whose injected `runHandshake` honours the fingerprint gate (calling
* verifyOfferedFpr with `offeredFpr`) and, on 'trust', returns the real relay-e2e client session.
*/
function makeTransport(opts: {
expectedFpr: string
offeredFpr: string
onPinMismatch?: (e: string, o: string, s: 'api' | 'cache') => Promise<FprDecision>
pins?: ReturnType<typeof createHostPinStore>
}) {
const { client, host } = loopbackPair()
const pins = opts.pins ?? createHostPinStore(localStorage)
const runHandshake = vi.fn(
async (_io: E2EHandshakeIo, args: RunHandshakeArgs): Promise<E2ESession> => {
const decision = await args.verifyOfferedFpr(opts.offeredFpr)
if (decision === 'abort') throw new Error('handshake aborted: fingerprint mismatch')
return client
},
)
const deps: E2ETransportDeps = {
runHandshake,
context: ctx,
agentPubkey: new Uint8Array([1, 2, 3]),
}
const onPinMismatch = opts.onPinMismatch ?? (async () => 'abort' as FprDecision)
const transport = createE2ETransport(
cfg,
'RAWTOK',
'h1',
opts.expectedFpr,
pins,
onPinMismatch,
deps,
)
return { transport, host, pins, runHandshake }
}
async function openTransport(t: ReturnType<typeof makeTransport>['transport']): Promise<void> {
const opened = t.open()
MockWs.last!.fireOpen()
await opened
}
beforeEach(() => {
localStorage.clear()
MockWs.last = null
})
// Patch the ctor into deps via globalThis for these tests.
function withMockWs<T>(fn: () => T): T {
const original = (globalThis as { WebSocket?: unknown }).WebSocket
;(globalThis as { WebSocket?: unknown }).WebSocket = MockWs as unknown as typeof WebSocket
try {
return fn()
} finally {
;(globalThis as { WebSocket?: unknown }).WebSocket = original
}
}
describe('createE2ETransport (T8) — INV2 / INV13 / TOFU trust root', () => {
it('first-connect MITM: relay fpr ≠ API expectedFpr → abort, no session, nothing recorded', async () => {
await withMockWs(async () => {
const onPinMismatch = vi.fn(async () => 'abort' as FprDecision)
const { transport, pins } = makeTransport({
expectedFpr: 'fpr-API',
offeredFpr: 'fpr-RELAY-FORGED',
onPinMismatch,
})
await expect(openTransport(transport)).rejects.toThrow()
expect(onPinMismatch).toHaveBeenCalledWith('fpr-API', 'fpr-RELAY-FORGED', 'api')
expect(pins.get('h1')).toBeNull() // never recorded a relay-forwarded value
expect(MockWs.last!.closed).toBe(true)
})
})
it('happy path: fpr == expectedFpr → session established, API value cached (audit trail)', async () => {
await withMockWs(async () => {
const { transport, pins } = makeTransport({ expectedFpr: 'fpr-API', offeredFpr: 'fpr-API' })
await openTransport(transport)
expect(pins.get('h1')).toBe('fpr-API') // the API-sourced value, not a handshake-derived one
})
})
it('INV2: a known plaintext marker never appears un-AEAD\'d on the wire', async () => {
await withMockWs(async () => {
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
await openTransport(transport)
const marker = new TextEncoder().encode('SECRET-MARKER-12345')
transport.send(marker)
const framed = MockWs.last!.sent.at(-1) as Uint8Array
expect(new TextDecoder().decode(framed)).not.toContain('SECRET-MARKER-12345')
})
})
it('round-trips: a host-sealed frame decrypts to the original plaintext via onMessage', async () => {
await withMockWs(async () => {
const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
const received: Uint8Array[] = []
transport.onMessage((b) => received.push(b))
await openTransport(transport)
MockWs.last!.fireMessage(host.seal(new TextEncoder().encode('hello')))
expect(new TextDecoder().decode(received[0]!)).toBe('hello')
})
})
it('INV13 tamper: flipping a ciphertext byte → AEAD tag fails → socket torn down', async () => {
await withMockWs(async () => {
const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
let closeReason = ''
transport.onClose((r) => (closeReason = r))
await openTransport(transport)
const frame = Uint8Array.from(host.seal(new TextEncoder().encode('data')))
const last = frame.length - 1
frame[last] = (frame[last] ?? 0) ^ 0xff // flip a tag/ciphertext byte
MockWs.last!.fireMessage(frame)
expect(closeReason).toBe('e2e-verify-failed')
expect(MockWs.last!.closed).toBe(true)
})
})
it('INV13 replay: re-injecting a captured frame → rejected → tear-down', async () => {
await withMockWs(async () => {
const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
let closeReason = ''
transport.onClose((r) => (closeReason = r))
await openTransport(transport)
const frame = host.seal(new TextEncoder().encode('data'))
MockWs.last!.fireMessage(frame) // seq 0 accepted
MockWs.last!.fireMessage(frame) // replay of seq 0 → strict-successor guard rejects
expect(closeReason).toBe('e2e-verify-failed')
})
})
it('INV13 reorder: seq=1 before seq=0 → rejected', async () => {
await withMockWs(async () => {
const { transport, host } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
let closeReason = ''
transport.onClose((r) => (closeReason = r))
await openTransport(transport)
host.seal(new TextEncoder().encode('zero')) // consume seq 0 on the host sender
const frameOne = host.seal(new TextEncoder().encode('one')) // seq 1
MockWs.last!.fireMessage(frameOne) // client expects seq 0 first
expect(closeReason).toBe('e2e-verify-failed')
})
})
it('INV13 reflection: a client→host frame re-injected as host→client → rejected (direction-bound)', async () => {
await withMockWs(async () => {
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
let closeReason = ''
transport.onClose((r) => (closeReason = r))
await openTransport(transport)
// Capture a genuine outbound (client→host) frame and reflect it into the inbound path.
transport.send(new TextEncoder().encode('outbound'))
const reflected = MockWs.last!.sent.at(-1) as Uint8Array
MockWs.last!.fireMessage(reflected)
expect(closeReason).toBe('e2e-verify-failed') // c2h frame cannot verify under h2c
})
})
it('key non-exposure: no key bytes reach localStorage; only the public fpr is cached', async () => {
await withMockWs(async () => {
const { transport } = makeTransport({ expectedFpr: 'fpr-API', offeredFpr: 'fpr-API' })
await openTransport(transport)
transport.send(new TextEncoder().encode('x'))
const dump = JSON.stringify(localStorage)
expect(dump).toContain('fpr-API')
expect(dump).not.toMatch(/__aeadKey|CryptoKey/)
})
})
it('a socket error during the E2E open rejects (no partial session)', async () => {
await withMockWs(async () => {
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
const opened = transport.open()
MockWs.last!.onerror?.({})
await expect(opened).rejects.toThrow('websocket error')
})
})
it('a plain server close during DATA phase surfaces its reason string', async () => {
await withMockWs(async () => {
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
let reason = ''
transport.onClose((r) => (reason = r))
await openTransport(transport)
MockWs.last!.onclose?.({ code: 1000, reason: 'agent shutdown' })
expect(reason).toBe('agent shutdown')
})
})
it('a bare close (no code/reason) surfaces the default "closed" reason', async () => {
await withMockWs(async () => {
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
let reason = ''
transport.onClose((r) => (reason = r))
await openTransport(transport)
MockWs.last!.onclose?.({})
expect(reason).toBe('closed')
})
})
it('operator override: onPinMismatch("api") → "trust" lets the handshake proceed (branch)', async () => {
await withMockWs(async () => {
const onPinMismatch = vi.fn(async () => 'trust' as FprDecision)
const { transport, pins } = makeTransport({
expectedFpr: 'fpr-API',
offeredFpr: 'fpr-DIFFERENT',
onPinMismatch,
})
await openTransport(transport)
expect(onPinMismatch).toHaveBeenCalledWith('fpr-API', 'fpr-DIFFERENT', 'api')
expect(pins.get('h1')).toBe('fpr-API') // still records the authoritative API value
})
})
it('send() before the session is established throws (never sends plaintext)', () => {
withMockWs(() => {
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
expect(() => transport.send(new Uint8Array([1]))).toThrow('e2e transport not open')
})
})
it('a non-abort handshake failure tears the socket down and rejects open()', async () => {
await withMockWs(async () => {
const pins = createHostPinStore(localStorage)
const deps: E2ETransportDeps = {
runHandshake: vi.fn(async () => {
throw new Error('agent unreachable')
}),
context: ctx,
agentPubkey: new Uint8Array([1]),
}
const transport = createE2ETransport(cfg, 'RAWTOK', 'h1', 'f', pins, async () => 'trust', deps)
let closeReason = ''
transport.onClose((r) => (closeReason = r))
const opened = transport.open()
MockWs.last!.fireOpen()
await expect(opened).rejects.toThrow('agent unreachable')
expect(closeReason).toBe('e2e-handshake-failed')
expect(pins.get('h1')).toBeNull()
})
})
it('a 4403 server close during the DATA phase surfaces onClose("forbidden")', async () => {
await withMockWs(async () => {
const { transport } = makeTransport({ expectedFpr: 'f', offeredFpr: 'f' })
let closeReason = ''
transport.onClose((r) => (closeReason = r))
await openTransport(transport)
MockWs.last!.onclose?.({ code: 4403 })
expect(closeReason).toBe('forbidden')
})
})
it('cache drift: a later expectedFpr differing from the cache surfaces onPinMismatch(...,"cache")', async () => {
await withMockWs(async () => {
const pins = createHostPinStore(localStorage)
pins.record('h1', 'fpr-OLD')
const onPinMismatch = vi.fn(async () => 'trust' as FprDecision)
const { transport } = makeTransport({
expectedFpr: 'fpr-NEW',
offeredFpr: 'fpr-NEW',
onPinMismatch,
pins,
})
await openTransport(transport)
expect(onPinMismatch).toHaveBeenCalledWith('fpr-NEW', 'fpr-OLD', 'cache')
expect(pins.get('h1')).toBe('fpr-NEW') // API value is authoritative — cache updated
})
})
})

View File

@@ -0,0 +1,277 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readConfig } from '../src/config'
import { createHostPinStore } from '../src/host-pin-store'
import { createPassthroughTransport, type WebSocketLike } from '../src/ws-transport'
import { mountTerminalView, type TerminalLike } from '../src/terminal-view'
import { decodeServerMessage } from '../src/protocol'
import { createWebAuthnClient } from '../src/webauthn'
import { mountPreviewClient, type ReadonlyTerminalLike } from '../src/preview-client'
import { createApiClient } from '../src/api-client'
import { mountPasswordLogin } from '../src/login-password'
import type { TerminalTransport } from '../src/ws-transport'
const cfg = readConfig({ protocol: 'http:', host: 'a.term.localhost', hostname: 'a.term.localhost' })
afterEach(() => vi.unstubAllGlobals())
describe('host-pin-store fallbacks', () => {
it('uses an in-memory Storage when localStorage is unavailable', () => {
vi.stubGlobal('localStorage', undefined)
const pins = createHostPinStore()
expect(pins.get('h')).toBeNull()
pins.record('h', 'fpr-mem')
expect(pins.get('h')).toBe('fpr-mem')
})
})
describe('ws-transport defensive branches', () => {
it('send() before open() throws (never silently drops bytes)', () => {
const t = createPassthroughTransport(cfg, { wsCtor: FakeWs })
expect(() => t.send(new Uint8Array([1]))).toThrow('transport not open')
})
it('decodes a string WS payload to bytes', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: FakeWs })
const got: Uint8Array[] = []
t.onMessage((b) => got.push(b))
const opened = t.open()
FakeWs.last!.fire()
await opened
FakeWs.last!.msg('hi')
expect(new TextDecoder().decode(got[0]!)).toBe('hi')
t.close()
})
})
describe('terminal-view defensive branches', () => {
it('drops a send when the transport is not open (caught), and disposes cleanly', () => {
const root = document.createElement('div')
Object.defineProperty(root, 'clientWidth', { value: 10 })
Object.defineProperty(root, 'clientHeight', { value: 10 })
const throwing: TerminalTransport = {
open: async () => {},
send: () => {
throw new Error('not open')
},
onMessage: () => {},
onClose: () => {},
close: () => {},
}
const term: TerminalLike = {
cols: 80,
rows: 24,
open: () => {},
write: () => {},
onData: () => {},
fit: () => {},
dispose: () => {},
}
const view = mountTerminalView(root, throwing, { createTerminal: () => term })
view.dispose()
})
})
describe('protocol edge cases', () => {
it('decodes exit with a reason and rejects unknown/array frames', () => {
expect(decodeServerMessage(enc({ type: 'exit', code: 1, reason: 'bye' }))).toEqual({
type: 'exit',
code: 1,
reason: 'bye',
})
expect(decodeServerMessage(enc({ type: 'nope' }))).toBeNull()
expect(decodeServerMessage(enc([1, 2]))).toBeNull()
})
})
describe('webauthn register mapping', () => {
it('maps an attestation credential to base64url fields', async () => {
const attestation = {
id: 'reg',
type: 'public-key',
rawId: new Uint8Array([1]).buffer,
response: {
clientDataJSON: new Uint8Array([2]).buffer,
attestationObject: new Uint8Array([3]).buffer,
},
}
const container = {
create: vi.fn(async () => attestation),
get: vi.fn(),
} as unknown as CredentialsContainer
const res = await createWebAuthnClient(container).register(
{} as PublicKeyCredentialCreationOptions,
)
expect(res.response.attestationObject.length).toBeGreaterThan(0)
expect(res.id).toBe('reg')
})
})
describe('webauthn error branches', () => {
it('register cancel → cancelled; register/authenticate null → failed; AbortError → cancelled', async () => {
const cancel = {
create: vi.fn(async () => {
throw Object.assign(new Error('abort'), { name: 'AbortError' })
}),
get: vi.fn(async () => null),
} as unknown as CredentialsContainer
const wa = createWebAuthnClient(cancel)
await expect(wa.register({} as PublicKeyCredentialCreationOptions)).rejects.toMatchObject({
kind: 'cancelled',
})
await expect(
wa.authenticate({} as PublicKeyCredentialRequestOptions),
).rejects.toMatchObject({ kind: 'failed' }) // null assertion → failed
const nullReg = {
create: vi.fn(async () => null),
get: vi.fn(),
} as unknown as CredentialsContainer
await expect(
createWebAuthnClient(nullReg).register({} as PublicKeyCredentialCreationOptions),
).rejects.toMatchObject({ kind: 'failed' })
})
})
describe('webauthn non-Error rejection', () => {
it('maps a thrown non-Error value to WebAuthnError("failed")', async () => {
const container = {
get: vi.fn(async () => {
throw 'string failure' // eslint-disable-line no-throw-literal
}),
create: vi.fn(),
} as unknown as CredentialsContainer
await expect(
createWebAuthnClient(container).authenticate({} as PublicKeyCredentialRequestOptions),
).rejects.toMatchObject({ kind: 'failed' })
})
})
describe('dashboard optional-branch coverage', () => {
it('renders with default pollMs and an open action that is a no-op when onOpen is absent', async () => {
const { mountDashboard } = await import('../src/dashboard')
const root = document.createElement('div')
const api = {
listHosts: vi.fn(async () => [
{
hostId: 'id-a',
accountId: 'acc',
subdomain: 'a',
agentPubkey: new Uint8Array([1]),
enrollFpr: 'f',
status: 'online',
lastSeen: 't',
createdAt: 't',
revokedAt: null,
},
]),
} as unknown as import('../src/api-client').ApiClient
const dash = mountDashboard(root, api) // no opts → default pollMs, onOpen undefined
await dash.refresh()
expect(() => (root.querySelector('.host-open') as HTMLButtonElement).click()).not.toThrow()
dash.dispose()
})
})
describe('dashboard generic-error branch', () => {
it('a non-ApiError rejection still shows the generic banner and keeps polling', async () => {
const { mountDashboard } = await import('../src/dashboard')
const root = document.createElement('div')
const api = {
listHosts: vi.fn(async () => {
throw new Error('kaboom')
}),
} as unknown as import('../src/api-client').ApiClient
const dash = mountDashboard(root, api, { pollMs: 100000 })
await dash.refresh()
expect(root.querySelector('.dashboard-error')?.textContent).toBe('Could not load hosts.')
dash.dispose()
})
})
describe('ws-transport raw Uint8Array message branch', () => {
it('delivers a raw Uint8Array payload unchanged', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: FakeWs })
const got: Uint8Array[] = []
t.onMessage((b) => got.push(b))
const opened = t.open()
FakeWs.last!.fire()
await opened
FakeWs.last!.msg(new Uint8Array([7, 7]))
expect(got[0]).toEqual(new Uint8Array([7, 7]))
t.close()
})
})
describe('preview-client unavailable path', () => {
it('shows "unavailable" when key derivation throws', async () => {
const card = document.createElement('div')
const client = mountPreviewClient(
card,
{ sessionId: 's', alg: 'aes-256-gcm', frames: [new Uint8Array([1])] },
new Uint8Array(32),
{ cols: 80, rows: 24 },
{
deriveContentKey: () => {
throw new Error('bad key')
},
openReplayCiphertext: () => new Uint8Array(),
createTerminal: () => ({ open: () => {}, write: () => {}, dispose: () => {} }) as ReadonlyTerminalLike,
},
)
await client.render()
expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable')
client.dispose()
})
})
describe('api-client + login-password error branches', () => {
it('hostStatus returns the parsed status; a network error maps to ApiError(network)', async () => {
const ok = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ status: 'online' }) }) as Response)
const api = createApiClient(cfg, ok as unknown as typeof fetch)
expect(await api.hostStatus('h1')).toBe('online')
const boom = vi.fn(async () => {
throw new Error('offline')
})
await expect(
createApiClient(cfg, boom as unknown as typeof fetch).listHosts(),
).rejects.toMatchObject({ kind: 'network' })
})
it('password login surfaces a network error via textContent', async () => {
const root = document.createElement('div')
const boom = vi.fn(async () => {
throw new Error('down')
}) as unknown as typeof fetch
const login = mountPasswordLogin(root, cfg, { fetchImpl: boom })
await expect(login.submit('tok')).resolves.toBe('rejected')
expect(root.querySelector('.login-error')?.textContent).toContain('Network error')
})
})
// ── helpers ──
function enc(v: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(v))
}
class FakeWs implements WebSocketLike {
static last: FakeWs | null = null
binaryType = ''
protocol = ''
onopen: ((ev: unknown) => void) | null = null
onmessage: ((ev: { data: unknown }) => void) | null = null
onclose: ((ev: { code?: number; reason?: string }) => void) | null = null
onerror: ((ev: unknown) => void) | null = null
constructor() {
FakeWs.last = this
}
send(): void {}
close(): void {}
fire(): void {
this.protocol = 'term.relay.v1'
this.onopen?.({})
}
msg(data: unknown): void {
this.onmessage?.({ data })
}
}

View File

@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createHostPinStore } from '../src/host-pin-store'
describe('createHostPinStore (T8) — cache/audit of the API-sourced fingerprint', () => {
beforeEach(() => localStorage.clear())
it('record/get round-trips the API-verified fingerprint', () => {
const pins = createHostPinStore(localStorage)
expect(pins.get('h1')).toBeNull()
pins.record('h1', 'fpr-api')
expect(pins.get('h1')).toBe('fpr-api')
})
it('detects drift: a caller comparing get() vs a fresh API value sees the rotation', () => {
const pins = createHostPinStore(localStorage)
pins.record('h1', 'fpr-old')
const cached = pins.get('h1')
const freshApi = 'fpr-new'
expect(cached).not.toBe(freshApi) // caller surfaces this as a 'cache' mismatch for review
})
it('keeps hosts isolated — one host_id never returns another host\'s pin', () => {
const pins = createHostPinStore(localStorage)
pins.record('h1', 'fpr-1')
pins.record('h2', 'fpr-2')
expect(pins.get('h1')).toBe('fpr-1')
expect(pins.get('h2')).toBe('fpr-2')
})
it('persists only the public fingerprint (no key/secret material at rest)', () => {
const pins = createHostPinStore(localStorage)
pins.record('h1', 'fpr-abc')
expect(JSON.stringify(localStorage)).toContain('fpr-abc')
expect(JSON.stringify(localStorage)).not.toMatch(/BEGIN|PRIVATE|secret/i)
})
})

View File

@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mountPasskeyLogin, type PasskeyAuthApi } from '../src/login-passkey'
import { WebAuthnError, type AuthenticationResult, type WebAuthnClient } from '../src/webauthn'
import type { ApiClient } from '../src/api-client'
const fakeAssertion: AuthenticationResult = {
id: 'x',
rawId: 'cmF3',
type: 'public-key',
response: { clientDataJSON: 'Y2Q', authenticatorData: 'YWQ', signature: 'c2ln', userHandle: null },
}
function reqOptions(rpId: string): PublicKeyCredentialRequestOptions {
return { challenge: new Uint8Array([1]).buffer, rpId } as PublicKeyCredentialRequestOptions
}
const stubApi = {} as ApiClient
describe('mountPasskeyLogin (T7)', () => {
let root: HTMLElement
beforeEach(() => {
root = document.createElement('div')
document.body.append(root)
})
it('successful authenticate → assertion verified → "ok"', async () => {
const wa: WebAuthnClient = { register: vi.fn(), authenticate: vi.fn(async () => fakeAssertion) }
const authApi: PasskeyAuthApi = {
getLoginChallenge: vi.fn(async () => reqOptions('app.example.com')),
verifyLogin: vi.fn(async () => 'ok' as const),
getStepUpChallenge: vi.fn(),
verifyStepUp: vi.fn(),
}
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
await expect(login.login()).resolves.toBe('ok')
expect(authApi.verifyLogin).toHaveBeenCalledWith(fakeAssertion)
})
it('user cancels the passkey prompt → "rejected", no session, no throw leak', async () => {
const wa: WebAuthnClient = {
register: vi.fn(),
authenticate: vi.fn(async () => {
throw new WebAuthnError('cancelled', 'dismissed')
}),
}
const authApi: PasskeyAuthApi = {
getLoginChallenge: vi.fn(async () => reqOptions('app.example.com')),
verifyLogin: vi.fn(async () => 'ok' as const),
getStepUpChallenge: vi.fn(),
verifyStepUp: vi.fn(),
}
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
await expect(login.login()).resolves.toBe('rejected')
expect(authApi.verifyLogin).not.toHaveBeenCalled()
})
it('stepUp runs a FRESH ceremony each call (a stolen cookie alone cannot open a session)', async () => {
const authenticate = vi.fn(async () => fakeAssertion)
const wa: WebAuthnClient = { register: vi.fn(), authenticate }
const authApi: PasskeyAuthApi = {
getLoginChallenge: vi.fn(),
verifyLogin: vi.fn(),
getStepUpChallenge: vi.fn(async () => reqOptions('app.example.com')),
verifyStepUp: vi.fn(async () => 'ok' as const),
}
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
await login.stepUp('h1')
await login.stepUp('h1')
expect(authenticate).toHaveBeenCalledTimes(2) // not cached — a new assertion every time
})
it('rpId comes from the SERVER challenge, never location.hostname (§8 Q#5)', async () => {
const authenticate = vi.fn((_c: PublicKeyCredentialRequestOptions) => Promise.resolve(fakeAssertion))
const wa: WebAuthnClient = { register: vi.fn(), authenticate }
const authApi: PasskeyAuthApi = {
getLoginChallenge: vi.fn(async () => reqOptions('app.example.com')),
verifyLogin: vi.fn(async () => 'ok' as const),
getStepUpChallenge: vi.fn(),
verifyStepUp: vi.fn(),
}
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
await login.login()
const passed = authenticate.mock.calls[0]![0] as PublicKeyCredentialRequestOptions
expect(passed.rpId).toBe('app.example.com')
expect(passed.rpId).not.toBe(window.location.hostname)
})
it('a failed challenge fetch → "rejected" with an error message (no throw leak)', async () => {
const wa: WebAuthnClient = { register: vi.fn(), authenticate: vi.fn(async () => fakeAssertion) }
const authApi: PasskeyAuthApi = {
getLoginChallenge: vi.fn(async () => {
throw new Error('challenge 500')
}),
verifyLogin: vi.fn(),
getStepUpChallenge: vi.fn(),
verifyStepUp: vi.fn(),
}
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
await expect(login.login()).resolves.toBe('rejected')
expect(root.querySelector('.passkey-error')?.textContent).toContain('Could not start')
})
it('a non-cancel authenticate failure → "rejected" and a failure message', async () => {
const wa: WebAuthnClient = {
register: vi.fn(),
authenticate: vi.fn(async () => {
throw new Error('authenticator exploded')
}),
}
const authApi: PasskeyAuthApi = {
getLoginChallenge: vi.fn(async () => reqOptions('app.example.com')),
verifyLogin: vi.fn(),
getStepUpChallenge: vi.fn(),
verifyStepUp: vi.fn(),
}
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
await expect(login.login()).resolves.toBe('rejected')
expect(root.querySelector('.passkey-error')?.textContent).toContain('failed')
})
it('exposes EXACTLY login + stepUp — no phone/SMS/OTP field (never SMS)', () => {
const wa: WebAuthnClient = { register: vi.fn(), authenticate: vi.fn() }
const authApi = {
getLoginChallenge: vi.fn(),
verifyLogin: vi.fn(),
getStepUpChallenge: vi.fn(),
verifyStepUp: vi.fn(),
} as unknown as PasskeyAuthApi
const login = mountPasskeyLogin(root, stubApi, wa, authApi)
expect(Object.keys(login).sort()).toEqual(['login', 'stepUp'])
expect(JSON.stringify(Object.keys(login))).not.toMatch(/sms|phone|otp/i)
})
})

View File

@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readConfig } from '../src/config'
import { mountPasswordLogin } from '../src/login-password'
const cfg = readConfig({
protocol: 'https:',
host: 'alice.term.example.com',
hostname: 'alice.term.example.com',
})
function okFetch(): typeof fetch {
return vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }) as Response) as unknown as typeof fetch
}
function rejectFetch(): typeof fetch {
return vi.fn(async () => ({ ok: false, status: 401, json: async () => ({}) }) as Response) as unknown as typeof fetch
}
describe('mountPasswordLogin — v0.8 gate (T3)', () => {
let root: HTMLElement
beforeEach(() => {
root = document.createElement('div')
document.body.append(root)
})
it('correct token → "ok" and issues a credentialed cookie login request', async () => {
const fetchImpl = okFetch()
const onSuccess = vi.fn()
const login = mountPasswordLogin(root, cfg, { fetchImpl, onSuccess })
await expect(login.submit('s3cret')).resolves.toBe('ok')
expect(onSuccess).toHaveBeenCalledOnce()
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!
const init = call[1] as RequestInit
expect(init.credentials).toBe('include')
expect(JSON.parse(init.body as string)).toEqual({ clientToken: 's3cret' })
})
it('wrong token → "rejected", no onSuccess, error shown via textContent (no innerHTML)', async () => {
const login = mountPasswordLogin(root, cfg, { fetchImpl: rejectFetch() })
await expect(login.submit('nope')).resolves.toBe('rejected')
const err = root.querySelector('.login-error') as HTMLElement
expect(err.textContent).toBe('Invalid access token.')
expect(err.innerHTML).toBe('Invalid access token.') // textContent-set, no markup injected
})
it('empty input keeps submit disabled (fail-fast) and submit("") is rejected without a fetch', async () => {
const fetchImpl = okFetch()
const login = mountPasswordLogin(root, cfg, { fetchImpl })
const button = root.querySelector('button') as HTMLButtonElement
expect(button.disabled).toBe(true)
await expect(login.submit('')).resolves.toBe('rejected')
expect(fetchImpl).not.toHaveBeenCalled()
})
it('does not persist the token to localStorage', async () => {
const login = mountPasswordLogin(root, cfg, { fetchImpl: okFetch() })
await login.submit('s3cret')
expect(JSON.stringify(localStorage)).not.toContain('s3cret')
})
})

View File

@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest'
import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from 'relay-e2e'
import { randomBytes } from 'relay-e2e'
import {
mountPreviewClient,
type PreviewDeps,
type ReadonlyTerminalLike,
type ReplaySource,
} from '../src/preview-client'
const HOST_SECRET = randomBytes(32)
const SESSION_ID = 'sess-1'
const ALG = 'aes-256-gcm' as const
/** Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. */
function sealedReplay(lines: readonly string[], secret = HOST_SECRET): ReplaySource {
const k = deriveContentKey({ hostContentSecret: secret, sessionId: SESSION_ID, alg: ALG })
const frames = lines.map((line, i) =>
// sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens.
// openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec.
encodeReplayFrame(sealReplayFrame(k, BigInt(i), new TextEncoder().encode(line))),
)
return { sessionId: SESSION_ID, alg: ALG, frames }
}
// The replay codec: relay-e2e's openReplayCiphertext expects the encoded envelope bytes.
import { encodeEnvelope } from 'relay-e2e'
function encodeReplayFrame(env: ReturnType<typeof sealReplayFrame>): Uint8Array {
return encodeEnvelope(env)
}
const realDeps: PreviewDeps = { deriveContentKey, openReplayCiphertext }
function mockTerminal(): ReadonlyTerminalLike & { written: string[] } {
const written: string[] = []
return { written, open: () => {}, write: (d) => written.push(d), dispose: () => {} }
}
describe('mountPreviewClient (T9)', () => {
it('recoverable-key decrypt: sealReplayFrame frames render into the read-only xterm (FIX 3)', async () => {
const card = document.createElement('div')
const term = mockTerminal()
const client = mountPreviewClient(
card,
sealedReplay(['line-A', 'line-B']),
HOST_SECRET,
{ cols: 80, rows: 24 },
{ ...realDeps, createTerminal: () => term },
)
await client.render()
expect(term.written).toEqual(['line-A', 'line-B'])
})
it('wrong/host-mismatched secret → openReplayCiphertext throws → "unavailable", never garbled', async () => {
const card = document.createElement('div')
const term = mockTerminal()
const replay = sealedReplay(['secret-screen']) // sealed under HOST_SECRET
const client = mountPreviewClient(
card,
replay,
randomBytes(32), // a DIFFERENT host secret → derives a different K_content
{ cols: 80, rows: 24 },
{ ...realDeps, createTerminal: () => term },
)
await client.render()
expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable')
expect(term.written).toEqual([]) // never wrote a torn screen
})
it('read-only: the preview terminal exposes NO input path (no onData/send)', async () => {
const card = document.createElement('div')
const term = mockTerminal()
mountPreviewClient(
card,
sealedReplay(['x']),
HOST_SECRET,
{ cols: 80, rows: 24 },
{ ...realDeps, createTerminal: () => term },
).render()
expect('onData' in term).toBe(false)
expect('send' in term).toBe(false)
})
it('never writes hostContentSecret to localStorage/console', async () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
const card = document.createElement('div')
await mountPreviewClient(
card,
sealedReplay(['x']),
HOST_SECRET,
{ cols: 80, rows: 24 },
{ ...realDeps, createTerminal: () => mockTerminal() },
).render()
expect(spy).not.toHaveBeenCalled()
expect(JSON.stringify(localStorage)).not.toContain(
Array.from(HOST_SECRET.slice(0, 4)).join(','),
)
spy.mockRestore()
})
})

View File

@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
import {
deriveContentKey,
encodeEnvelope,
openReplayCiphertext,
randomBytes,
sealReplayFrame,
} from 'relay-e2e'
import { mountPreviewGrid } from '../src/preview-grid'
import type { PreviewDeps, ReplaySource } from '../src/preview-client'
import type { ApiClient } from '../src/api-client'
import type { HostRecord } from '../src/api-schemas'
const ALG = 'aes-256-gcm' as const
function host(sub: string): HostRecord {
return {
hostId: `id-${sub}`,
accountId: 'acc',
subdomain: sub,
agentPubkey: new Uint8Array([1]),
enrollFpr: `fpr-${sub}`,
status: 'online',
lastSeen: '2026-01-01T00:00:00Z',
createdAt: '2026-01-01T00:00:00Z',
revokedAt: null,
}
}
function sealed(sessionId: string, secret: Uint8Array, line: string): ReplaySource {
const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG })
return { sessionId, alg: ALG, frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))] }
}
function fakeApi(hosts: readonly HostRecord[]): ApiClient {
return {
listHosts: vi.fn(async () => hosts),
getHost: vi.fn(),
requestPairingCode: vi.fn(),
hostStatus: vi.fn(),
issueCapabilityToken: vi.fn(),
} as unknown as ApiClient
}
const realDeps: PreviewDeps = {
deriveContentKey,
openReplayCiphertext,
createTerminal: () => ({ open: () => {}, write: () => {}, dispose: () => {} }),
}
const flush = () => new Promise((r) => setTimeout(r, 0))
describe('mountPreviewGrid (T9)', () => {
it('renders one preview card per authorized host', async () => {
const root = document.createElement('div')
const secret = randomBytes(32)
const loadReplay = vi.fn(async (hostId: string) => ({
replay: sealed(hostId, secret, 'screen'),
hostContentSecret: secret,
}))
const grid = mountPreviewGrid(root, fakeApi([host('a'), host('b')]), loadReplay, realDeps)
await flush()
await flush()
expect(root.querySelectorAll('.preview-card')).toHaveLength(2)
grid.dispose()
})
it('a host the account can\'t reach (loadReplay rejects) → "unavailable", never a foreign screen (INV1)', async () => {
const root = document.createElement('div')
const loadReplay = vi.fn(async () => {
throw new Error('forbidden')
})
const grid = mountPreviewGrid(root, fakeApi([host('a')]), loadReplay, realDeps)
await flush()
await flush()
expect(root.querySelector('.preview-unavailable')?.textContent).toBe('unavailable')
grid.dispose()
})
it('a listHosts failure renders a grid-level error, not a fabricated screen', async () => {
const root = document.createElement('div')
const api = {
listHosts: vi.fn(async () => {
throw new Error('unauth')
}),
} as unknown as ApiClient
const grid = mountPreviewGrid(root, api, vi.fn(), realDeps)
await flush()
expect(root.querySelector('.preview-grid-error')?.textContent).toBe('Could not load previews.')
grid.dispose()
})
it('dispose() tears down every preview client (no leaked xterms)', async () => {
const root = document.createElement('div')
const secret = randomBytes(32)
const disposed: string[] = []
const deps: PreviewDeps = {
deriveContentKey,
openReplayCiphertext,
createTerminal: () => ({ open: () => {}, write: () => {}, dispose: () => disposed.push('x') }),
}
const loadReplay = vi.fn(async (hostId: string) => ({
replay: sealed(hostId, secret, 'screen'),
hostContentSecret: secret,
}))
const grid = mountPreviewGrid(root, fakeApi([host('a')]), loadReplay, deps)
await flush()
await flush()
grid.dispose()
expect(disposed.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest'
import { mountTerminalView, type TerminalLike } from '../src/terminal-view'
import { decodeServerMessage, type ClientMessage } from '../src/protocol'
import type { TerminalTransport } from '../src/ws-transport'
const decoder = new TextDecoder()
function decodeClient(bytes: Uint8Array): ClientMessage {
return JSON.parse(decoder.decode(bytes)) as ClientMessage
}
/** In-memory transport capturing sent frames and exposing a way to push incoming bytes. */
function fakeTransport(): TerminalTransport & { sent: ClientMessage[]; push(m: object): void; closed: boolean } {
let msgCb: ((b: Uint8Array) => void) | null = null
const sent: ClientMessage[] = []
return {
sent,
closed: false,
open: async () => {},
send: (bytes) => sent.push(decodeClient(bytes)),
onMessage: (cb) => {
msgCb = cb
},
onClose: () => {},
close() {
this.closed = true
},
push(m: object) {
msgCb?.(new TextEncoder().encode(JSON.stringify(m)))
},
}
}
/** Mock xterm; `fitCalls` records fit() invocations, `dataCb` fires simulated keystrokes. */
function fakeTerminal(): TerminalLike & {
written: string[]
fitCalls: number
typeInto(data: string): void
} {
let onDataCb: ((d: string) => void) | null = null
return {
cols: 80,
rows: 24,
written: [],
fitCalls: 0,
open: () => {},
write(data: string) {
this.written.push(data)
},
onData: (cb) => {
onDataCb = cb
},
fit() {
this.fitCalls++
},
dispose: () => {},
typeInto(data: string) {
onDataCb?.(data)
},
}
}
function realDims(el: HTMLElement): void {
Object.defineProperty(el, 'clientWidth', { value: 640, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: 480, configurable: true })
}
describe('mountTerminalView (T4)', () => {
it('sends attach FIRST, then a resize frame once the container has real dimensions', () => {
const root = document.createElement('div')
realDims(root)
const transport = fakeTransport()
const term = fakeTerminal()
mountTerminalView(root, transport, { createTerminal: () => term })
expect(transport.sent[0]).toEqual({ type: 'attach', sessionId: null })
const resize = transport.sent.find((m) => m.type === 'resize')
expect(resize).toEqual({ type: 'resize', cols: 80, rows: 24 })
expect(term.fitCalls).toBeGreaterThan(0)
})
it('keypress → input frame; Enter carries "\\r" verbatim (not "\\n")', () => {
const root = document.createElement('div')
realDims(root)
const transport = fakeTransport()
const term = fakeTerminal()
mountTerminalView(root, transport, { createTerminal: () => term })
term.typeInto('\r')
const input = transport.sent.find((m) => m.type === 'input')
expect(input).toEqual({ type: 'input', data: '\r' })
expect(JSON.stringify(input)).not.toContain('\\n')
})
it('incoming output frame → xterm.write; attached/exit are not written as output', () => {
const root = document.createElement('div')
realDims(root)
const transport = fakeTransport()
const term = fakeTerminal()
mountTerminalView(root, transport, { createTerminal: () => term })
transport.push({ type: 'output', data: 'hello' })
transport.push({ type: 'attached', sessionId: 'x' })
expect(term.written).toEqual(['hello'])
})
it('does NOT call fit() while the container is display:none (guards NaN dims)', () => {
const root = document.createElement('div') // clientWidth/Height default to 0 in jsdom
const transport = fakeTransport()
const term = fakeTerminal()
mountTerminalView(root, transport, { createTerminal: () => term })
expect(term.fitCalls).toBe(0)
expect(transport.sent.some((m) => m.type === 'resize')).toBe(false)
})
it('the same seam mounts identically over any TerminalTransport (drop-in proof)', () => {
const root = document.createElement('div')
realDims(root)
const transport = fakeTransport()
const view = mountTerminalView(root, transport, { createTerminal: fakeTerminal })
view.dispose()
expect(transport.closed).toBe(true)
})
it('server messages decode via the shared codec (round-trip sanity)', () => {
const bytes = new TextEncoder().encode(JSON.stringify({ type: 'output', data: 'z' }))
expect(decodeServerMessage(bytes)).toEqual({ type: 'output', data: 'z' })
expect(decodeServerMessage(new TextEncoder().encode('not json'))).toBeNull()
})
})

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import { encodeBase64UrlBytes } from 'relay-contracts'
import {
base64UrlToBuffer,
bufferToBase64Url,
createWebAuthnClient,
WebAuthnError,
} from '../src/webauthn'
function ab(bytes: number[]): ArrayBuffer {
return new Uint8Array(bytes).buffer
}
const assertionCred = {
id: 'cred-1',
type: 'public-key',
rawId: ab([1, 2, 3]),
response: {
clientDataJSON: ab([4, 5]),
authenticatorData: ab([6]),
signature: ab([7, 8]),
userHandle: null,
},
}
describe('webauthn wrapper (T7)', () => {
it('base64url ↔ ArrayBuffer round-trip is lossless', () => {
const bytes = [0, 1, 250, 255, 128, 64]
const b64u = bufferToBase64Url(ab(bytes))
expect(new Uint8Array(base64UrlToBuffer(b64u))).toEqual(new Uint8Array(bytes))
})
it('authenticate maps the assertion to base64url fields', async () => {
const container = {
get: vi.fn(async () => assertionCred),
create: vi.fn(),
} as unknown as CredentialsContainer
const wa = createWebAuthnClient(container)
const res = await wa.authenticate({ challenge: ab([9]) } as PublicKeyCredentialRequestOptions)
expect(res.rawId).toBe(encodeBase64UrlBytes(new Uint8Array([1, 2, 3])))
expect(res.response.signature).toBe(encodeBase64UrlBytes(new Uint8Array([7, 8])))
expect(res.response.userHandle).toBeNull()
})
it('a user-cancelled prompt (NotAllowedError) maps to WebAuthnError("cancelled")', async () => {
const container = {
get: vi.fn(async () => {
throw Object.assign(new Error('dismissed'), { name: 'NotAllowedError' })
}),
create: vi.fn(),
} as unknown as CredentialsContainer
const wa = createWebAuthnClient(container)
await expect(
wa.authenticate({ challenge: ab([1]) } as PublicKeyCredentialRequestOptions),
).rejects.toMatchObject({ name: 'WebAuthnError', kind: 'cancelled' })
})
it('throws WebAuthnError("unsupported") when no credentials container is available', () => {
expect(() => createWebAuthnClient(undefined)).toThrow(WebAuthnError)
})
it('does not leak credential material to console', async () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
const container = {
get: vi.fn(async () => assertionCred),
create: vi.fn(),
} as unknown as CredentialsContainer
await createWebAuthnClient(container).authenticate({
challenge: ab([1]),
} as PublicKeyCredentialRequestOptions)
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
})

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
import { readConfig } from '../src/config'
import { createPassthroughTransport, type WebSocketLike } from '../src/ws-transport'
const cfg = readConfig({
protocol: 'https:',
host: 'alice.term.example.com',
hostname: 'alice.term.example.com',
})
/** Controllable mock WebSocket capturing constructor args and driving lifecycle events. */
class MockWs implements WebSocketLike {
static last: MockWs | null = null
binaryType = ''
protocol = ''
readonly url: string
readonly protocols: readonly string[]
readonly sent: Array<ArrayBufferView | ArrayBufferLike | string> = []
closed: { code?: number; reason?: string } | null = null
onopen: ((ev: unknown) => void) | null = null
onmessage: ((ev: { data: unknown }) => void) | null = null
onclose: ((ev: { code?: number; reason?: string }) => void) | null = null
onerror: ((ev: unknown) => void) | null = null
constructor(url: string, protocols?: string | readonly string[]) {
this.url = url
this.protocols = protocols === undefined ? [] : typeof protocols === 'string' ? [protocols] : protocols
MockWs.last = this
}
send(data: ArrayBufferView | ArrayBufferLike | string): void {
this.sent.push(data)
}
close(code?: number, reason?: string): void {
const ev: { code?: number; reason?: string } = {}
if (code !== undefined) ev.code = code
if (reason !== undefined) ev.reason = reason
this.closed = ev
this.onclose?.(ev)
}
fireOpen(echoedProtocol: string): void {
this.protocol = echoedProtocol
this.onopen?.({})
}
fireMessage(data: unknown): void {
this.onmessage?.({ data })
}
}
describe('createPassthroughTransport (T4)', () => {
it('v0.8: opens same-origin wss with NO query-string token (cookie-only auth)', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
const opened = t.open()
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
await opened
expect(MockWs.last!.url).toBe('wss://alice.term.example.com/term')
expect(MockWs.last!.url).not.toMatch(/token|\?/)
expect(MockWs.last!.protocols).toEqual([APP_SUBPROTOCOL]) // no token entry in v0.8
expect(MockWs.last!.binaryType).toBe('arraybuffer')
})
it('round-trips bytes: send() reaches the socket, onMessage() delivers incoming bytes', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
const received: Uint8Array[] = []
t.onMessage((b) => received.push(b))
const opened = t.open()
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
await opened
t.send(new Uint8Array([9, 8, 7]))
expect(MockWs.last!.sent[0]).toEqual(new Uint8Array([9, 8, 7]))
MockWs.last!.fireMessage(new Uint8Array([1, 2]).buffer)
expect(received[0]).toEqual(new Uint8Array([1, 2]))
})
it('v0.9+ token attachment: token rides Sec-WebSocket-Protocol, NEVER the URL (§4.3)', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: MockWs, capabilityToken: 'RAWTOK' })
const opened = t.open()
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
await opened
expect(MockWs.last!.protocols).toEqual([APP_SUBPROTOCOL, encodeTokenSubprotocol('RAWTOK')])
expect(MockWs.last!.url).not.toContain('RAWTOK') // never in the URL/query
expect(MockWs.last!.url).not.toContain(encodeTokenSubprotocol('RAWTOK'))
})
it('v0.9+ echo rule: a token echo (or foreign subprotocol) tears the socket down', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: MockWs, capabilityToken: 'RAWTOK' })
const opened = t.open()
MockWs.last!.fireOpen(encodeTokenSubprotocol('RAWTOK')) // relay wrongly echoes the token entry
await expect(opened).rejects.toThrow()
expect(MockWs.last!.closed).not.toBeNull()
})
it('server close reason surfaces via onClose (no silent swallow)', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
let reason = ''
t.onClose((r) => (reason = r))
const opened = t.open()
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
await opened
MockWs.last!.close(1000, 'shell exited')
expect(reason).toBe('shell exited')
})
it('a socket error during open rejects the open() promise', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
const opened = t.open()
MockWs.last!.onerror?.({})
await expect(opened).rejects.toThrow('websocket error')
})
it('close() before open() is a safe no-op (no throw)', () => {
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
expect(() => t.close()).not.toThrow()
})
it('a 4403 upgrade denial maps to onClose("forbidden") (INV1/INV6 client mirror)', async () => {
const t = createPassthroughTransport(cfg, { wsCtor: MockWs })
let reason = ''
t.onClose((r) => (reason = r))
const opened = t.open()
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
await opened
MockWs.last!.close(4403, '')
expect(reason).toBe('forbidden')
})
})