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:
111
relay-web/test/api-client.test.ts
Normal file
111
relay-web/test/api-client.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user