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.
118 lines
4.5 KiB
TypeScript
118 lines
4.5 KiB
TypeScript
/**
|
|
* 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
|
|
},
|
|
}
|
|
}
|