Files
web-terminal/term-relay/data-plane/revocation-subscriber.ts
Yaojia Wang 2af57e6686 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.
2026-07-02 06:10:16 +02:00

84 lines
3.2 KiB
TypeScript

/**
* T14 · Revocation subscriber (INV12 data-plane teardown owner, FIX 4). Subscribes to the frozen
* §4.2 `relay:revocations` bus and maps a `KillSignal` onto the EXISTING §4.1 wire (no new frame
* type) via T11's `drainHost(…, revoked)` — immediate teardown (grace forced to 0) within
* `REVOCATION_PUSH_BUDGET_MS`. P3/P5 PUBLISH; every P1 node SUBSCRIBES (P1 never imports P5 —
* the KillSignal shape lives in relay-contracts, keeping the DAG acyclic).
*
* Security: scope selection is blast-radius-bounded (host ⊂ account ⊂ global); a host this node
* doesn't serve is a no-op (no cross-tenant reach, INV1). `signal.reason` is metadata only, never
* written as terminal payload/log content (INV10). A malformed signal is dropped + counted — a
* poisoned message can neither kill the bus nor trigger a spurious teardown.
*/
import {
KillSignalSchema,
RELAY_REVOCATIONS_CHANNEL,
type KillSignal,
type RevocationScope,
} from 'relay-contracts'
import type { AgentTunnel } from './agent-listener.js'
import { DRAIN_REASON } from './drain.js'
export interface Subscription {
close(): void
}
export interface RevocationSubscriberDeps {
subscribe(channel: string, onMessage: (raw: string) => void): Subscription
drainHost(hostId: string, reason: typeof DRAIN_REASON.revoked): Promise<void>
tunnels(): ReadonlyMap<string, AgentTunnel>
now: () => number
onApplied?(signal: KillSignal, hostsAffected: number, elapsedMs: number): void
onDropped?(): void // malformed-signal counter (metadata only, INV10)
}
/**
* Pure scope→hosts selector: which of THIS node's live hosts a signal hits. `accountOf` maps a
* live hostId to its accountId (carried on the tunnel). Testable in isolation.
*/
export function hostsForScope(
scope: RevocationScope,
liveHosts: ReadonlyMap<string, AgentTunnel>,
accountOf: (hostId: string) => string | undefined,
): readonly string[] {
switch (scope.kind) {
case 'host':
return liveHosts.has(scope.hostId) ? [scope.hostId] : []
case 'account':
return [...liveHosts.keys()].filter((h) => accountOf(h) === scope.accountId)
case 'global':
return [...liveHosts.keys()]
}
}
function parseKillSignal(raw: string): KillSignal | null {
let json: unknown
try {
json = JSON.parse(raw)
} catch {
return null
}
const result = KillSignalSchema.safeParse(json)
return result.success ? result.data : null
}
export function startRevocationSubscriber(deps: RevocationSubscriberDeps): Subscription {
const accountOf = (hostId: string): string | undefined => deps.tunnels().get(hostId)?.accountId
const sub = deps.subscribe(RELAY_REVOCATIONS_CHANNEL, (raw) => {
const startedAt = deps.now()
const signal = parseKillSignal(raw)
if (signal === null) {
deps.onDropped?.() // dropped + counted; bus stays alive, no teardown fired
return
}
const hosts = hostsForScope(signal.scope, deps.tunnels(), accountOf)
// Fire immediate teardown (reason=revoked ⇒ grace forced to 0 in T11) for each in-scope host.
void Promise.all(hosts.map((hostId) => deps.drainHost(hostId, DRAIN_REASON.revoked))).then(() => {
deps.onApplied?.(signal, hosts.length, deps.now() - startedAt)
})
})
return { close: () => sub.close() }
}