/** * T13 — revocation (global + per-host), INV12. The within-seconds tunnel-kill PUBLISHES a * KillSignal on the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4 — SAME channel T10 drain * uses); every P1 node subscribes and injects the §4.1 CLOSE+RST within REVOCATION_PUSH_BUDGET_MS. * KillSignal / RevocationScope / the channel + budget constants are IMPORTED from relay-contracts, * never redefined. `reason` is metadata only (INV10, zero payload). Revocation writes an immutable * host snapshot (INV8) and is idempotent. */ import { REVOCATION_PUSH_BUDGET_MS, type RevocationBus } from 'relay-contracts' import type { HostRegistry } from '../registry/hosts.js' import type { RoutingTable } from '../routing/table.js' import type { AuditWriter } from '../audit/log.js' import { noopAuditWriter } from '../audit/log.js' export { REVOCATION_PUSH_BUDGET_MS } /** Short-TTL token revocation store (Redis `revoked:{jti}` in prod). Shared key space with P5. */ export interface RevokedTokenStore { revoke(jti: string, ttlSec: number): Promise isRevoked(jti: string): Promise } /** In-memory implementation with TTL expiry (default; Redis is the production swap). */ export function createInMemoryRevokedTokenStore(): RevokedTokenStore { const until = new Map() return { async revoke(jti, ttlSec) { until.set(jti, Date.now() + Math.max(0, ttlSec) * 1000) }, async isRevoked(jti) { const exp = until.get(jti) if (exp === undefined) return false if (exp <= Date.now()) { until.delete(jti) return false } return true }, } } export interface Revoker { revokeHost(hostId: string): Promise revokeAccount(accountId: string): Promise revokeToken(jti: string, expUnix: number): Promise isTokenRevoked(jti: string): Promise } export interface RevokerDeps { readonly hosts: HostRegistry readonly routing: RoutingTable readonly bus: RevocationBus readonly tokens: RevokedTokenStore readonly audit?: AuditWriter readonly actor?: string } export function createRevoker(deps: RevokerDeps): Revoker { const audit = deps.audit ?? noopAuditWriter() const actor = deps.actor ?? 'system' const at = () => Math.floor(Date.now() / 1000) return { async revokeHost(hostId) { const host = await deps.hosts.getHost(hostId) if (host === null) return // idempotent: nothing to revoke if (host.status !== 'revoked') { await deps.hosts.setHostStatus(hostId, 'revoked') // immutable snapshot (INV8) + audit host.revoke } await deps.routing.systemDropRoute(hostId) // resolveRoute → null after this await deps.bus.publish({ scope: { kind: 'host', hostId }, at: at(), reason: 'revoked' }) await audit.writeAuditEvent({ action: 'revoke', principalId: actor, accountId: host.accountId, hostId, ts: new Date().toISOString(), meta: { scope: 'host' }, }) }, async revokeAccount(accountId) { const hosts = await deps.hosts.listHosts(accountId) for (const h of hosts) { if (h.status !== 'revoked') { // eslint-disable-next-line no-await-in-loop await deps.hosts.setHostStatus(h.hostId, 'revoked') } // eslint-disable-next-line no-await-in-loop await deps.routing.systemDropRoute(h.hostId) } // One account-scoped KillSignal; P1 tears down all of the account's live streams. await deps.bus.publish({ scope: { kind: 'account', accountId }, at: at(), reason: 'revoked' }) await audit.writeAuditEvent({ action: 'revoke', principalId: actor, accountId, hostId: null, ts: new Date().toISOString(), meta: { scope: 'account', hosts: String(hosts.length) }, }) }, async revokeToken(jti, expUnix) { const ttlSec = Math.max(0, expUnix - Math.floor(Date.now() / 1000)) await deps.tokens.revoke(jti, ttlSec) }, async isTokenRevoked(jti) { return deps.tokens.isRevoked(jti) }, } }