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:
93
relay-run/src/wiring/mtls-verifier.ts
Normal file
93
relay-run/src/wiring/mtls-verifier.ts
Normal 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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
121
relay-run/src/wiring/revocation-subscriber.ts
Normal file
121
relay-run/src/wiring/revocation-subscriber.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
40
relay-run/src/wiring/route-resolver.ts
Normal file
40
relay-run/src/wiring/route-resolver.ts
Normal 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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
197
relay-run/src/wiring/stores-pg.ts
Normal file
197
relay-run/src/wiring/stores-pg.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user