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,118 @@
/**
* T14 — immutable, ZERO-PAYLOAD audit writer (INV10). Single funnel that P5 also imports for
* attach/manage/kill/revoke events. Append-only: no update/delete path is exposed. A payload
* guard rejects any `meta` value that could smuggle terminal output (length cap + control/ANSI
* bytes), so keystrokes/output/secrets can never land in `audit_log`.
*/
import { z } from 'zod'
import type { AuditStore, AuditRow } from '../store/ports.js'
export type AuditAction =
| 'account.create'
| 'account.suspend'
| 'host.bind'
| 'host.revoke'
| 'pairing.issue'
| 'pairing.redeem'
| 'subdomain.assign'
| 'node.drain'
| 'attach'
| 'manage'
| 'kill'
| 'revoke'
export interface AuditEntry {
readonly action: AuditAction
readonly principalId: string
readonly accountId: string
readonly hostId: string | null
readonly ts: string
readonly meta: Readonly<Record<string, string>>
}
/** Max length of a single metadata value — small enough that no terminal frame fits (INV10). */
export const MAX_META_VALUE_LEN = 256
/** Max number of metadata keys per entry. */
export const MAX_META_KEYS = 16
const AuditActionSchema = z.enum([
'account.create',
'account.suspend',
'host.bind',
'host.revoke',
'pairing.issue',
'pairing.redeem',
'subdomain.assign',
'node.drain',
'attach',
'manage',
'kill',
'revoke',
])
/** True if the string contains any C0 control byte (0x000x1f) — incl. ESC that begins ANSI. */
function hasControlBytes(v: string): boolean {
for (let i = 0; i < v.length; i++) {
if (v.charCodeAt(i) < 0x20) return true
}
return false
}
const MetaValue = z
.string()
.max(MAX_META_VALUE_LEN, 'meta value exceeds zero-payload cap')
.refine((v) => !hasControlBytes(v), 'meta value contains control/ANSI bytes (possible terminal payload)')
const AuditEntrySchema = z
.object({
action: AuditActionSchema,
principalId: z.string().min(1),
accountId: z.string().min(1),
hostId: z.string().nullable(),
ts: z.string().datetime({ offset: true }),
meta: z.record(z.string(), MetaValue).refine((m) => Object.keys(m).length <= MAX_META_KEYS, {
message: 'too many meta keys',
}),
})
.strict()
export interface AuditWriter {
writeAuditEvent(entry: AuditEntry): Promise<void>
queryAudit(accountId: string, from: string, to: string): Promise<readonly AuditEntry[]>
}
export function createAuditLog(store: AuditStore): AuditWriter {
return {
async writeAuditEvent(entry) {
const parsed = AuditEntrySchema.safeParse(entry)
if (!parsed.success) {
throw new Error(`audit payload guard rejected entry: ${parsed.error.issues[0]?.message ?? 'invalid'}`)
}
const row: AuditRow = { ...parsed.data }
await store.append(row) // append-only, immutable
},
async queryAudit(accountId, from, to) {
const rows = await store.query(accountId, from, to)
return rows.map((r) => ({
action: r.action as AuditAction,
principalId: r.principalId,
accountId: r.accountId,
hostId: r.hostId,
ts: r.ts,
meta: r.meta,
}))
},
}
}
/** No-op writer for tasks that treat audit as an optional injected dependency in v0.9. */
export function noopAuditWriter(): AuditWriter {
return {
async writeAuditEvent() {
/* intentionally does nothing */
},
async queryAudit() {
return []
},
}
}