/** * T9 — Redis routing table (`route:{host_id}`, heartbeat-TTL, INV7). Redis holds only LOCATION, * never ownership or secrets; a missing/expired key fails CLOSED (host treated offline). Every * mutation is scoped to the AUTHENTICATED node identity: a node can only route hosts to ITSELF * (`entry.relayNodeId === caller.nodeId`) and can only heartbeat/drop a route it currently holds. */ import type { RouteEntry } from '../model/records.js' import type { NodeStatus, RouteStore } from '../store/ports.js' import type { NodeIdentity } from '../node-auth/identity.js' export class RouteAuthError extends Error {} export interface RoutingTable { upsertRoute(caller: NodeIdentity, hostId: string, entry: RouteEntry, ttlSec: number): Promise heartbeatRoute(caller: NodeIdentity, hostId: string, ttlSec: number): Promise resolveRoute(hostId: string): Promise dropRoute(caller: NodeIdentity, hostId: string): Promise /** System teardown path (drain/revoke) — bypasses the holding-node check by design. */ systemDropRoute(hostId: string): Promise } export interface RoutingTableDeps { readonly routes: RouteStore /** Optional node-status hook: a draining node stops receiving new upserts (T10). */ readonly nodeStatus?: (nodeId: string) => Promise } export function createRoutingTable(deps: RoutingTableDeps): RoutingTable { return { async upsertRoute(caller, hostId, entry, ttlSec) { // A node may ONLY route hosts to itself — never inject a route for another node's id. if (entry.relayNodeId !== caller.nodeId) { throw new RouteAuthError('entry.relayNodeId must equal the authenticated caller nodeId') } if (deps.nodeStatus !== undefined) { const status = await deps.nodeStatus(caller.nodeId) if (status === 'draining') throw new RouteAuthError('node is draining; not accepting new routes') } await deps.routes.set(hostId, entry, ttlSec) }, async heartbeatRoute(caller, hostId, ttlSec) { const current = await deps.routes.get(hostId) // A stale/foreign node cannot refresh (hijack) another node's live tunnel; missing ⇒ no-op. if (current === null || current.relayNodeId !== caller.nodeId) return await deps.routes.refreshTtl(hostId, ttlSec) }, async resolveRoute(hostId) { return deps.routes.get(hostId) // null ⇒ offline (fails closed, INV7) }, async dropRoute(caller, hostId) { const current = await deps.routes.get(hostId) if (current === null) return if (current.relayNodeId !== caller.nodeId) { throw new RouteAuthError('only the holding node may drop its route') } await deps.routes.delete(hostId) }, async systemDropRoute(hostId) { await deps.routes.delete(hostId) }, } }