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,77 @@
/**
* §4.2 Account / host data model — Zod schemas + inferred TS types.
* Postgres = ownership source of truth; Redis = live location (RouteEntry).
* Records are immutable (INV8): updates insert a new row / swap a snapshot.
*/
import { z } from 'zod'
/** account.status (string-literal union, §4.2). */
export type AccountStatus = 'active' | 'suspended'
/** account.plan tier (string-literal union, §4.2). */
export type PlanTier = 'free' | 'personal' | 'pro' | 'team'
/** host.status (string-literal union, §4.2). */
export type HostStatus = 'online' | 'offline' | 'draining' | 'revoked'
export const AccountStatusSchema = z.enum(['active', 'suspended'])
export const PlanTierSchema = z.enum(['free', 'personal', 'pro', 'team'])
export const HostStatusSchema = z.enum(['online', 'offline', 'draining', 'revoked'])
/** ISO-8601 timestamptz as a string (control-plane serialization boundary). */
const IsoTimestamp = z.string().datetime({ offset: true })
/** Unguessable UUID identifiers (INV1: host_id never recycled). */
const Uuid = z.string().uuid()
/** accounts row (§4.2). */
export const AccountRecordSchema = z
.object({
accountId: Uuid,
plan: PlanTierSchema,
createdAt: IsoTimestamp,
status: AccountStatusSchema,
})
.strict()
.readonly()
export type AccountRecord = z.infer<typeof AccountRecordSchema>
/**
* hosts row (§4.2). `agentPubkey` is the Ed25519 PUBLIC key only (INV4); the private
* key never leaves the host. `enrollFpr` is pinned by the browser for E2E TOFU (§4.4).
*/
export const HostRecordSchema = z
.object({
hostId: Uuid,
accountId: Uuid,
subdomain: z.string().min(1),
agentPubkey: z.instanceof(Uint8Array),
enrollFpr: z.string().min(1),
status: HostStatusSchema,
lastSeen: IsoTimestamp,
createdAt: IsoTimestamp,
revokedAt: IsoTimestamp.nullable(),
})
.strict()
.readonly()
export type HostRecord = z.infer<typeof HostRecordSchema>
/** sessions row (§4.2). `accountId` is DENORMALIZED for O(1) deny-by-default authz (INV3/INV6). */
export const SessionRecordSchema = z
.object({
sessionId: Uuid,
hostId: Uuid,
accountId: Uuid,
createdAt: IsoTimestamp,
lastAttachAt: IsoTimestamp,
})
.strict()
.readonly()
export type SessionRecord = z.infer<typeof SessionRecordSchema>
/** Redis `route:{host_id}` value — live routing table, NOT source of truth (INV7). */
export const RouteEntrySchema = z
.object({
relayNodeId: z.string().min(1),
updatedAt: IsoTimestamp,
})
.strict()
.readonly()
export type RouteEntry = z.infer<typeof RouteEntrySchema>

View File

@@ -0,0 +1,28 @@
/**
* §4.2 INV8 version-table DDL contract — immutable records + atomic snapshot swap.
*
* Mutable columns (hosts.status/last_seen, sessions.last_attach_at) are NEVER updated in
* place. A new version row is inserted into `host_versions` and the `hosts_current` pointer
* is CAS-swapped, so a concurrent read sees whole-old or whole-new — never a torn read.
*
* These DDL strings are the FROZEN contract P3 must apply verbatim (the migration owner is
* P3; relay-contracts owns the shape so every plan agrees on the version-chain semantics).
*/
/** Immutable per-host snapshot rows (append-only; `supersedes` forms an audit chain). */
export const HOST_VERSIONS_DDL = `CREATE TABLE IF NOT EXISTS host_versions (
version_id uuid PRIMARY KEY,
host_id uuid NOT NULL REFERENCES hosts(host_id),
snapshot jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
supersedes uuid NULL REFERENCES host_versions(version_id)
);` as const
/** CAS-swapped pointer to the live snapshot (atomic current-version indirection). */
export const HOSTS_CURRENT_DDL = `CREATE TABLE IF NOT EXISTS hosts_current (
host_id uuid PRIMARY KEY REFERENCES hosts(host_id),
version_id uuid NOT NULL REFERENCES host_versions(version_id)
);` as const
/** Ordered DDL statements for the INV8 version tables. */
export const INV8_VERSION_TABLE_DDL = [HOST_VERSIONS_DDL, HOSTS_CURRENT_DDL] as const

View File

@@ -0,0 +1,48 @@
/**
* §4.2 FIX 4 — control→data-plane revocation teardown shapes (INV12).
*
* Frozen HERE (not in relay-auth) so P1 relay nodes can SUBSCRIBE without importing P5,
* keeping the dependency DAG acyclic. P3/P5 PUBLISH a KillSignal on the one named bus;
* every P1 relay node subscribes and injects a §4.1 CLOSE+RST (host/account scope) or a
* connection-level GOAWAY (global scope) — no new frame type.
*/
import { z } from 'zod'
/** The single named control→data-plane Redis pub/sub channel (§4.2 FIX 4). */
export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const
/** Teardown budget: a live tunnel must drop within this many ms of revocation (INV12). */
export const REVOCATION_PUSH_BUDGET_MS = 2000 as const
/** What a KillSignal targets (§4.2). */
export type RevocationScope =
| { readonly kind: 'host'; readonly hostId: string }
| { readonly kind: 'account'; readonly accountId: string }
| { readonly kind: 'global' }
export const RevocationScopeSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('host'), hostId: z.string().uuid() }).strict(),
z.object({ kind: z.literal('account'), accountId: z.string().uuid() }).strict(),
z.object({ kind: z.literal('global') }).strict(),
])
/** Signal published on `relay:revocations` (§4.2). `reason` is metadata only, ZERO payload (INV10). */
export interface KillSignal {
readonly scope: RevocationScope
readonly at: number // epoch seconds the revocation was issued
readonly reason: string
}
export const KillSignalSchema = z
.object({
scope: RevocationScopeSchema,
at: z.number().int().nonnegative(),
reason: z.string(),
})
.strict()
.readonly()
/** Transport contract: P3/P1 implement the `relay:revocations` publish side. */
export interface RevocationBus {
publish(signal: KillSignal): Promise<void>
}