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,320 @@
/**
* In-memory adapter for the repository ports — the tested + v0.9-MVP-shipped default.
*
* INV8: lifecycle/business fields (account.status, host.status/revoked_at) are versioned via
* append-only companion rows + an atomic single-row pointer swap. Each `swapStatus` mutation is
* a synchronous critical section (no `await` inside) so a concurrent reader sees whole-old or
* whole-new — never a torn mix. Prior snapshots stay readable from the `versions()` list.
* High-frequency liveness (`last_seen`, route TTL) is advisory and NOT versioned (INV7).
*/
import type {
AccountRecord,
AccountStatus,
AccountStatusVersionRow,
HostRecord,
HostStatus,
HostStatusVersionRow,
MeteringSampleRow,
PairingCodeRecord,
RouteEntry,
SessionRecord,
} from '../model/records.js'
import type {
AccountStore,
AuditRow,
AuditStore,
CasOutcome,
HostStore,
MeteringStore,
NodeRow,
NodeStatus,
NodeStore,
PairingRow,
PairingStore,
RouteStore,
SessionStore,
Stores,
SubdomainStore,
} from './ports.js'
interface AccountCell {
record: AccountRecord
version: number
versions: AccountStatusVersionRow[]
}
interface HostCell {
record: HostRecord
version: number
versions: HostStatusVersionRow[]
}
function memAccountStore(): AccountStore {
const cells = new Map<string, AccountCell>()
return {
async insert(rec) {
if (cells.has(rec.accountId)) throw new Error('duplicate accountId')
cells.set(rec.accountId, {
record: rec,
version: 1,
versions: [
{ accountId: rec.accountId, version: 1, status: rec.status, changedAt: rec.createdAt, changedBy: 'system' },
],
})
},
async get(id) {
return cells.get(id)?.record ?? null
},
async swapStatus(id, status, changedBy) {
const cell = cells.get(id)
if (cell === undefined) throw new Error('account not found')
// --- atomic critical section (no await) ---
const nextVersion = cell.version + 1
const changedAt = new Date().toISOString()
const nextRecord: AccountRecord = { ...cell.record, status }
cell.versions.push({ accountId: id, version: nextVersion, status, changedAt, changedBy })
cell.record = nextRecord
cell.version = nextVersion
// --- end critical section ---
return nextRecord
},
async versions(id) {
return (cells.get(id)?.versions ?? []).slice()
},
}
}
function memHostStore(): HostStore {
const cells = new Map<string, HostCell>()
const bySub = new Map<string, string>()
return {
async insert(rec) {
if (cells.has(rec.hostId)) throw new Error('duplicate hostId')
if (bySub.has(rec.subdomain)) throw new Error('duplicate subdomain')
cells.set(rec.hostId, {
record: rec,
version: 1,
versions: [
{
hostId: rec.hostId,
version: 1,
status: rec.status,
revokedAt: rec.revokedAt,
changedAt: rec.createdAt,
changedBy: 'system',
},
],
})
bySub.set(rec.subdomain, rec.hostId)
},
async get(id) {
return cells.get(id)?.record ?? null
},
async getBySubdomain(sub) {
const id = bySub.get(sub)
return id === undefined ? null : (cells.get(id)?.record ?? null)
},
async listByAccount(accountId) {
return [...cells.values()].filter((c) => c.record.accountId === accountId).map((c) => c.record)
},
async swapStatus(id, status, revokedAt, changedBy) {
const cell = cells.get(id)
if (cell === undefined) throw new Error('host not found')
const nextVersion = cell.version + 1
const changedAt = new Date().toISOString()
const nextRecord: HostRecord = { ...cell.record, status, revokedAt }
cell.versions.push({ hostId: id, version: nextVersion, status, revokedAt, changedAt, changedBy })
cell.record = nextRecord
cell.version = nextVersion
return nextRecord
},
async touchLastSeen(id, ts) {
const cell = cells.get(id)
if (cell === undefined) return
// Advisory cache — overwrite in place, NO version row (INV7).
cell.record = { ...cell.record, lastSeen: ts }
},
async versions(id) {
return (cells.get(id)?.versions ?? []).slice()
},
}
}
function memSessionStore(): SessionStore {
const rows = new Map<string, SessionRecord>()
return {
async insert(rec) {
if (rows.has(rec.sessionId)) throw new Error('duplicate sessionId')
rows.set(rec.sessionId, rec)
},
async get(id) {
return rows.get(id) ?? null
},
}
}
function memSubdomainStore(hosts: HostStore): SubdomainStore {
const reserved = new Set<string>()
return {
async reserve(sub) {
// Synchronous claim FIRST (no await before add) so two concurrent callers can't both win.
if (reserved.has(sub)) return false
reserved.add(sub)
if ((await hosts.getBySubdomain(sub)) !== null) {
reserved.delete(sub) // an already-bound host owns this label — release the optimistic claim
return false
}
return true
},
async isTaken(sub) {
return reserved.has(sub) || (await hosts.getBySubdomain(sub)) !== null
},
}
}
interface PairingCell {
record: PairingCodeRecord
attempts: number
}
function memPairingStore(): PairingStore {
const cells = new Map<string, PairingCell>()
return {
async insert(rec) {
if (cells.has(rec.codeHash)) throw new Error('duplicate code_hash')
cells.set(rec.codeHash, { record: rec, attempts: 0 })
},
async get(codeHash): Promise<PairingRow | null> {
const cell = cells.get(codeHash)
return cell === undefined ? null : { record: cell.record, redeemAttempts: cell.attempts }
},
async casRedeem(codeHash, nowIso): Promise<CasOutcome> {
const cell = cells.get(codeHash)
if (cell === undefined) return 'unknown'
// --- atomic critical section: CAS redeemedAt from null ---
if (cell.record.redeemedAt !== null) return 'already_redeemed'
cell.record = { ...cell.record, redeemedAt: nowIso }
return 'ok'
// --- end critical section ---
},
async registerFailure(codeHash) {
const cell = cells.get(codeHash)
if (cell === undefined) return 0
cell.attempts += 1
return cell.attempts
},
}
}
interface RouteCell {
entry: RouteEntry
expiresAtMs: number
}
function memRouteStore(): RouteStore {
const cells = new Map<string, RouteCell>()
const live = (id: string): RouteCell | null => {
const c = cells.get(id)
if (c === undefined) return null
if (c.expiresAtMs <= Date.now()) {
cells.delete(id) // fail closed: expired ⇒ offline (INV7)
return null
}
return c
}
return {
async set(hostId, entry, ttlSec) {
cells.set(hostId, { entry, expiresAtMs: Date.now() + ttlSec * 1000 })
},
async refreshTtl(hostId, ttlSec) {
const c = live(hostId)
if (c === null) return false
c.expiresAtMs = Date.now() + ttlSec * 1000
return true
},
async get(hostId) {
return live(hostId)?.entry ?? null
},
async delete(hostId) {
cells.delete(hostId)
},
async listByNode(nodeId) {
const out: { hostId: string; entry: RouteEntry }[] = []
for (const hostId of [...cells.keys()]) {
const c = live(hostId)
if (c !== null && c.entry.relayNodeId === nodeId) out.push({ hostId, entry: c.entry })
}
return out
},
}
}
function memNodeStore(): NodeStore {
const rows = new Map<string, NodeRow>()
return {
async upsert(row) {
rows.set(row.nodeId, row)
},
async get(id) {
return rows.get(id) ?? null
},
async setStatus(id, status: NodeStatus) {
const r = rows.get(id)
if (r === undefined) throw new Error('node not found')
rows.set(id, { ...r, status })
},
async touch(id, ts) {
const r = rows.get(id)
if (r !== undefined) rows.set(id, { ...r, lastSeen: ts })
},
async delete(id) {
rows.delete(id)
},
}
}
function memMeteringStore(): MeteringStore {
const rows: MeteringSampleRow[] = []
return {
async append(row) {
rows.push(row) // append-only
},
async query(accountId, fromIso, toIso) {
const from = Date.parse(fromIso)
const to = Date.parse(toIso)
return rows.filter(
(r) => r.accountId === accountId && Date.parse(r.sampledAt) >= from && Date.parse(r.sampledAt) <= to,
)
},
}
}
function memAuditStore(): AuditStore {
const rows: AuditRow[] = []
return {
async append(row) {
rows.push(row) // append-only, no update/delete exposed (INV10)
},
async query(accountId, fromIso, toIso) {
const from = Date.parse(fromIso)
const to = Date.parse(toIso)
return rows.filter(
(r) => r.accountId === accountId && Date.parse(r.ts) >= from && Date.parse(r.ts) <= to,
)
},
}
}
/** Build a full in-memory store set (all ports wired). */
export function createMemoryStores(): Stores {
const hosts = memHostStore()
return {
accounts: memAccountStore(),
hosts,
sessions: memSessionStore(),
subdomains: memSubdomainStore(hosts),
pairing: memPairingStore(),
routes: memRouteStore(),
nodes: memNodeStore(),
metering: memMeteringStore(),
audit: memAuditStore(),
}
}

View File

@@ -0,0 +1,142 @@
/**
* Repository ports (Repository Pattern, patterns.md). Registries/services depend on these
* abstractions, NOT on a concrete storage engine. Two adapters implement them:
* - `store/memory.ts` — in-memory, the tested + v0.9-MVP-shipped default. Immutable/versioned
* discipline (INV8) is enforced here in synchronous critical sections (JS is single-threaded,
* so a mutation with no `await` inside is atomic — no torn read).
* - `store/pg.ts` — parameterized Postgres SQL over `db/pool.ts` (integration seam, typechecked).
*
* The INV8 versioning logic lives in the store so both adapters agree on the contract; the
* registries orchestrate and add authz/ownership predicates.
*/
import type {
AccountRecord,
AccountStatus,
AccountStatusVersionRow,
HostRecord,
HostStatus,
HostStatusVersionRow,
MeteringSampleRow,
PairingCodeRecord,
RouteEntry,
SessionRecord,
} from '../model/records.js'
export interface AccountStore {
insert(rec: AccountRecord): Promise<void>
get(accountId: string): Promise<AccountRecord | null>
/** Atomic: append a status version then swap the single-row pointer. Returns the NEW record. */
swapStatus(accountId: string, status: AccountStatus, changedBy: string): Promise<AccountRecord>
versions(accountId: string): Promise<readonly AccountStatusVersionRow[]>
}
export interface HostStore {
insert(rec: HostRecord): Promise<void>
get(hostId: string): Promise<HostRecord | null>
getBySubdomain(subdomain: string): Promise<HostRecord | null>
listByAccount(accountId: string): Promise<readonly HostRecord[]>
/** Atomic status version + pointer swap. `revokedAt` set only on 'revoked'. Returns NEW record. */
swapStatus(
hostId: string,
status: HostStatus,
revokedAt: string | null,
changedBy: string,
): Promise<HostRecord>
/** Advisory liveness cache update — NOT versioned (INV7; live truth is Redis). */
touchLastSeen(hostId: string, ts: string): Promise<void>
versions(hostId: string): Promise<readonly HostStatusVersionRow[]>
}
export interface SessionStore {
insert(rec: SessionRecord): Promise<void>
get(sessionId: string): Promise<SessionRecord | null>
}
/** Reservation of a subdomain label; atomic single-winner under concurrency (INV1). */
export interface SubdomainStore {
reserve(subdomain: string): Promise<boolean> // false ⇒ already taken
isTaken(subdomain: string): Promise<boolean>
}
export interface PairingRow {
readonly record: PairingCodeRecord
readonly redeemAttempts: number
}
export type RedeemOutcome =
| 'ok'
| 'already_redeemed'
| 'expired'
| 'unknown'
| 'too_many_attempts'
| 'bad_csr'
export type CasOutcome = 'ok' | 'already_redeemed' | 'unknown'
export interface PairingStore {
insert(rec: PairingCodeRecord): Promise<void>
get(codeHash: string): Promise<PairingRow | null>
/**
* Atomic compare-and-set of `redeemedAt` from null (double-spend guard). Exactly one
* concurrent caller wins 'ok'; the loser gets 'already_redeemed'. Lockout/expiry are
* pre-checked by the registry from `get()`.
*/
casRedeem(codeHash: string, nowIso: string): Promise<CasOutcome>
/** Atomic failed-attempt increment for a known code_hash; returns the new attempt count. */
registerFailure(codeHash: string): Promise<number>
}
/** Redis-like routing table with per-key heartbeat TTL (INV7). */
export interface RouteStore {
set(hostId: string, entry: RouteEntry, ttlSec: number): Promise<void>
refreshTtl(hostId: string, ttlSec: number): Promise<boolean> // false if key already expired/absent
get(hostId: string): Promise<RouteEntry | null>
delete(hostId: string): Promise<void>
/** Live routes whose entry targets `nodeId` (used by drain enumeration). */
listByNode(nodeId: string): Promise<readonly { hostId: string; entry: RouteEntry }[]>
}
export type NodeStatus = 'active' | 'draining'
export interface NodeRow {
readonly nodeId: string
readonly addr: string
readonly status: NodeStatus
readonly lastSeen: string
}
export interface NodeStore {
upsert(row: NodeRow): Promise<void>
get(nodeId: string): Promise<NodeRow | null>
setStatus(nodeId: string, status: NodeStatus): Promise<void>
touch(nodeId: string, ts: string): Promise<void>
delete(nodeId: string): Promise<void>
}
export interface MeteringStore {
append(row: MeteringSampleRow): Promise<void> // append-only (INV8)
query(accountId: string, fromIso: string, toIso: string): Promise<readonly MeteringSampleRow[]>
}
export interface AuditRow {
readonly action: string
readonly principalId: string
readonly accountId: string
readonly hostId: string | null
readonly ts: string
readonly meta: Readonly<Record<string, string>>
}
export interface AuditStore {
append(row: AuditRow): Promise<void> // append-only, no update/delete path (INV10)
query(accountId: string, fromIso: string, toIso: string): Promise<readonly AuditRow[]>
}
export interface Stores {
readonly accounts: AccountStore
readonly hosts: HostStore
readonly sessions: SessionStore
readonly subdomains: SubdomainStore
readonly pairing: PairingStore
readonly routes: RouteStore
readonly nodes: NodeStore
readonly metering: MeteringStore
readonly audit: AuditStore
}