feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts

RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass):
- A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script.
- B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3).
- B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14).
- B3: store-backed RouteResolver (subdomain->hostId).
- B4: Redis relay:revocations subscriber -> tunnel teardown (INV12).
- B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint.
- B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol.
- C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps.
- D1: same-origin static serve of relay-web/public from the browser WSS.
- E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md.
Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on
browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2);
scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
This commit is contained in:
Yaojia Wang
2026-07-06 16:13:34 +02:00
parent 95b9cccf07
commit aa1912b962
40 changed files with 4783 additions and 19 deletions

View File

@@ -9,6 +9,7 @@
},
"scripts": {
"start": "tsx src/main.ts",
"start:phase1": "tsx src/main-phase1.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"

View File

@@ -0,0 +1,282 @@
/**
* Phase 1 PRODUCTION entrypoint — `npm run start:phase1`. Composes the SHARED-STORE data plane
* (B1B4) behind a publicly-bound TLS listener, serves the relay-web bundle same-origin as the
* browser WSS (D1), and hosts a STAGING operator token-mint (`POST /auth/mint`, B5/auth-mint.ts).
*
* Unlike Phase-0 `main.ts` (in-RAM fakes + self-signed dev CA), everything here is real and
* env-configured — one world of truth over the SAME Postgres + Redis as the control-plane (INV7,
* restart-safe): the host registry that gates mTLS (INV14) and route resolution, the Redis
* revocation bus that tears live tunnels down (INV12), and the shared P5 verify key (INV9).
*
* browser ──WSS(:BIND_PORT)──▶ relay-node ──opaque splice(INV2)──▶ agent tunnel ◀──mTLS(:AGENT_BIND_PORT)── agent
* │ P5 onUpgrade: Origin/CSWSH + capability verify + DPoP │ registry-gated verifyAgentCert
* └ same-origin: static bundle (D1) + POST /auth/mint (B5) └ Redis relay:revocations → teardown
*
* All configuration is from ENV (no hardcoded hosts/ports/secrets). Phase-0 `main.ts` is UNTOUCHED.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { loadVerifyKeyFromEnv, KeyConfigError } from 'relay-auth/src/config/keys.js'
import { createPgPool, createQuery } from 'control-plane/src/db/pool.js'
import { createPgStores } from 'control-plane/src/store/pg.js'
import { createRedisClient } from 'control-plane/src/boot/redis.js'
import type {
MtlsVerifier,
TlsServerFactory,
} from 'term-relay/data-plane/agent-listener.js'
import { createRelayEnforceDeps } from './wiring/stores-pg.js'
import { createMtlsVerifier, type AsyncMtlsVerifier } from './wiring/mtls-verifier.js'
import { createStoreRouteResolver } from './wiring/route-resolver.js'
import { startRevocationSubscriber, type RevocableNode } from './wiring/revocation-subscriber.js'
import { createAuthorizer } from './wiring/authorizer.js'
import { buildDataPlane, makeDataPlaneConfig } from './wiring/data-plane.js'
import { makeAgentTlsServerFactory } from './servers/agent-tls.js'
import { startBrowserServer } from './servers/browser-server.js'
import { createAuthMintRoute, loadSigningKeyFromEnv } from './servers/auth-mint.js'
const DEFAULT_BIND_HOST = '0.0.0.0'
const DEFAULT_BIND_PORT = 443
const HERE = dirname(fileURLToPath(import.meta.url)) // <repo>/relay-run/src
const DEFAULT_WEB_ROOT = join(HERE, '..', '..', 'relay-web', 'public')
// ── env helpers (fail-fast on misconfiguration) ─────────────────────────────────────────────────
function requireEnv(name: string): string {
const v = process.env[name]
if (v === undefined || v.length === 0) {
throw new KeyConfigError(`required env ${name} is not set`)
}
return v
}
function requirePort(name: string): number {
const raw = requireEnv(name)
const n = Number(raw)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
throw new KeyConfigError(`env ${name} must be an integer port 165535 (got ${JSON.stringify(raw)})`)
}
return n
}
function intEnv(name: string, fallback: number): number {
const raw = process.env[name]
if (raw === undefined || raw.length === 0) return fallback
const n = Number(raw)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
throw new KeyConfigError(`env ${name} must be an integer port 165535 (got ${JSON.stringify(raw)})`)
}
return n
}
// ── async mTLS → sync-slot bridge ───────────────────────────────────────────────────────────────
interface MtlsBridge {
/** Sync `MtlsVerifier` for the data plane; reads the pre-computed verdict for this connection. */
readonly sync: MtlsVerifier
/** Wrap the real TLS factory so each peer is registry-verified (async) BEFORE `attach` runs. */
wrap(base: TlsServerFactory): TlsServerFactory
}
/**
* term-relay's `MtlsVerifier.verifyPeer` is SYNC, but a registry-backed verifier (B2) is inherently
* async (Postgres lookup) — the ASYNC IMPEDANCE flagged in mtls-verifier.ts. We bridge it WITHOUT
* editing term-relay (outside our lane) by doing the async verify in the TLS `onPeer` hook and
* caching the verdict keyed by the peer's DER, which the sync `verifyPeer` (called synchronously by
* `attach`, immediately after `onPeer` fires) then reads. The set→onPeer→get sequence runs
* synchronously inside one `.then` callback, so a single-slot cache per DER is race-free. Fail-closed
* throughout: a rejected/failed verify caches `null`, so `attach` closes the peer with 4401 (INV14).
*/
function bridgeAsyncMtls(
asyncMtls: AsyncMtlsVerifier,
onError: (e: unknown) => void,
): MtlsBridge {
const pending = new Map<string, { hostId: string; accountId: string } | null>()
const keyOf = (der: Uint8Array): string => Buffer.from(der).toString('base64')
const sync: MtlsVerifier = {
verifyPeer(peerCert) {
const k = keyOf(peerCert)
const verdict = pending.get(k) ?? null
pending.delete(k) // one-shot: consumed by the attach() that triggered this onPeer
return verdict
},
}
const wrap = (base: TlsServerFactory): TlsServerFactory => (opts, onPeer) =>
base(opts, (ws, der) => {
asyncMtls
.verifyPeer(der)
.then((verdict) => {
pending.set(keyOf(der), verdict)
onPeer(ws, der) // sync attach() → sync.verifyPeer(der) reads + consumes the verdict
})
.catch((e: unknown) => {
onError(e)
pending.set(keyOf(der), null) // fail-closed → attach() closes 4401
onPeer(ws, der)
})
})
return { sync, wrap }
}
// ── boot ────────────────────────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const now = (): number => Math.floor(Date.now() / 1000)
// Config (fail-fast; secrets are read but never logged — INV9).
const bindHost = process.env.BIND_HOST || DEFAULT_BIND_HOST
const bindPort = intEnv('BIND_PORT', DEFAULT_BIND_PORT)
const agentBindPort = requirePort('AGENT_BIND_PORT')
const tlsCertPath = requireEnv('TLS_CERT_PATH')
const tlsKeyPath = requireEnv('TLS_KEY_PATH')
const agentServerCertPath = requireEnv('AGENT_SERVER_CERT_PATH')
const agentServerKeyPath = requireEnv('AGENT_SERVER_KEY_PATH')
const agentCaCertPath = requireEnv('AGENT_CA_CERT_PATH')
const agentCaChainPath = requireEnv('AGENT_CA_CHAIN_PATH')
const baseDomain = requireEnv('BASE_DOMAIN')
const relayNodeId = requireEnv('RELAY_NODE_ID')
const trustDomain = requireEnv('RELAY_TRUST_DOMAIN')
const allowedOrigins = requireEnv('ALLOWED_ORIGINS')
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0)
if (allowedOrigins.length === 0) {
throw new KeyConfigError('ALLOWED_ORIGINS must contain at least one origin (CSWSH exact-match)')
}
const pgUrl = requireEnv('PG_URL')
const redisUrl = requireEnv('REDIS_URL')
const webRoot = process.env.WEB_ROOT || DEFAULT_WEB_ROOT
// Shared P5 verify key (RELAY_AUTH_VERIFY_PUBKEY) — configured process-wide, never logged (INV9).
await loadVerifyKeyFromEnv()
// Shared stores: SAME Postgres + Redis as the control-plane (INV7).
const pool = createPgPool(pgUrl)
const query = createQuery(pool)
const stores = createPgStores(query)
const redis = createRedisClient(redisUrl)
const redisSubscriber = createRedisClient(redisUrl) // dedicated subscriber-mode connection
const deps = createRelayEnforceDeps({ query, redis })
const resolver = createStoreRouteResolver({ hosts: stores.hosts })
const asyncMtls = createMtlsVerifier({
caChainPem: readFileSync(agentCaChainPath, 'utf8'),
hosts: deps.hosts,
now,
onError: (e) => console.error('[mtls-verify]', e),
})
const mtlsBridge = bridgeAsyncMtls(asyncMtls, (e) => console.error('[mtls-bridge]', e))
const authorizer = createAuthorizer({ deps, allowedOrigins, now })
const config = makeDataPlaneConfig({ baseDomain, bindHost, bindPort, agentBindPort, relayNodeId })
const agentTlsFactory = makeAgentTlsServerFactory({
serverCertPath: agentServerCertPath,
serverKeyPath: agentServerKeyPath,
bindHost,
bindPort: agentBindPort,
onListening: () => console.log(`[agent-mtls] listening wss://${bindHost}:${agentBindPort}`),
onError: (e) => console.error('[agent-mtls]', e),
})
const dp = buildDataPlane({
config,
authorizer,
resolver,
mtls: mtlsBridge.sync,
now,
caBundle: [readFileSync(agentCaCertPath)],
onError: (e) => console.error('[data-plane]', e),
tlsServerFactory: mtlsBridge.wrap(agentTlsFactory),
})
// STAGING operator token-mint (B5). Enabled only when BOTH the password gate and the signing key
// are configured; otherwise the endpoint stays off (fail-closed) and static-only mode serves.
const operatorPassword = process.env.OPERATOR_PASSWORD ?? ''
const signPrivRaw = process.env.CAPABILITY_SIGN_PRIVKEY ?? ''
let onRequest: ReturnType<typeof createAuthMintRoute> | undefined
let mintEnabled = false
if (operatorPassword.length > 0 && signPrivRaw.length > 0) {
const signingKey = await loadSigningKeyFromEnv(signPrivRaw)
onRequest = createAuthMintRoute({
signingKey,
hosts: stores.hosts,
operatorPassword,
now,
onError: (e) => console.error('[auth-mint]', e),
})
mintEnabled = true
} else {
console.warn(
'[auth-mint] STAGING mint disabled — set OPERATOR_PASSWORD and CAPABILITY_SIGN_PRIVKEY to enable POST /auth/mint',
)
}
const browserServer = startBrowserServer({
certPath: tlsCertPath,
keyPath: tlsKeyPath,
bindHost,
bindPort,
node: dp.node,
landingHtml: '<!doctype html><title>relay</title>', // unused when staticRoot is set
staticRoot: webRoot,
...(onRequest ? { onRequest } : {}),
onListening: () => console.log(`[browser-wss] listening https://${bindHost}:${bindPort}`),
onError: (e) => console.error('[browser-wss]', e),
})
// INV12: a Redis relay:revocations kill-signal tears matching live tunnel(s) down on this node.
const revocableNode: RevocableNode = {
activeTunnels: () =>
[...dp.listener.tunnels().values()].map((t) => ({ hostId: t.hostId, accountId: t.accountId })),
closeStream: (hostId) => dp.node.closeTunnel(hostId),
}
const revsub = startRevocationSubscriber({
redisSubscriber,
node: revocableNode,
// INV10: log counts + scope KIND only — never signal.reason / terminal payload.
onApplied: (signal, hostsAffected) =>
console.log(`[revocation] applied scope=${signal.scope.kind} hostsAffected=${hostsAffected}`),
onDropped: () => console.warn('[revocation] dropped malformed kill-signal'),
onError: (e) => console.error('[revocation]', e),
})
console.log('\n=== relay-run Phase 1 READY ===')
console.log(`Base domain : ${baseDomain} trustDomain: ${trustDomain} node: ${relayNodeId}`)
console.log(`Browser WSS : https://${bindHost}:${bindPort} (static root: ${webRoot})`)
console.log(`Agent mTLS : wss://${bindHost}:${agentBindPort}`)
console.log(`Allowed origins : ${allowedOrigins.join(', ')}`)
console.log(`Operator mint : ${mintEnabled ? 'ENABLED (STAGING /auth/mint)' : 'disabled'}`)
console.log('Ctrl-C to stop.\n')
let shuttingDown = false
const shutdown = async (): Promise<void> => {
if (shuttingDown) return
shuttingDown = true
console.log('\nshutting down…')
try {
revsub.close()
browserServer.close()
dp.listener.close()
await Promise.allSettled([redis.quit(), redisSubscriber.quit(), pool.end()])
} catch (e) {
console.error('[shutdown]', e)
} finally {
process.exit(0)
}
}
process.on('SIGINT', () => void shutdown())
process.on('SIGTERM', () => void shutdown())
}
main().catch((e) => {
console.error('fatal:', e instanceof Error ? e.message : e)
process.exit(1)
})

View File

@@ -0,0 +1,262 @@
/**
* B5 · STAGING-ONLY operator token-mint (`POST /auth/mint`).
*
* ┌─ STAGING NOTICE ────────────────────────────────────────────────────────────────────────────┐
* │ The ONLY gate here is a shared `OPERATOR_PASSWORD` (constant-time compared). This is a │
* │ deliberate Phase-1 staging shortcut so an operator can mint a browser capability token without │
* │ the full human-auth stack. Phase 2 REPLACES this password gate with WebAuthn (P5 T5T8). │
* └────────────────────────────────────────────────────────────────────────────────────────────┘
*
* Flow: the operator's browser generates its OWN DPoP keypair, computes the base64url SHA-256 JWK
* thumbprint `jkt`, and POSTs `{ password, jkt, subdomain }`. On a correct password we resolve the
* subdomain to its host row in the CP `hosts` store and mint a short-lived (<=60 s) §4.3 capability
* token BOUND to that `jkt` (`cnf.jkt`, RFC 7800 proof-of-possession) via the REAL P5 issue path.
*
* SECURITY:
* - INV3: `sub`(accountId)/`host`(hostId)/`aud`(subdomain) come SOLELY from the authenticated CP
* store row — the request body only NAMES a subdomain and the client's own DPoP thumbprint; it
* never supplies an accountId/hostId.
* - INV9: the signing private key and the minted token are NEVER logged. `onError` receives only
* the thrown error (callers must not log request bodies / tokens either).
* - Deny-by-default: unknown/revoked subdomain, bad password, malformed body → 4xx, no token.
* - Input is validated at the boundary (Zod) BEFORE any store/crypto work; body size is capped.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash, timingSafeEqual } from 'node:crypto'
import { issueCapabilityToken, type AuthenticatedPrincipal } from 'relay-auth'
import type { CapabilityRight } from 'relay-contracts'
import type { HostStore } from 'control-plane/src/store/ports.js'
/** Route this handler owns. */
const MINT_PATH = '/auth/mint' as const
/** Minted tokens are connect-scoped and short-lived (P5 clamps issue to [30, 60] s). */
const MINT_TTL_SEC = 60
/** Reject an oversized request body before buffering it (DoS guard). */
const MAX_BODY_BYTES = 4096
/** Least-privilege: an operator connecting to their terminal needs only `attach`. */
const MINT_RIGHTS: readonly CapabilityRight[] = ['attach']
/** A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]. */
const JKT_RE = /^[A-Za-z0-9_-]{43}$/
/** A single DNS label (tenant subdomain): lowercase alnum + hyphens, 163 chars, no leading/trailing '-'. */
const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
/** The single CP `hosts` capability this route needs (interface segregation). */
export type SubdomainHostLookup = Pick<HostStore, 'getBySubdomain'>
export interface AuthMintDeps {
/** P5 capability signing PRIVATE key (Ed25519 CryptoKey with ['sign']). NEVER logged. */
readonly signingKey: CryptoKey
/** CP hosts store — the ONLY source of accountId/hostId (INV3). */
readonly hosts: SubdomainHostLookup
/** Shared staging operator password (constant-time compared). Phase 2 → WebAuthn. */
readonly operatorPassword: string
/** Epoch-SECONDS clock (token iat/exp). */
readonly now: () => number
/** Observability seam — receives thrown errors only (never bodies/tokens/keys). */
readonly onError?: (e: unknown) => void
}
interface MintRequest {
readonly password: string
readonly jkt: string
readonly subdomain: string
}
/** Validate the untrusted JSON body at the boundary. Returns the typed request or `null` to reject. */
function parseMintRequest(json: unknown): MintRequest | null {
if (typeof json !== 'object' || json === null) return null
const { password, jkt, subdomain } = json as Record<string, unknown>
if (typeof password !== 'string' || password.length === 0) return null
if (typeof jkt !== 'string' || !JKT_RE.test(jkt)) return null
if (typeof subdomain !== 'string' || !SUBDOMAIN_RE.test(subdomain)) return null
return { password, jkt, subdomain }
}
/** Length-independent constant-time string equality (compares fixed-size SHA-256 digests). */
function safeEqual(a: string, b: string): boolean {
const ha = createHash('sha256').update(a).digest()
const hb = createHash('sha256').update(b).digest()
return timingSafeEqual(ha, hb)
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body)
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
})
res.end(payload)
}
/** Path portion of a URL (query/hash stripped) — the route matches on path only. */
function pathOf(url: string): string {
return url.split('?', 1)[0].split('#', 1)[0]
}
/** Buffer the request body up to `maxBytes`; reject (and destroy the stream) if it exceeds the cap. */
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
return new Promise<string>((resolve, reject) => {
let size = 0
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > maxBytes) {
req.destroy()
reject(new Error('request body too large'))
return
}
chunks.push(chunk)
})
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
req.on('error', (e) => reject(e))
})
}
function operatorPrincipal(accountId: string, authAt: number): AuthenticatedPrincipal {
// Minimal authenticated principal — issueCapabilityToken reads only `.accountId` (INV3).
return {
kind: 'human',
accountId,
principalId: `operator:${accountId}`,
amr: ['passkey'],
authAt,
stepUpAt: null,
}
}
async function handleMint(
req: IncomingMessage,
res: ServerResponse,
deps: AuthMintDeps,
): Promise<void> {
try {
let raw: string
try {
raw = await readBody(req, MAX_BODY_BYTES)
} catch {
sendJson(res, 413, { error: 'payload_too_large' })
return
}
let json: unknown
try {
json = JSON.parse(raw)
} catch {
sendJson(res, 400, { error: 'invalid_json' })
return
}
const parsed = parseMintRequest(json)
if (parsed === null) {
sendJson(res, 400, { error: 'invalid_request' })
return
}
const { password, jkt, subdomain } = parsed
// STAGING gate. Constant-time to avoid a password-length/prefix timing oracle.
if (!safeEqual(password, deps.operatorPassword)) {
sendJson(res, 401, { error: 'unauthorized' })
return
}
const host = await deps.hosts.getBySubdomain(subdomain)
if (host === null) {
sendJson(res, 404, { error: 'unknown_subdomain' })
return
}
if (host.status === 'revoked') {
sendJson(res, 403, { error: 'host_revoked' })
return
}
const nowSec = deps.now()
// INV3: accountId/hostId/subdomain are the store row's, NEVER the request body's.
const token = await issueCapabilityToken(
{
principal: operatorPrincipal(host.accountId, nowSec),
aud: host.subdomain,
host: host.hostId,
rights: MINT_RIGHTS,
ttlSeconds: MINT_TTL_SEC,
cnfJkt: jkt,
},
deps.signingKey,
nowSec,
)
// INV9: the token is a bearer secret — return it, NEVER log it.
sendJson(res, 200, { token })
} catch (e: unknown) {
deps.onError?.(e)
if (!res.headersSent) sendJson(res, 500, { error: 'internal_error' })
}
}
/**
* Build the `POST /auth/mint` pre-router for `startBrowserServer`'s `onRequest` hook.
*
* Returns `true` when it CLAIMS the request (its path is `/auth/mint`) so the caller must not also
* write a response — the actual mint completes asynchronously. Returns `false` for any other path so
* the request falls through to the static/landing handler.
*/
export function createAuthMintRoute(
deps: AuthMintDeps,
): (req: IncomingMessage, res: ServerResponse) => boolean {
return (req, res) => {
if (pathOf(req.url ?? '/') !== MINT_PATH) return false // not our route → fall through to static
if (req.method !== 'POST') {
sendJson(res, 405, { error: 'method_not_allowed' })
return true
}
void handleMint(req, res, deps)
return true
}
}
// ── P5 capability signing-key loader ──────────────────────────────────────────────────────────────
const PEM_BODY_RE = /-----BEGIN [^-]+-----|-----END [^-]+-----/g
/** Coerce to an ArrayBuffer-backed view (WebCrypto's importKey wants `Uint8Array<ArrayBuffer>`). */
function toArrayBufferView(u: Uint8Array): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(u.byteLength)
out.set(u)
return out
}
/** Decode standard-or-URL base64 (padding optional) to bytes. */
function base64AnyToBytes(s: string): Uint8Array {
const std = s.replace(/-/g, '+').replace(/_/g, '/')
return new Uint8Array(Buffer.from(std, 'base64'))
}
/** Strip PEM armor + whitespace and base64-decode the body to DER bytes. */
function pemBodyToDer(pem: string): Uint8Array {
const b64 = pem.replace(PEM_BODY_RE, '').replace(/\s+/g, '')
return base64AnyToBytes(b64)
}
/**
* Load the P5 capability SIGNING key (Ed25519 private) from the `CAPABILITY_SIGN_PRIVKEY` env value.
* Accepts either a PKCS#8 PEM (the `gen-capability-key.sh` output — contains `-----BEGIN`) or a
* base64/base64url encoding of the PKCS#8 DER. Imported non-extractable with only `['sign']` usage.
*
* INV9: the raw key material is never logged; this throws a generic error on a malformed value.
*/
export async function loadSigningKeyFromEnv(raw: string): Promise<CryptoKey> {
const trimmed = raw.trim()
if (trimmed.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY is empty')
const der = trimmed.includes('BEGIN') ? pemBodyToDer(trimmed) : base64AnyToBytes(trimmed)
if (der.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY did not decode to any key bytes')
try {
return await globalThis.crypto.subtle.importKey(
'pkcs8',
toArrayBufferView(der),
{ name: 'Ed25519' },
false,
['sign'],
)
} catch {
// Never surface the underlying material in the error.
throw new Error('CAPABILITY_SIGN_PRIVKEY is not a valid PKCS#8 Ed25519 private key')
}
}

View File

@@ -12,6 +12,7 @@ import { APP_SUBPROTOCOL } from 'relay-contracts'
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
import type { RelayNode } from 'term-relay/data-plane/relay-node.js'
import { wsToWebSocketLike } from '../wiring/socket-pipe.js'
import { serveStatic } from './static-web.js'
export interface BrowserServerOptions {
readonly certPath: string
@@ -20,6 +21,20 @@ export interface BrowserServerOptions {
readonly bindPort: number
readonly node: RelayNode
readonly landingHtml: string
/**
* When set, the HTTP handler serves the built relay-web bundle from this directory (D1), SAME
* ORIGIN as the WSS (so Origin/CSP stay aligned). When unset, `landingHtml` is served — the
* Phase-0 dev fallback. The WS upgrade is unaffected either way (it rides the `upgrade` event).
*/
readonly staticRoot?: string
/**
* Optional pre-router (B5): consulted BEFORE `staticRoot`/`landingHtml` on every non-upgrade HTTP
* request. Return `true` to claim the request (the hook owns the response — it may finish it
* asynchronously); return `false` to fall through to the static/landing behavior below. Default
* (undefined) preserves D1's behavior exactly. WS upgrades never reach this hook (they ride the
* `upgrade` event), so same-origin `POST /auth/mint` can coexist with the WSS.
*/
readonly onRequest?: (req: IncomingMessage, res: ServerResponse) => boolean
readonly onListening?: () => void
readonly onError?: (e: unknown) => void
}
@@ -56,7 +71,24 @@ function buildUpgradeRequest(req: IncomingMessage): UpgradeRequest {
export function startBrowserServer(opts: BrowserServerOptions): Server {
const server = createServer(
{ cert: readFileSync(opts.certPath), key: readFileSync(opts.keyPath) },
(_req: IncomingMessage, res: ServerResponse) => {
(req: IncomingMessage, res: ServerResponse) => {
// Pre-router (B5): a claimed request is fully owned by the hook (e.g. POST /auth/mint) and
// must NOT fall through to static/landing (which would double-write the response).
if (opts.onRequest !== undefined && opts.onRequest(req, res)) return
// Static-bundle mode (Phase 1): serve relay-web from `staticRoot`, SAME-ORIGIN as the WSS.
// Non-upgrade HTTP requests only — WS upgrades never reach this handler (see `upgrade` event).
if (opts.staticRoot !== undefined) {
const file = serveStatic(opts.staticRoot, req.url ?? '/')
if (file) {
res.writeHead(file.status, file.headers)
res.end(file.body)
} else {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
res.end('Not Found')
}
return
}
// Phase-0 fallback: a single landing page.
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(opts.landingHtml)
},

View File

@@ -0,0 +1,96 @@
/**
* Pure static-file resolver for the browser HTTPS server (D1). Serves the built relay-web bundle
* SAME-ORIGIN as the browser WSS so the page's Origin and CSP stay aligned with the WS endpoint.
*
* Deterministic given (root, urlPath): it resolves the request path under `root`, reads the file,
* and returns its bytes + Content-Type on a hit — or `null` when the path is malformed, escapes
* `root` (traversal), or names no readable regular file. The caller maps `null` to a 404.
*
* Security (STRICT traversal guard): the resolved absolute path MUST be `root` itself or a
* descendant of it. `../`, percent-encoded `..` (`%2e%2e`), and NUL bytes all reject to `null` —
* this function never reads a byte outside `root`.
*/
import { readFileSync, statSync } from 'node:fs'
import { resolve, sep, extname } from 'node:path'
export interface StaticFile {
readonly status: number
readonly headers: Record<string, string>
readonly body: Buffer
}
/** Served for a bare `/` request. */
const DEFAULT_DOC = 'index.html'
const OCTET_STREAM = 'application/octet-stream'
/** Extension → Content-Type. Covers what the relay-web bundle ships plus common static assets. */
const MIME_BY_EXT: Readonly<Record<string, string>> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
}
function contentTypeFor(filePath: string): string {
return MIME_BY_EXT[extname(filePath).toLowerCase()] ?? OCTET_STREAM
}
/**
* Strip query/hash, percent-decode, and reject malformed / NUL-byte paths. Returns the path made
* relative to `root` (leading slashes stripped, `/` → `index.html`), or `null` to reject.
*/
function normalizeUrlPath(urlPath: string): string | null {
const noQuery = urlPath.split('?')[0].split('#')[0]
let decoded: string
try {
decoded = decodeURIComponent(noQuery)
} catch {
return null
}
if (decoded.includes('\0')) return null
const rel = decoded.replace(/^\/+/, '')
return rel.length === 0 ? DEFAULT_DOC : rel
}
/**
* Resolve `urlPath` to a file under `root`. Returns the file bytes + MIME on a hit, or `null` when
* the path is malformed, escapes `root`, or names no readable regular file.
*/
export function serveStatic(root: string, urlPath: string): StaticFile | null {
const rel = normalizeUrlPath(urlPath)
if (rel === null) return null
const rootAbs = resolve(root)
const resolved = resolve(rootAbs, rel)
// STRICT traversal guard: resolved must be the root itself or a descendant of it.
if (resolved !== rootAbs && !resolved.startsWith(rootAbs + sep)) return null
try {
if (!statSync(resolved).isFile()) return null
const body = readFileSync(resolved)
return {
status: 200,
headers: {
'content-type': contentTypeFor(resolved),
'content-length': String(body.length),
},
body,
}
} catch {
// ENOENT / EACCES / etc. — treat as "no servable file", caller 404s.
return null
}
}

View File

@@ -0,0 +1,93 @@
/**
* B2 · registry-backed MtlsVerifier (INV4/INV14) — replaces the Phase-0 stub in `relay-world.ts:149`
* that trusted ANY peer cert. Wraps relay-auth's `verifyAgentCert` against the SHARED host registry
* (the same `HostRegistryPort` B1 builds over Postgres) and the pinned agent-CA bundle, returning
* `{ hostId, accountId }` ONLY for an enrolled, unrevoked host whose leaf chains to our CA and is in
* its validity window. Every other outcome is `null` (fail-closed).
*
* INV3: `hostId`/`accountId` originate ONLY from the authenticated cert material (SPIFFE-ID) matched
* against the registry — never from a client-supplied field. INV14: the pubkey is bound to the
* registry AFTER CA-chain validation (a cert can chain to our CA yet still be denied if not enrolled).
*
* ── ASYNC IMPEDANCE (flagged for the orchestrator; adaptation per task B2) ──────────────────────
* The term-relay `MtlsVerifier.verifyPeer` (agent-listener.ts:16) is declared SYNC and its caller
* (agent-listener.ts:93) does NOT await it (`if (verified === null)`). A registry-backed verifier
* CANNOT be sync — `HostRegistryPort.getById` returns a Promise, so `verifyAgentCert` is async. This
* factory therefore returns an `AsyncMtlsVerifier` (Promise-returning `verifyPeer`). Wiring it into the
* data-plane requires `MtlsVerifier.verifyPeer` to become async END-TO-END: `agent-listener.ts` must
* `await` the result, and the `relay-world.ts:149` stub must be replaced. Those files are OUTSIDE B2's
* Owns. Until that migration lands, assigning this into the sync slot type-errors at the wiring site
* (`Promise<…>` is not assignable to `{…}|null`) — a useful compile-time tripwire, not a silent bug.
*/
import {
verifyAgentCert,
defaultParseX509,
type ParseCert,
type HostRegistryPort,
} from 'relay-auth'
const PEM_LINE_WIDTH = 64
/** Non-global (safe for `.test()`): asserts a bundle actually holds a PEM CERTIFICATE block. */
const PEM_CERT_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
/**
* Async analog of term-relay's sync `MtlsVerifier` (agent-listener.ts). See ASYNC IMPEDANCE above:
* a registry lookup is inherently async, so `verifyPeer` returns a Promise.
*/
export interface AsyncMtlsVerifier {
verifyPeer(peerCertDer: Uint8Array): Promise<{ hostId: string; accountId: string } | null>
}
export interface MtlsVerifierDeps {
/** Pinned agent-CA bundle (PEM: intermediate(s) + root). NEVER the browser LE chain (INV14). */
readonly caChainPem: string
/** Shared host registry (same port B1 builds over Postgres). accountId/hostId only from here (INV3). */
readonly hosts: HostRegistryPort
/** Epoch-SECONDS provider (compared against the leaf's notBefore/notAfter). */
readonly now: () => number
/** Optional observability seam for a registry/parse fault; the verifier still fails closed. */
readonly onError?: (e: unknown) => void
/** Test seam: cert parser (defaults to production `defaultParseX509`). */
readonly parse?: ParseCert
}
/**
* Wrap raw DER bytes (`getPeerCertificate(true).raw`) into a PEM certificate block with 64-column
* base64, the shape node's `X509Certificate` (via `verifyAgentCert`) parses.
*/
export function derToPem(der: Uint8Array): string {
const b64 = Buffer.from(der).toString('base64')
const wrapped = b64.match(new RegExp(`.{1,${PEM_LINE_WIDTH}}`, 'g'))?.join('\n') ?? ''
return `-----BEGIN CERTIFICATE-----\n${wrapped}\n-----END CERTIFICATE-----\n`
}
export function createMtlsVerifier(deps: MtlsVerifierDeps): AsyncMtlsVerifier {
const { caChainPem, hosts, now } = deps
const onError = deps.onError ?? (() => {})
const parse = deps.parse ?? defaultParseX509
// Fail fast at construction on a misconfigured CA bundle (INV14: a real pinned CA is required; an
// empty/garbage bundle would otherwise silently deny every agent with an opaque `chain_invalid`).
if (typeof caChainPem !== 'string' || !PEM_CERT_RE.test(caChainPem)) {
throw new Error('createMtlsVerifier: caChainPem must contain at least one PEM CERTIFICATE block')
}
return {
async verifyPeer(peerCertDer) {
if (peerCertDer === undefined || peerCertDer === null || peerCertDer.length === 0) {
return null // no client cert presented → fail closed
}
try {
const leafPem = derToPem(peerCertDer)
const result = await verifyAgentCert(leafPem, caChainPem, now(), hosts, parse)
if (!result.ok || result.hostId === undefined || result.accountId === undefined) {
return null // expired / chain-invalid / not-enrolled / revoked / account-mismatch → fail closed
}
return { hostId: result.hostId, accountId: result.accountId }
} catch (e: unknown) {
onError(e) // registry/DB fault: surface it, never throw out of verifyPeer → fail closed
return null
}
},
}
}

View File

@@ -0,0 +1,121 @@
/**
* B4 · relay-run revocation subscriber (INV12 data-plane teardown). Bridges the frozen §4.2
* `relay:revocations` Redis pub/sub bus onto the running relay node so a revocation tears the live
* tunnel(s) down within the INV12 budget.
*
* Flow: an injected ioredis subscriber-mode client SUBSCRIBEs the one named channel; each message is
* validated as a `KillSignal` with the relay-contracts schema (malformed → dropped + counted, never
* a teardown and never a bus crash — a poisoned message can neither kill the bus nor fire a spurious
* kill); then for every live tunnel THIS node serves the pure relay-auth predicate
* `killsScope(signal, hostAccountId, hostId)` decides coverage and, on a match, the whole host is
* torn down (revoked ⇒ grace forced to 0 upstream in T11).
*
* Reuse, don't re-derive: scope math is `killsScope` (relay-auth) and validation is `KillSignalSchema`
* (relay-contracts) — this module only wires those pure primitives to a concrete ioredis client and
* the relay node. Security: scope selection is blast-radius-bounded (host ⊂ account ⊂ global); a host
* this node does not serve is a no-op (no cross-tenant reach). `signal.reason` is metadata only and is
* never written as terminal payload or log content (INV10). The owning `accountId` used for
* account-scope fan-out comes only from the authenticated tunnel (INV3).
*/
import { KillSignalSchema, RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts'
import { killsScope } from 'relay-auth'
/** One live tunnel this node currently serves: the host and the account that owns it. Both are
* authenticated material carried on the tunnel (INV3) — never derived from the untrusted signal. */
export interface ActiveTunnelRef {
readonly hostId: string
readonly accountId: string
}
/**
* What the subscriber needs from the running relay node: enumerate the tunnels it serves and tear a
* whole host's live streams/devices down (INV12). Kept narrow + injectable so the Phase-1 entry (B5)
* adapts the real data-plane {node, listener}: `activeTunnels` ← `listener.tunnels()` values,
* `closeStream(hostId)` ← `node.closeTunnel(hostId)` (the whole-host revocation lever).
*/
export interface RevocableNode {
/** Snapshot-friendly view of the tunnels currently attached to this node. */
activeTunnels(): Iterable<ActiveTunnelRef>
/** Immediately tear down every live stream/device on `hostId` (whole-host revocation, INV12). */
closeStream(hostId: string): void
}
/**
* Minimal structural view of an ioredis client in subscriber mode — only the members used here — so
* this module carries no hard dependency on ioredis and stays unit-testable with a fake. A real
* ioredis `Redis` instance satisfies this shape.
*/
export interface RedisSubscriber {
subscribe(channel: string): Promise<unknown>
unsubscribe(channel: string): Promise<unknown>
on(event: 'message', listener: (channel: string, message: string) => void): unknown
off(event: 'message', listener: (channel: string, message: string) => void): unknown
}
export interface RevocationSubscriberDeps {
readonly redisSubscriber: RedisSubscriber
readonly node: RevocableNode
/** Observability (metadata only, INV10): a valid signal was applied to `hostsAffected` hosts here. */
readonly onApplied?: (signal: KillSignal, hostsAffected: number) => void
/** Malformed-message counter — a dropped signal never fires a teardown (INV12 safety). */
readonly onDropped?: () => void
/** Boundary error routing for subscribe/unsubscribe failures — never silently swallowed. */
readonly onError?: (error: unknown) => void
}
export interface RevocationSubscription {
close(): void
}
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): RevocationSubscription {
const { redisSubscriber, node, onApplied, onDropped, onError } = deps
const onMessage = (channel: string, message: string): void => {
if (channel !== RELAY_REVOCATIONS_CHANNEL) return // this client may also carry other channels
const signal = parseKillSignal(message)
if (signal === null) {
onDropped?.() // dropped + counted; bus stays alive, no teardown fired
return
}
// Snapshot first: closeStream tears down (mutates) the node's live-tunnel set as we iterate.
const tunnels = [...node.activeTunnels()]
let hostsAffected = 0
for (const { hostId, accountId } of tunnels) {
if (!killsScope(signal, accountId, hostId)) continue // blast-radius bounded; unrelated host = no-op
node.closeStream(hostId)
hostsAffected += 1
}
onApplied?.(signal, hostsAffected)
}
redisSubscriber.on('message', onMessage)
// Fire the SUBSCRIBE; route a rejected subscribe to onError (boundary I/O — never swallowed).
Promise.resolve(redisSubscriber.subscribe(RELAY_REVOCATIONS_CHANNEL)).catch((error: unknown) => {
onError?.(error)
})
let closed = false
return {
close(): void {
if (closed) return // idempotent teardown
closed = true
redisSubscriber.off('message', onMessage)
Promise.resolve(redisSubscriber.unsubscribe(RELAY_REVOCATIONS_CHANNEL)).catch(
(error: unknown) => {
onError?.(error)
},
)
},
}
}

View File

@@ -0,0 +1,40 @@
/**
* B3 · Store-backed RouteResolver — the PRODUCTION replacement for the one-entry in-RAM
* `memoryRouteResolver` (wiring/data-plane.ts:110). It implements term-relay's `RouteResolver`
* interface EXACTLY (`resolveSubdomain(subdomain) -> ResolvedHost | null`) by looking the tenant
* label up in the control-plane Postgres `hosts` store via `hosts.getBySubdomain(subdomain)`.
*
* Fail-closed (returns null, → 403 at the T8 upgrade edge) on:
* - unknown subdomain: `getBySubdomain` returns null.
* - revoked host: the row still exists (status is versioned, not deleted — INV8/INV12), so we
* must reject `status === 'revoked'` here; a revoked host must never resolve to a route.
*
* The resolver is only a HINT for candidate lookup (subdomain-router.ts): the returned `hostId`
* is what `authorizeUpgrade` feeds to P5 as `requestedHostId`, and P5 gates the signed token's
* `host` against it (INV1). Identity (`accountId`/`hostId`) here comes solely from the CP store —
* the ownership source of truth — never from the caller (INV3).
*/
import type { HostStore } from 'control-plane/src/store/ports.js'
import type { RouteResolver, ResolvedHost } from 'term-relay/data-plane/subdomain-router.js'
/**
* The single HostStore capability this resolver needs (interface segregation): a subdomain lookup.
* A full `createPgStores().hosts` (`HostStore`) satisfies it structurally.
*/
export type HostSubdomainLookup = Pick<HostStore, 'getBySubdomain'>
export interface StoreRouteResolverDeps {
readonly hosts: HostSubdomainLookup
}
/** Build a RouteResolver that resolves subdomain → host from the control-plane `hosts` store. */
export function createStoreRouteResolver({ hosts }: StoreRouteResolverDeps): RouteResolver {
return {
async resolveSubdomain(subdomain: string): Promise<ResolvedHost | null> {
const host = await hosts.getBySubdomain(subdomain)
if (host === null) return null // unknown subdomain — fail closed
if (host.status === 'revoked') return null // revoked host — fail closed (INV12)
return { hostId: host.hostId, accountId: host.accountId, subdomain: host.subdomain }
},
}
}

View File

@@ -0,0 +1,197 @@
/**
* B1 — shared-store `EnforceDeps` for relay-auth's `onUpgrade` pipeline, backed by the SAME
* Postgres + Redis as the control-plane (P3). This replaces the Phase-0 in-RAM fakes
* (`memory-stores.ts`) so the data-plane and the control-plane read one world of truth
* (restart-safe, INV7).
*
* Design:
* - hosts / sessions / audit -> Postgres via the CP repository adapter (`createPgStores(query)`).
* The CP `HostRecord` IS the relay-auth `HostRecord` (both re-export the frozen relay-contracts
* §4.2 shape), so `getById` is a direct pass-through; sessions/audit remap to the port shapes.
* - revocation / buckets -> Redis. `consumeOnce` is a single-use `SET NX` burn (Finding-4); the
* token bucket is an atomic Lua script (no read-modify-write race).
*
* SECURITY:
* - INV3: `accountId` is only ever read from authenticated material (the CP registry rows / the
* principal), never from client input. This adapter surfaces stored rows; it never fabricates an
* accountId from a request field.
* - Fail-closed: store/Redis errors are NOT swallowed — they reject and the enforcement pipeline
* denies the upgrade (deny-by-default).
* - INV9/INV10: audit rows carry metadata only (no keys, no terminal payload).
*/
import { createPgStores } from 'control-plane/src/store/pg.js'
import type { QueryFn } from 'control-plane/src/db/pool.js'
import type {
AuditEvent,
EnforceDeps,
HostRegistryPort,
RevocationStore,
SessionRegistryPort,
TokenBucketStore,
} from 'relay-auth'
import { NO_STEPUP_POLICY } from 'relay-auth/src/human/stepup/stepup.js'
// ── Redis surface (ioredis-compatible, injected loosely so tests can pass a mock) ────────────────
/**
* The minimal slice of an ioredis client this adapter uses. A real `ioredis` `Redis` instance
* structurally satisfies this (its methods are supersets), and a unit-test mock can implement it.
*/
export interface RedisLike {
exists(key: string): Promise<number>
set(key: string, value: string, mode?: 'NX'): Promise<string | null>
expireat(key: string, timestamp: number): Promise<number>
eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>
}
export interface RelayEnforceDepsConfig {
readonly query: QueryFn
readonly redis: RedisLike
}
// ── Redis key namespaces (opaque to relay-auth) ──────────────────────────────────────────────────
const REVOKED_PREFIX = 'revoked:' as const
const USED_PREFIX = 'used:' as const
const BUCKET_PREFIX = 'bucket:' as const
/** Buffer added to the computed refill window before a fully-refilled bucket key may be reaped. */
const BUCKET_TTL_BUFFER_SEC = 1
/**
* Atomic token bucket (lazy refill). One round-trip, no read-modify-write race:
* KEYS[1]=bucket key · ARGV: refillPerSec, burst(capacity), now(epoch sec), ttl(sec).
* Returns 1 when a token was available (and consumed), 0 when throttled.
*/
const TOKEN_BUCKET_LUA = `
local key = KEYS[1]
local refill = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local state = redis.call('HMGET', key, 't', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil then
tokens = burst
ts = now
end
local elapsed = now - ts
if elapsed > 0 then
tokens = math.min(burst, tokens + elapsed * refill)
end
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HSET', key, 't', tokens, 'ts', now)
redis.call('EXPIRE', key, ttl)
return allowed
`
/** Seconds a bucket needs to fully refill from empty, plus a small reap buffer. */
function bucketTtlSec(refillPerSec: number, burst: number): number {
const refillWindow = refillPerSec > 0 ? Math.ceil(burst / refillPerSec) : Math.ceil(burst)
return Math.max(1, refillWindow) + BUCKET_TTL_BUFFER_SEC
}
// ── Redis-backed ports ───────────────────────────────────────────────────────────────────────────
function redisRevocationStore(redis: RedisLike): RevocationStore {
return {
async isRevoked(jti) {
return (await redis.exists(REVOKED_PREFIX + jti)) > 0
},
async revokeJti(jti, exp) {
await redis.set(REVOKED_PREFIX + jti, '1')
await redis.expireat(REVOKED_PREFIX + jti, exp)
},
async consumeOnce(jti, exp) {
// First-use wins: SET NX returns 'OK' only when the key did not exist (Finding-4).
const set = await redis.set(USED_PREFIX + jti, '1', 'NX')
if (set !== 'OK') return false
await redis.expireat(USED_PREFIX + jti, exp)
return true
},
}
}
function redisTokenBucketStore(redis: RedisLike): TokenBucketStore {
return {
async take(key, refillPerSec, burst, now) {
const ttl = bucketTtlSec(refillPerSec, burst)
const allowed = await redis.eval(
TOKEN_BUCKET_LUA,
1,
BUCKET_PREFIX + key,
refillPerSec,
burst,
now,
ttl,
)
return Number(allowed) === 1
},
}
}
// ── Postgres-backed ports (over the CP repository adapter) ────────────────────────────────────────
/** Map a relay-auth `AuditEvent` onto the CP `audit_log` row shape (metadata only, INV10). */
function toAuditRow(e: AuditEvent): {
action: string
principalId: string
accountId: string
hostId: string | null
ts: string
meta: Record<string, string>
} {
const meta: Record<string, string> = {
outcome: e.outcome,
reason: e.reason,
remoteAddrHash: e.remoteAddrHash,
}
if (e.sessionId !== null) meta.sessionId = e.sessionId
if (e.jti !== null) meta.jti = e.jti
return {
action: e.action,
principalId: e.principalId,
accountId: e.accountId,
hostId: e.hostId,
ts: e.ts,
meta,
}
}
// ── Assembly ──────────────────────────────────────────────────────────────────────────────────────
/**
* Build the relay-auth `EnforceDeps` over the shared Postgres (`query`) + Redis (`redis`).
* `stepUpPolicyFor` returns `NO_STEPUP_POLICY` (staging single-operator; Phase 2 -> per-host WebAuthn).
*/
export function createRelayEnforceDeps({ query, redis }: RelayEnforceDepsConfig): EnforceDeps {
const stores = createPgStores(query)
const hosts: HostRegistryPort = {
// CP HostRecord === relay-auth HostRecord (both = relay-contracts §4.2) — direct pass-through.
getById: (hostId) => stores.hosts.get(hostId),
}
const sessions: SessionRegistryPort = {
getById: async (sessionId) => {
const rec = await stores.sessions.get(sessionId)
return rec === null ? null : { hostId: rec.hostId, accountId: rec.accountId }
},
}
return {
hosts,
sessions,
revocation: redisRevocationStore(redis),
buckets: redisTokenBucketStore(redis),
audit: {
append: (e) => stores.audit.append(toAuditRow(e)),
},
stepUpPolicyFor: () => NO_STEPUP_POLICY,
}
}

View File

@@ -0,0 +1,260 @@
/**
* B5 · unit tests for the STAGING operator token-mint (`POST /auth/mint`) and the P5 capability
* signing-key loader. Covers: the DPoP-bound short-lived token happy path (claims + PoP binding),
* the deny-by-default gates (bad password / unknown+revoked subdomain / malformed body / wrong
* method / oversized body), route claiming semantics, and PEM+base64 key loading round-trips.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { Readable } from 'node:stream'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { HostRecord } from 'control-plane/src/model/records.js'
import { verifyPaseto } from 'relay-auth/src/crypto/paseto.js'
import {
createAuthMintRoute,
loadSigningKeyFromEnv,
type SubdomainHostLookup,
} from '../src/servers/auth-mint.js'
const subtle = globalThis.crypto.subtle
const NOW = 1_800_000_000
const PASSWORD = 'staging-operator-secret'
// A syntactically valid base64url SHA-256 JWK thumbprint (exactly 43 chars).
const JKT = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNO12'.padEnd(43, 'X').slice(0, 43)
interface Loaded {
readonly signingKey: CryptoKey
readonly publicKey: CryptoKey
}
let keys: Loaded
beforeAll(async () => {
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
publicKey: CryptoKey
privateKey: CryptoKey
}
keys = { signingKey: kp.privateKey, publicKey: kp.publicKey }
})
function mkHost(overrides: Partial<HostRecord> = {}): HostRecord {
return {
hostId: 'host-1',
accountId: 'acct-1',
subdomain: 'alice',
agentPubkey: new Uint8Array(32),
enrollFpr: 'fpr-host-1',
status: 'online',
lastSeen: '2026-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
...overrides,
}
}
function fakeHosts(rec: HostRecord | null): SubdomainHostLookup {
return { getBySubdomain: async () => rec }
}
interface CapturedRes {
statusCode: number
headers: Record<string, string>
body: string
headersSent: boolean
writeHead(status: number, headers: Record<string, string>): CapturedRes
end(chunk?: string): void
}
function fakeRes(): { res: ServerResponse; captured: CapturedRes; done: Promise<void> } {
let resolveDone!: () => void
const done = new Promise<void>((r) => (resolveDone = r))
const captured: CapturedRes = {
statusCode: 0,
headers: {},
body: '',
headersSent: false,
writeHead(status, headers) {
this.statusCode = status
this.headers = headers
this.headersSent = true
return this
},
end(chunk?: string) {
if (chunk !== undefined) this.body += chunk
resolveDone()
},
}
return { res: captured as unknown as ServerResponse, captured, done }
}
function fakeReq(method: string, url: string, body?: string): IncomingMessage {
const chunks = body === undefined ? [] : [Buffer.from(body, 'utf8')]
const req = Readable.from(chunks) as unknown as IncomingMessage
;(req as { method?: string }).method = method
;(req as { url?: string }).url = url
return req
}
function mkRoute(hosts: SubdomainHostLookup): (req: IncomingMessage, res: ServerResponse) => boolean {
return createAuthMintRoute({
signingKey: keys.signingKey,
hosts,
operatorPassword: PASSWORD,
now: () => NOW,
onError: () => {},
})
}
async function post(
hosts: SubdomainHostLookup,
bodyObj: unknown,
): Promise<CapturedRes> {
const route = mkRoute(hosts)
const { res, captured, done } = fakeRes()
const claimed = route(fakeReq('POST', '/auth/mint', JSON.stringify(bodyObj)), res)
expect(claimed).toBe(true)
await done
return captured
}
describe('createAuthMintRoute — happy path', () => {
it('mints a short-lived capability token bound to the client jkt (INV3 identity from store)', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice' })
expect(captured.statusCode).toBe(200)
expect(captured.headers['cache-control']).toBe('no-store')
const parsedBody = JSON.parse(captured.body) as { token: string }
expect(typeof parsedBody.token).toBe('string')
const claims = (await verifyPaseto(parsedBody.token, keys.publicKey)) as {
sub: string
aud: string
host: string
rights: string[]
iat: number
exp: number
cnf: { jkt: string }
}
// Identity is the STORE row's, never the request body's (INV3).
expect(claims.sub).toBe('acct-1')
expect(claims.host).toBe('host-1')
expect(claims.aud).toBe('alice')
expect(claims.rights).toContain('attach')
// DPoP proof-of-possession binding to the client-provided thumbprint.
expect(claims.cnf.jkt).toBe(JKT)
// Short-lived (<= 60 s).
expect(claims.iat).toBe(NOW)
expect(claims.exp - claims.iat).toBeLessThanOrEqual(60)
expect(claims.exp - claims.iat).toBeGreaterThan(0)
// The token itself must never leak into logs — asserted by construction (no console here).
})
})
describe('createAuthMintRoute — deny by default', () => {
it('rejects a wrong password with 401 (no token)', async () => {
const captured = await post(fakeHosts(mkHost()), { password: 'wrong', jkt: JKT, subdomain: 'alice' })
expect(captured.statusCode).toBe(401)
expect(captured.body).not.toContain('token')
})
it('rejects an unknown subdomain with 404', async () => {
const captured = await post(fakeHosts(null), { password: PASSWORD, jkt: JKT, subdomain: 'ghost' })
expect(captured.statusCode).toBe(404)
})
it('rejects a revoked host with 403 (INV12: revoked never mints)', async () => {
const captured = await post(
fakeHosts(mkHost({ status: 'revoked', revokedAt: '2026-02-01T00:00:00.000Z' })),
{ password: PASSWORD, jkt: JKT, subdomain: 'alice' },
)
expect(captured.statusCode).toBe(403)
})
it('rejects a malformed jkt with 400', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: 'too-short', subdomain: 'alice' })
expect(captured.statusCode).toBe(400)
})
it('rejects a malformed subdomain with 400', async () => {
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'Not_Valid!' })
expect(captured.statusCode).toBe(400)
})
it('rejects invalid JSON with 400', async () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured, done } = fakeRes()
const claimed = route(fakeReq('POST', '/auth/mint', '{not json'), res)
expect(claimed).toBe(true)
await done
expect(captured.statusCode).toBe(400)
})
it('rejects an oversized body with 413', async () => {
const big = 'x'.repeat(5000)
const captured = await post(fakeHosts(mkHost()), { password: PASSWORD, jkt: JKT, subdomain: 'alice', pad: big })
expect(captured.statusCode).toBe(413)
})
})
describe('createAuthMintRoute — routing semantics', () => {
it('claims /auth/mint but 405s a non-POST method', () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured } = fakeRes()
const claimed = route(fakeReq('GET', '/auth/mint', undefined), res)
expect(claimed).toBe(true)
expect(captured.statusCode).toBe(405)
})
it('does NOT claim a non-matching path (returns false, response untouched)', () => {
const route = mkRoute(fakeHosts(mkHost()))
const { res, captured } = fakeRes()
const claimed = route(fakeReq('POST', '/index.html', undefined), res)
expect(claimed).toBe(false)
expect(captured.statusCode).toBe(0)
})
it('claims /auth/mint even with a query string', () => {
const route = mkRoute(fakeHosts(null))
const { res } = fakeRes()
const claimed = route(fakeReq('GET', '/auth/mint?foo=1', undefined), res)
expect(claimed).toBe(true)
})
})
describe('loadSigningKeyFromEnv', () => {
async function exportPkcs8Pem(): Promise<{ pem: string; b64: string; publicKey: CryptoKey }> {
const kp = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as {
publicKey: CryptoKey
privateKey: CryptoKey
}
const der = new Uint8Array(await subtle.exportKey('pkcs8', kp.privateKey))
const b64 = Buffer.from(der).toString('base64')
const pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join('\n')}\n-----END PRIVATE KEY-----\n`
return { pem, b64, publicKey: kp.publicKey }
}
async function canSign(priv: CryptoKey, pub: CryptoKey): Promise<boolean> {
const data = new Uint8Array([1, 2, 3, 4])
const sig = new Uint8Array(await subtle.sign({ name: 'Ed25519' }, priv, data))
return subtle.verify({ name: 'Ed25519' }, pub, sig, data)
}
it('loads a PKCS#8 PEM into a usable signing key', async () => {
const { pem, publicKey } = await exportPkcs8Pem()
const key = await loadSigningKeyFromEnv(pem)
expect(await canSign(key, publicKey)).toBe(true)
})
it('loads a bare base64 PKCS#8 DER into a usable signing key', async () => {
const { b64, publicKey } = await exportPkcs8Pem()
const key = await loadSigningKeyFromEnv(b64)
expect(await canSign(key, publicKey)).toBe(true)
})
it('throws on an empty value', async () => {
await expect(loadSigningKeyFromEnv(' ')).rejects.toThrow()
})
it('throws on a non-key value (never leaking material)', async () => {
await expect(loadSigningKeyFromEnv('not-a-real-key')).rejects.toThrow()
})
})

View File

@@ -0,0 +1,109 @@
/**
* Unit tests for the pure static-file resolver (D1). Covers index resolution, MIME mapping, query
* stripping, and the STRICT path-traversal guard (raw `..`, percent-encoded `%2e%2e`, and NUL byte
* all reject — even when the escaped target file genuinely exists on disk).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { serveStatic } from '../src/servers/static-web.js'
const INDEX_HTML = '<!doctype html><title>idx</title>'
const PAIR_HTML = '<!doctype html><title>pair</title>'
const INDEX_JS = 'export const x = 1\n'
const XTERM_CSS = 'body{margin:0}'
const SECRET = 'TOP SECRET — outside root'
let base: string // temp parent dir (holds the secret sibling)
let root: string // the static root passed to serveStatic
beforeAll(() => {
base = mkdtempSync(join(tmpdir(), 'relay-static-'))
root = join(base, 'public')
mkdirSync(join(root, 'build'), { recursive: true })
writeFileSync(join(root, 'index.html'), INDEX_HTML)
writeFileSync(join(root, 'pair.html'), PAIR_HTML)
writeFileSync(join(root, 'build', 'index.js'), INDEX_JS)
writeFileSync(join(root, 'build', 'xterm.css'), XTERM_CSS)
// A real file OUTSIDE root — the traversal target the guard must never reach.
writeFileSync(join(base, 'secret.txt'), SECRET)
})
afterAll(() => {
rmSync(base, { recursive: true, force: true })
})
describe('serveStatic — index resolution', () => {
it('serves index.html at "/"', () => {
const res = serveStatic(root, '/')
expect(res).not.toBeNull()
expect(res?.status).toBe(200)
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
expect(res?.headers['content-length']).toBe(String(Buffer.byteLength(INDEX_HTML)))
})
it('serves an explicit .html page', () => {
const res = serveStatic(root, '/pair.html')
expect(res?.status).toBe(200)
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
expect(res?.body.toString('utf8')).toBe(PAIR_HTML)
})
it('strips the query string before resolving', () => {
const res = serveStatic(root, '/index.html?join=abc123')
expect(res?.status).toBe(200)
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
})
it('strips the hash fragment before resolving', () => {
const res = serveStatic(root, '/#section')
expect(res?.status).toBe(200)
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
})
})
describe('serveStatic — MIME types', () => {
it('maps /build/*.js to text/javascript', () => {
const res = serveStatic(root, '/build/index.js')
expect(res?.status).toBe(200)
expect(res?.headers['content-type']).toBe('text/javascript; charset=utf-8')
expect(res?.body.toString('utf8')).toBe(INDEX_JS)
})
it('maps .css to text/css', () => {
const res = serveStatic(root, '/build/xterm.css')
expect(res?.status).toBe(200)
expect(res?.headers['content-type']).toBe('text/css; charset=utf-8')
})
})
describe('serveStatic — traversal guard (returns null)', () => {
it('blocks a raw ".." escape even though the target exists', () => {
// Sanity: the target file really is readable outside root, so a null result proves the guard.
expect(serveStatic(root, '/../secret.txt')).toBeNull()
})
it('blocks a deep ".." escape', () => {
expect(serveStatic(root, '/build/../../secret.txt')).toBeNull()
})
it('blocks percent-encoded ".." (%2e%2e)', () => {
expect(serveStatic(root, '/%2e%2e/secret.txt')).toBeNull()
})
it('rejects a NUL byte in the path', () => {
expect(serveStatic(root, '/index.html%00.js')).toBeNull()
})
})
describe('serveStatic — misses (returns null)', () => {
it('returns null for a nonexistent file', () => {
expect(serveStatic(root, '/nope.html')).toBeNull()
})
it('returns null for a directory (no listing)', () => {
expect(serveStatic(root, '/build')).toBeNull()
})
})

View File

@@ -0,0 +1,158 @@
/**
* B2 tests — registry-backed MtlsVerifier (INV4/INV14, fail-closed).
*
* Two layers, mirroring relay-auth/test/mtls.test.ts:
* 1. REAL X.509 path: a self-signed root → leaf chain (relay-auth's committed mTLS fixtures) fed to
* `verifyPeer` as DER bytes, exercising the production `defaultParseX509` chain walk + DER→PEM
* conversion end-to-end against a fake host registry.
* 2. Deterministic seam: an injected `ParseCert` isolates the DER→PEM conversion + registry-gating +
* fail-closed mapping without depending on fixture contents.
*/
import { readFileSync } from 'node:fs'
import { X509Certificate } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { describe, it, expect } from 'vitest'
import { defaultParseX509, spiffeIdFor, type ParseCert, type ParsedCert } from 'relay-auth'
import type { HostRegistryPort } from 'relay-auth'
import { createMtlsVerifier, derToPem } from '../../src/wiring/mtls-verifier.js'
import { fakeHostRegistry, makeHostRecord } from '../../src/wiring/memory-stores.js'
// Reuse the committed real mTLS fixtures (leaf chains to ca-chain; SPIFFE account/acct-A/host/host-1).
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '../../../relay-auth/test/fixtures/mtls')
const readFixture = (name: string): string => readFileSync(join(FIXTURES, name), 'utf8')
const leafPem = readFixture('leaf.pem')
const caChainPem = readFixture('ca-chain.pem')
const foreignLeafPem = readFixture('foreign-leaf.pem')
const leafDer = new Uint8Array(new X509Certificate(leafPem).raw)
const foreignDer = new Uint8Array(new X509Certificate(foreignLeafPem).raw)
// A time strictly inside the leaf's validity window (fixtures are long-lived; do not hardcode).
const parsedLeaf = defaultParseX509(leafPem, caChainPem)
const IN_WINDOW = parsedLeaf.notBefore + 60
const enrolledHosts = (): HostRegistryPort =>
fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1')])
describe('derToPem (DER → PEM)', () => {
it('wraps DER as a 64-column PEM CERTIFICATE block that round-trips back to the same DER', () => {
const pem = derToPem(leafDer)
expect(pem.startsWith('-----BEGIN CERTIFICATE-----\n')).toBe(true)
expect(pem.trimEnd().endsWith('-----END CERTIFICATE-----')).toBe(true)
// No base64 body line exceeds the PEM 64-column width.
const body = pem.split('\n').slice(1, -2)
for (const line of body) expect(line.length).toBeLessThanOrEqual(64)
// Parsing the produced PEM yields byte-identical DER.
expect(new Uint8Array(new X509Certificate(pem).raw)).toEqual(leafDer)
})
})
describe('createMtlsVerifier — real X.509 path (INV14)', () => {
it('accepts an enrolled, in-date leaf chaining to the pinned CA → {hostId, accountId}', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
})
it('refuses a leaf signed by a foreign CA not in the pinned bundle → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(foreignDer)).toBeNull()
})
it('refuses a valid leaf whose host is NOT in the registry (INV4) → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: fakeHostRegistry([]), now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses when the registry account ≠ the cert SPIFFE account → null', async () => {
const hosts = fakeHostRegistry([makeHostRecord('acct-B', 'host-1', 'sub-host-1')])
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses a revoked host (INV12/registry gate) → null', async () => {
const hosts = fakeHostRegistry([makeHostRecord('acct-A', 'host-1', 'sub-host-1', 'revoked')])
const v = createMtlsVerifier({ caChainPem, hosts, now: () => IN_WINDOW })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses an expired leaf → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notAfter + 100 })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
it('refuses a not-yet-valid leaf → null', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => parsedLeaf.notBefore - 100 })
expect(await v.verifyPeer(leafDer)).toBeNull()
})
})
describe('createMtlsVerifier — fail-closed on malformed / absent input', () => {
it('returns null for an empty DER (no client cert presented)', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(new Uint8Array(0))).toBeNull()
})
it('returns null for garbage DER bytes that are not a certificate', async () => {
const v = createMtlsVerifier({ caChainPem, hosts: enrolledHosts(), now: () => IN_WINDOW })
expect(await v.verifyPeer(new Uint8Array([1, 2, 3, 4, 5]))).toBeNull()
})
})
describe('createMtlsVerifier — construction validation (INV14)', () => {
it('throws when the CA bundle is empty', () => {
expect(() => createMtlsVerifier({ caChainPem: '', hosts: enrolledHosts(), now: () => IN_WINDOW })).toThrow()
})
it('throws when the CA bundle has no PEM CERTIFICATE block', () => {
expect(() =>
createMtlsVerifier({ caChainPem: 'not a certificate', hosts: enrolledHosts(), now: () => IN_WINDOW }),
).toThrow()
})
})
// ── Deterministic seam: isolate DER→PEM + registry gating from fixture contents ──────────────────
const okParsed = (spiffeUri: string): ParsedCert => ({
spiffeUri,
notBefore: 0,
notAfter: 4_000_000_000,
chainValid: true,
})
describe('createMtlsVerifier — ParseCert seam', () => {
it('feeds verifyAgentCert the exact PEM produced by derToPem from the input DER', async () => {
let seenLeafPem: string | null = null
const capturingParse: ParseCert = (leaf) => {
seenLeafPem = leaf
return okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com'))
}
const der = new Uint8Array([9, 8, 7, 6, 5, 4, 3, 2, 1])
const v = createMtlsVerifier({
caChainPem,
hosts: enrolledHosts(),
now: () => 1_000_000,
parse: capturingParse,
})
expect(await v.verifyPeer(der)).toEqual({ hostId: 'host-1', accountId: 'acct-A' })
expect(seenLeafPem).toBe(derToPem(der))
})
it('fails closed (null) and reports to onError when the registry lookup throws', async () => {
const errors: unknown[] = []
const throwingHosts: HostRegistryPort = {
getById: async () => {
throw new Error('registry unavailable')
},
}
const v = createMtlsVerifier({
caChainPem,
hosts: throwingHosts,
now: () => 1_000_000,
onError: (e) => errors.push(e),
parse: () => okParsed(spiffeIdFor('acct-A', 'host-1', 'example.com')),
})
expect(await v.verifyPeer(new Uint8Array([1, 2, 3]))).toBeNull()
expect(errors).toHaveLength(1)
})
})

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import { RELAY_REVOCATIONS_CHANNEL, type KillSignal } from 'relay-contracts'
import {
startRevocationSubscriber,
type ActiveTunnelRef,
type RedisSubscriber,
} from '../../src/wiring/revocation-subscriber.js'
/** Fake ioredis subscriber-mode client: records subscribe/unsubscribe and lets a test emit frames. */
class FakeRedisSubscriber implements RedisSubscriber {
readonly subscribed: string[] = []
readonly unsubscribed: string[] = []
private readonly handlers = new Set<(channel: string, message: string) => void>()
async subscribe(channel: string): Promise<number> {
this.subscribed.push(channel)
return this.subscribed.length
}
async unsubscribe(channel: string): Promise<number> {
this.unsubscribed.push(channel)
return this.unsubscribed.length
}
on(_event: 'message', listener: (channel: string, message: string) => void): this {
this.handlers.add(listener)
return this
}
off(_event: 'message', listener: (channel: string, message: string) => void): this {
this.handlers.delete(listener)
return this
}
/** Test driver: deliver a raw pub/sub frame to every registered listener. */
emit(channel: string, message: string): void {
for (const h of [...this.handlers]) h(channel, message)
}
get listenerCount(): number {
return this.handlers.size
}
}
/** Fake relay node: a live-tunnel map + a closeStream that records + removes the host (models the
* real whole-host teardown mutating the tunnel set, so snapshot-safety is exercised). */
function makeNode(initial: readonly ActiveTunnelRef[]) {
const tunnels = new Map(initial.map((t) => [t.hostId, t]))
const closed: string[] = []
return {
closed,
// Live iterator on purpose: the subscriber must snapshot before tearing down.
activeTunnels: () => tunnels.values(),
closeStream: (hostId: string): void => {
closed.push(hostId)
tunnels.delete(hostId)
},
}
}
const AT = 1_700_000_000
function killMessage(signal: KillSignal): string {
return JSON.stringify(signal)
}
describe('startRevocationSubscriber', () => {
it('subscribes to the relay:revocations channel on start', () => {
const redisSubscriber = new FakeRedisSubscriber()
startRevocationSubscriber({ redisSubscriber, node: makeNode([]) })
expect(redisSubscriber.subscribed).toEqual([RELAY_REVOCATIONS_CHANNEL])
expect(redisSubscriber.listenerCount).toBe(1)
})
it('tears down only the host a host-scoped signal names', () => {
const hostA = randomUUID()
const hostB = randomUUID()
const redisSubscriber = new FakeRedisSubscriber()
const node = makeNode([
{ hostId: hostA, accountId: randomUUID() },
{ hostId: hostB, accountId: randomUUID() },
])
const onApplied = vi.fn()
startRevocationSubscriber({ redisSubscriber, node, onApplied })
redisSubscriber.emit(
RELAY_REVOCATIONS_CHANNEL,
killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'compromised' }),
)
expect(node.closed).toEqual([hostA])
expect(onApplied).toHaveBeenCalledTimes(1)
expect(onApplied).toHaveBeenCalledWith(expect.objectContaining({ at: AT }), 1)
})
it('tears down every host under an account-scoped signal, leaving other accounts running', () => {
const acct1 = randomUUID()
const acct2 = randomUUID()
const hostA = randomUUID()
const hostB = randomUUID()
const hostC = randomUUID()
const redisSubscriber = new FakeRedisSubscriber()
const node = makeNode([
{ hostId: hostA, accountId: acct1 },
{ hostId: hostB, accountId: acct1 },
{ hostId: hostC, accountId: acct2 },
])
startRevocationSubscriber({ redisSubscriber, node })
redisSubscriber.emit(
RELAY_REVOCATIONS_CHANNEL,
killMessage({ scope: { kind: 'account', accountId: acct1 }, at: AT, reason: 'billing' }),
)
expect(node.closed.sort()).toEqual([hostA, hostB].sort())
expect(node.closed).not.toContain(hostC)
})
it('tears down every live host on a global-scoped signal', () => {
const hosts = [
{ hostId: randomUUID(), accountId: randomUUID() },
{ hostId: randomUUID(), accountId: randomUUID() },
{ hostId: randomUUID(), accountId: randomUUID() },
]
const redisSubscriber = new FakeRedisSubscriber()
const node = makeNode(hosts)
startRevocationSubscriber({ redisSubscriber, node })
redisSubscriber.emit(
RELAY_REVOCATIONS_CHANNEL,
killMessage({ scope: { kind: 'global' }, at: AT, reason: 'kill-switch' }),
)
expect(node.closed.sort()).toEqual(hosts.map((h) => h.hostId).sort())
})
it('is a no-op for a host-scoped signal naming a host this node does not serve', () => {
const served = randomUUID()
const redisSubscriber = new FakeRedisSubscriber()
const node = makeNode([{ hostId: served, accountId: randomUUID() }])
const onApplied = vi.fn()
startRevocationSubscriber({ redisSubscriber, node, onApplied })
redisSubscriber.emit(
RELAY_REVOCATIONS_CHANNEL,
killMessage({ scope: { kind: 'host', hostId: randomUUID() }, at: AT, reason: 'other-node' }),
)
expect(node.closed).toEqual([])
expect(onApplied).toHaveBeenCalledWith(expect.anything(), 0)
})
it('drops a malformed (non-JSON) message: no teardown, counted as dropped', () => {
const redisSubscriber = new FakeRedisSubscriber()
const node = makeNode([{ hostId: randomUUID(), accountId: randomUUID() }])
const onDropped = vi.fn()
const onApplied = vi.fn()
startRevocationSubscriber({ redisSubscriber, node, onDropped, onApplied })
redisSubscriber.emit(RELAY_REVOCATIONS_CHANNEL, '{ this is not json')
expect(node.closed).toEqual([])
expect(onDropped).toHaveBeenCalledTimes(1)
expect(onApplied).not.toHaveBeenCalled()
})
it('drops a schema-invalid signal (non-uuid host / missing fields): no teardown', () => {
const redisSubscriber = new FakeRedisSubscriber()
const node = makeNode([{ hostId: randomUUID(), accountId: randomUUID() }])
const onDropped = vi.fn()
startRevocationSubscriber({ redisSubscriber, node, onDropped })
// hostId is not a UUID → RevocationScopeSchema rejects.
redisSubscriber.emit(
RELAY_REVOCATIONS_CHANNEL,
JSON.stringify({ scope: { kind: 'host', hostId: 'not-a-uuid' }, at: AT, reason: 'x' }),
)
// Missing `at` → KillSignalSchema rejects.
redisSubscriber.emit(
RELAY_REVOCATIONS_CHANNEL,
JSON.stringify({ scope: { kind: 'global' }, reason: 'x' }),
)
expect(node.closed).toEqual([])
expect(onDropped).toHaveBeenCalledTimes(2)
})
it('ignores messages published on a different channel', () => {
const hostA = randomUUID()
const redisSubscriber = new FakeRedisSubscriber()
const node = makeNode([{ hostId: hostA, accountId: randomUUID() }])
const onDropped = vi.fn()
const onApplied = vi.fn()
startRevocationSubscriber({ redisSubscriber, node, onDropped, onApplied })
redisSubscriber.emit(
'some:other:channel',
killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'wrong-channel' }),
)
expect(node.closed).toEqual([])
expect(onDropped).not.toHaveBeenCalled()
expect(onApplied).not.toHaveBeenCalled()
})
it('close() removes the message listener, unsubscribes, and is idempotent', () => {
const hostA = randomUUID()
const redisSubscriber = new FakeRedisSubscriber()
const node = makeNode([{ hostId: hostA, accountId: randomUUID() }])
const sub = startRevocationSubscriber({ redisSubscriber, node })
sub.close()
sub.close() // idempotent — no throw, no double-unsubscribe
expect(redisSubscriber.unsubscribed).toEqual([RELAY_REVOCATIONS_CHANNEL])
expect(redisSubscriber.listenerCount).toBe(0)
// A frame delivered after close must not tear anything down.
redisSubscriber.emit(
RELAY_REVOCATIONS_CHANNEL,
killMessage({ scope: { kind: 'host', hostId: hostA }, at: AT, reason: 'after-close' }),
)
expect(node.closed).toEqual([])
})
it('routes a rejected subscribe() to onError instead of swallowing it', async () => {
const failing: RedisSubscriber = {
subscribe: () => Promise.reject(new Error('redis down')),
unsubscribe: async () => 0,
on: () => failing,
off: () => failing,
}
const onError = vi.fn()
startRevocationSubscriber({ redisSubscriber: failing, node: makeNode([]), onError })
await Promise.resolve() // let the rejected subscribe() microtask settle
expect(onError).toHaveBeenCalledTimes(1)
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error)
})
})

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import type { HostRecord, HostStatus } from 'control-plane/src/model/records.js'
import {
createStoreRouteResolver,
type HostSubdomainLookup,
} from '../../src/wiring/route-resolver.js'
const NOW_ISO = '2026-07-06T00:00:00.000Z'
/** Build a full §4.2 HostRecord fixture keyed by its subdomain label. */
function makeHost(subdomain: string, status: HostStatus = 'online'): HostRecord {
return {
hostId: randomUUID(),
accountId: randomUUID(),
subdomain,
agentPubkey: new Uint8Array([1, 2, 3]),
enrollFpr: 'fpr:' + subdomain,
status,
lastSeen: NOW_ISO,
createdAt: NOW_ISO,
revokedAt: status === 'revoked' ? NOW_ISO : null,
}
}
/** A minimal HostStore lookup fake: exact-match subdomain → record, else null (matches pg.ts). */
function fakeHosts(records: readonly HostRecord[]): HostSubdomainLookup {
const bySub = new Map(records.map((r) => [r.subdomain, r]))
return {
getBySubdomain: vi.fn(async (subdomain: string) => bySub.get(subdomain) ?? null),
}
}
describe('createStoreRouteResolver', () => {
it('resolves a known online host to {hostId, accountId, subdomain}', async () => {
// Arrange
const host = makeHost('alice')
const resolver = createStoreRouteResolver({ hosts: fakeHosts([host]) })
// Act
const resolved = await resolver.resolveSubdomain('alice')
// Assert
expect(resolved).toEqual({
hostId: host.hostId,
accountId: host.accountId,
subdomain: 'alice',
})
})
it('returns null (fail-closed) for an unknown subdomain', async () => {
// Arrange
const resolver = createStoreRouteResolver({ hosts: fakeHosts([makeHost('alice')]) })
// Act
const resolved = await resolver.resolveSubdomain('nobody')
// Assert
expect(resolved).toBeNull()
})
it('fails closed (returns null) for a revoked host even though the row still exists', async () => {
// Arrange
const resolver = createStoreRouteResolver({ hosts: fakeHosts([makeHost('alice', 'revoked')]) })
// Act
const resolved = await resolver.resolveSubdomain('alice')
// Assert
expect(resolved).toBeNull()
})
it('resolves offline and draining hosts (only revoked fails closed)', async () => {
// Arrange
const offline = makeHost('bob', 'offline')
const draining = makeHost('carol', 'draining')
const resolver = createStoreRouteResolver({ hosts: fakeHosts([offline, draining]) })
// Act + Assert
expect(await resolver.resolveSubdomain('bob')).toEqual({
hostId: offline.hostId,
accountId: offline.accountId,
subdomain: 'bob',
})
expect(await resolver.resolveSubdomain('carol')).toEqual({
hostId: draining.hostId,
accountId: draining.accountId,
subdomain: 'carol',
})
})
it('derives identity only from the store record, not the caller (INV3)', async () => {
// Arrange: the stored record carries the authoritative accountId/hostId.
const host = makeHost('alice')
const resolver = createStoreRouteResolver({ hosts: fakeHosts([host]) })
// Act
const resolved = await resolver.resolveSubdomain('alice')
// Assert: values come from the record, never fabricated from the input label.
expect(resolved?.accountId).toBe(host.accountId)
expect(resolved?.hostId).toBe(host.hostId)
})
it('passes the exact subdomain label through to getBySubdomain', async () => {
// Arrange
const hosts = fakeHosts([makeHost('alice')])
const resolver = createStoreRouteResolver({ hosts })
// Act
await resolver.resolveSubdomain('alice')
// Assert
expect(hosts.getBySubdomain).toHaveBeenCalledWith('alice')
expect(hosts.getBySubdomain).toHaveBeenCalledTimes(1)
})
it('propagates store errors (no silent swallow)', async () => {
// Arrange: a store that throws (e.g. DB unavailable) must NOT be masked as a null resolve.
const boom = new Error('db down')
const resolver = createStoreRouteResolver({
hosts: { getBySubdomain: vi.fn(async () => { throw boom }) },
})
// Act + Assert
await expect(resolver.resolveSubdomain('alice')).rejects.toThrow('db down')
})
})

View File

@@ -0,0 +1,265 @@
import { describe, it, expect } from 'vitest'
import type { AuditEvent } from 'relay-auth'
import { NO_STEPUP_POLICY } from 'relay-auth/src/human/stepup/stepup.js'
import type { QueryFn } from 'control-plane/src/db/pool.js'
import { createRelayEnforceDeps, type RedisLike } from '../../src/wiring/stores-pg.js'
// ── Fakes ─────────────────────────────────────────────────────────────────────────────────────
interface QueryCall {
readonly sql: string
readonly params: readonly unknown[]
}
/** A `QueryFn` that records every call and returns rows chosen by `rowsFor`. */
function makeFakeQuery(
rowsFor: (sql: string, params: readonly unknown[]) => unknown[],
): { query: QueryFn; calls: QueryCall[] } {
const calls: QueryCall[] = []
const query: QueryFn = async <T>(sql: string, params: readonly unknown[]): Promise<readonly T[]> => {
calls.push({ sql, params })
return rowsFor(sql, params) as T[]
}
return { query, calls }
}
interface RedisCall {
readonly method: string
readonly args: readonly unknown[]
}
/** A `RedisLike` mock whose methods can be overridden per test; every call is recorded. */
function makeMockRedis(over: Partial<RedisLike> = {}): RedisLike & { calls: RedisCall[] } {
const calls: RedisCall[] = []
const record = (method: string, args: unknown[]): void => void calls.push({ method, args })
const redis: RedisLike = {
exists: over.exists ?? (async (key) => (record('exists', [key]), 0)),
set: over.set ?? (async (key, value, mode) => (record('set', [key, value, mode]), 'OK')),
expireat: over.expireat ?? (async (key, ts) => (record('expireat', [key, ts]), 1)),
eval: over.eval ?? (async (...args) => (record('eval', args), 1)),
}
return Object.assign(redis, { calls })
}
const NOOP_QUERY: QueryFn = async () => []
// ── hosts (Postgres pass-through) ───────────────────────────────────────────────────────────────
describe('createRelayEnforceDeps.hosts', () => {
it('maps a CP host row to a relay-auth HostRecord', async () => {
const { query, calls } = makeFakeQuery(() => [
{
host_id: 'host-1',
account_id: 'acct-1',
subdomain: 'demo',
agent_pubkey: Buffer.from([1, 2, 3]),
enroll_fpr: 'fpr-1',
status: 'online',
last_seen: '2026-07-06T00:00:00.000Z',
created_at: '2026-07-05T00:00:00.000Z',
revoked_at: null,
},
])
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
const host = await deps.hosts.getById('host-1')
expect(host).not.toBeNull()
expect(host?.hostId).toBe('host-1')
expect(host?.accountId).toBe('acct-1')
expect(host?.subdomain).toBe('demo')
expect(host?.status).toBe('online')
expect(host?.revokedAt).toBeNull()
expect(Array.from(host?.agentPubkey ?? [])).toEqual([1, 2, 3])
// accountId came from the stored row, never fabricated (INV3).
expect(calls[0]?.params).toEqual(['host-1'])
})
it('returns null for an unknown host', async () => {
const { query } = makeFakeQuery(() => [])
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
expect(await deps.hosts.getById('nope')).toBeNull()
})
})
// ── sessions (Postgres, remapped to the port shape) ──────────────────────────────────────────────
describe('createRelayEnforceDeps.sessions', () => {
it('maps a session row to { hostId, accountId }', async () => {
const { query } = makeFakeQuery(() => [
{
session_id: 'sess-1',
host_id: 'host-1',
account_id: 'acct-1',
created_at: '2026-07-06T00:00:00.000Z',
last_attach_at: '2026-07-06T00:00:00.000Z',
},
])
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
expect(await deps.sessions.getById('sess-1')).toEqual({ hostId: 'host-1', accountId: 'acct-1' })
})
it('returns null for an unknown session', async () => {
const { query } = makeFakeQuery(() => [])
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
expect(await deps.sessions.getById('nope')).toBeNull()
})
})
// ── revocation (Redis) ───────────────────────────────────────────────────────────────────────────
describe('createRelayEnforceDeps.revocation', () => {
it('isRevoked is true iff the revoked:<jti> key exists', async () => {
const present = createRelayEnforceDeps({
query: NOOP_QUERY,
redis: makeMockRedis({ exists: async () => 1 }),
})
const absent = createRelayEnforceDeps({
query: NOOP_QUERY,
redis: makeMockRedis({ exists: async () => 0 }),
})
expect(await present.revocation.isRevoked('j1')).toBe(true)
expect(await absent.revocation.isRevoked('j1')).toBe(false)
})
it('revokeJti sets revoked:<jti> and pins its expiry to exp', async () => {
const redis = makeMockRedis()
const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis })
await deps.revocation.revokeJti('j1', 1_800_000_000)
expect(redis.calls).toEqual([
{ method: 'set', args: ['revoked:j1', '1', undefined] },
{ method: 'expireat', args: ['revoked:j1', 1_800_000_000] },
])
})
it('consumeOnce burns the jti: first use wins (SET NX), replays lose', async () => {
let existing = false
const redis = makeMockRedis({
set: async (_k, _v, mode) => {
if (mode !== 'NX') return 'OK'
if (existing) return null
existing = true
return 'OK'
},
})
const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis })
expect(await deps.revocation.consumeOnce('j1', 1_800_000_000)).toBe(true)
expect(await deps.revocation.consumeOnce('j1', 1_800_000_000)).toBe(false)
// expiry is only pinned on the winning first use.
expect(redis.calls.filter((c) => c.method === 'expireat')).toEqual([
{ method: 'expireat', args: ['used:j1', 1_800_000_000] },
])
})
})
// ── buckets (Redis token bucket) ─────────────────────────────────────────────────────────────────
describe('createRelayEnforceDeps.buckets', () => {
it('namespaces the key and forwards refill/burst/now + a computed ttl to the Lua script', async () => {
let evalArgs: readonly unknown[] = []
const redis = makeMockRedis({
eval: async (...args) => {
evalArgs = args
return 1
},
})
const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis })
const ok = await deps.buckets.take('connect:acct:a1', 1, 60, 1000)
expect(ok).toBe(true)
// eval(script, numKeys, key, refillPerSec, burst, now, ttl)
expect(evalArgs[1]).toBe(1) // numKeys
expect(evalArgs[2]).toBe('bucket:connect:acct:a1')
expect(evalArgs[3]).toBe(1) // refillPerSec
expect(evalArgs[4]).toBe(60) // burst
expect(evalArgs[5]).toBe(1000) // now
expect(evalArgs[6]).toBe(61) // ttl = ceil(60/1) + 1
})
it('maps a 0 result to throttled (false)', async () => {
const deps = createRelayEnforceDeps({
query: NOOP_QUERY,
redis: makeMockRedis({ eval: async () => 0 }),
})
expect(await deps.buckets.take('preauth:ip:x', 1, 60, 1000)).toBe(false)
})
})
// ── audit (Postgres audit_log, metadata only) ────────────────────────────────────────────────────
describe('createRelayEnforceDeps.audit', () => {
it('maps an AuditEvent onto the audit_log row, folding non-column fields into meta (INV10)', async () => {
const { query, calls } = makeFakeQuery(() => [])
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
const event: AuditEvent = {
ts: '2026-07-06T00:00:00.000Z',
action: 'attach',
principalId: 'cred-1',
accountId: 'acct-1',
hostId: 'host-1',
sessionId: 'sess-1',
jti: 'jti-1',
outcome: 'allow',
reason: 'ok',
remoteAddrHash: 'iphash',
}
await deps.audit.append(event)
const params = calls[0]?.params ?? []
expect(params[0]).toBe('attach') // action
expect(params[1]).toBe('cred-1') // principal_id
expect(params[2]).toBe('acct-1') // account_id
expect(params[3]).toBe('host-1') // host_id
expect(params[4]).toBe('2026-07-06T00:00:00.000Z') // ts
expect(JSON.parse(params[5] as string)).toEqual({
outcome: 'allow',
reason: 'ok',
remoteAddrHash: 'iphash',
sessionId: 'sess-1',
jti: 'jti-1',
})
})
it('omits null sessionId/jti from meta', async () => {
const { query, calls } = makeFakeQuery(() => [])
const deps = createRelayEnforceDeps({ query, redis: makeMockRedis() })
const event: AuditEvent = {
ts: '2026-07-06T00:00:00.000Z',
action: 'attach',
principalId: '',
accountId: '',
hostId: null,
sessionId: null,
jti: null,
outcome: 'deny',
reason: 'bad_origin',
remoteAddrHash: 'iphash',
}
await deps.audit.append(event)
expect(calls[0]?.params[3]).toBeNull() // host_id
expect(JSON.parse((calls[0]?.params[5] as string) ?? '{}')).toEqual({
outcome: 'deny',
reason: 'bad_origin',
remoteAddrHash: 'iphash',
})
})
})
// ── stepUpPolicyFor (staging) ────────────────────────────────────────────────────────────────────
describe('createRelayEnforceDeps.stepUpPolicyFor', () => {
it('returns NO_STEPUP_POLICY (staging single-operator)', () => {
const deps = createRelayEnforceDeps({ query: NOOP_QUERY, redis: makeMockRedis() })
// host argument is irrelevant in staging; the policy is never-required.
expect(deps.stepUpPolicyFor({} as never)).toBe(NO_STEPUP_POLICY)
expect(deps.stepUpPolicyFor({} as never).required).toBe(false)
})
})