feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
75
agent/src/log/logger.ts
Normal file
75
agent/src/log/logger.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Structured redacting logger — PLAN_RELAY_AGENT T2 (INV9: secrets never logged, no console.log).
|
||||
*
|
||||
* Meta keys whose name matches a secret substring are replaced with `[REDACTED]` before the
|
||||
* line is emitted, so a caller that accidentally passes `{ privateKey, cert, pairingCode,
|
||||
* agentToken }` can never leak the value. Emission goes through an injectable sink (default
|
||||
* stderr) — `console.log` is never used.
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export interface Logger {
|
||||
log(level: LogLevel, msg: string, meta?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
/** Sink for a formatted line. Default writes to stderr (NOT console.log). */
|
||||
export type LogSink = (line: string) => void
|
||||
|
||||
const LEVEL_ORDER: Readonly<Record<LogLevel, number>> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* Substrings that mark a meta key as secret (case-insensitive). Matches `privateKey` (key),
|
||||
* `cert`, `caChain` (chain? no — cert covers it via 'cert'), `pairingCode` (code), `agentToken`
|
||||
* (token), etc. `nonce`/`streamId`/`hostId` intentionally do NOT match (they are not secrets).
|
||||
*/
|
||||
const REDACT_SUBSTRINGS: readonly string[] = [
|
||||
'key',
|
||||
'cert',
|
||||
'code',
|
||||
'token',
|
||||
'secret',
|
||||
'proof',
|
||||
'password',
|
||||
'pem',
|
||||
]
|
||||
|
||||
const REDACTED = '[REDACTED]'
|
||||
|
||||
function isSecretKey(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
return REDACT_SUBSTRINGS.some((s) => lower.includes(s))
|
||||
}
|
||||
|
||||
function redactMeta(meta: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
out[k] = isSecretKey(k) ? REDACTED : v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const defaultSink: LogSink = (line) => {
|
||||
process.stderr.write(`${line}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redacting logger at a minimum level. Lines below `level` are dropped. Secret meta
|
||||
* values are redacted by key name before serialization (INV9).
|
||||
*/
|
||||
export function createLogger(level: LogLevel, sink: LogSink = defaultSink): Logger {
|
||||
const threshold = LEVEL_ORDER[level]
|
||||
return {
|
||||
log(msgLevel, msg, meta) {
|
||||
if (LEVEL_ORDER[msgLevel] < threshold) return
|
||||
const safeMeta = meta ? redactMeta(meta) : undefined
|
||||
const metaPart = safeMeta ? ` ${JSON.stringify(safeMeta)}` : ''
|
||||
sink(`${msgLevel.toUpperCase()} ${msg}${metaPart}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user