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:
320
control-plane/src/store/memory.ts
Normal file
320
control-plane/src/store/memory.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
142
control-plane/src/store/ports.ts
Normal file
142
control-plane/src/store/ports.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user