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,83 @@
/**
* Big-endian (network order) integer read/write helpers shared by every binary codec.
* Kept dependency-free (no Buffer) so the browser bundle (P6) imports it unchanged.
*/
import { ContractDecodeError, ContractEncodeError } from './errors.js'
export const U32_MAX = 0xffff_ffff
export const U64_MAX = 0xffff_ffff_ffff_ffffn
/** Assert a value is a non-negative integer that fits in uint32; throw otherwise. */
export function assertUint32(value: number, field: string): void {
if (!Number.isInteger(value) || value < 0 || value > U32_MAX) {
throw new ContractEncodeError(`${field} must be a uint32 (0..${U32_MAX}); got ${value}`)
}
}
/** Write a uint32 big-endian into `out` at `offset`. Returns the next offset. */
export function writeUint32BE(out: Uint8Array, offset: number, value: number): number {
assertUint32(value, 'uint32')
out[offset] = (value >>> 24) & 0xff
out[offset + 1] = (value >>> 16) & 0xff
out[offset + 2] = (value >>> 8) & 0xff
out[offset + 3] = value & 0xff
return offset + 4
}
/** Read a uint32 big-endian from `buf` at `offset`. */
export function readUint32BE(buf: Uint8Array, offset: number): number {
if (offset + 4 > buf.length) {
throw new ContractDecodeError(`truncated uint32 at offset ${offset} (len ${buf.length})`)
}
return (
((buf[offset]! << 24) >>> 0) +
(buf[offset + 1]! << 16) +
(buf[offset + 2]! << 8) +
buf[offset + 3]!
)
}
/** Write a uint64 big-endian (from a bigint) into `out` at `offset`. Returns next offset. */
export function writeUint64BE(out: Uint8Array, offset: number, value: bigint): number {
if (value < 0n || value > U64_MAX) {
throw new ContractEncodeError(`uint64 out of range: ${value}`)
}
for (let i = 7; i >= 0; i--) {
out[offset + i] = Number(value & 0xffn)
value >>= 8n
}
return offset + 8
}
/** Read a uint64 big-endian as a bigint from `buf` at `offset`. */
export function readUint64BE(buf: Uint8Array, offset: number): bigint {
if (offset + 8 > buf.length) {
throw new ContractDecodeError(`truncated uint64 at offset ${offset} (len ${buf.length})`)
}
let value = 0n
for (let i = 0; i < 8; i++) {
value = (value << 8n) | BigInt(buf[offset + i]!)
}
return value
}
/** Read a uint64 that must fit in a JS safe integer (§4.1 payloadLen safe-int guard). */
export function readSafeUint64BE(buf: Uint8Array, offset: number, field: string): number {
const value = readUint64BE(buf, offset)
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new ContractDecodeError(`${field} exceeds Number.MAX_SAFE_INTEGER: ${value}`)
}
return Number(value)
}
/** Concatenate byte chunks into one Uint8Array. */
export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
const total = chunks.reduce((n, c) => n + c.length, 0)
const out = new Uint8Array(total)
let offset = 0
for (const c of chunks) {
out.set(c, offset)
offset += c.length
}
return out
}