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:
77
relay-contracts/src/model/account.ts
Normal file
77
relay-contracts/src/model/account.ts
Normal 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>
|
||||
28
relay-contracts/src/model/ddl.ts
Normal file
28
relay-contracts/src/model/ddl.ts
Normal 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
|
||||
48
relay-contracts/src/model/revocation.ts
Normal file
48
relay-contracts/src/model/revocation.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user