Files
web-terminal/control-plane/src/audit/log.ts
Yaojia Wang 2af57e6686 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.
2026-07-02 06:10:16 +02:00

119 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* T14 — immutable, ZERO-PAYLOAD audit writer (INV10). Single funnel that P5 also imports for
* attach/manage/kill/revoke events. Append-only: no update/delete path is exposed. A payload
* guard rejects any `meta` value that could smuggle terminal output (length cap + control/ANSI
* bytes), so keystrokes/output/secrets can never land in `audit_log`.
*/
import { z } from 'zod'
import type { AuditStore, AuditRow } from '../store/ports.js'
export type AuditAction =
| 'account.create'
| 'account.suspend'
| 'host.bind'
| 'host.revoke'
| 'pairing.issue'
| 'pairing.redeem'
| 'subdomain.assign'
| 'node.drain'
| 'attach'
| 'manage'
| 'kill'
| 'revoke'
export interface AuditEntry {
readonly action: AuditAction
readonly principalId: string
readonly accountId: string
readonly hostId: string | null
readonly ts: string
readonly meta: Readonly<Record<string, string>>
}
/** Max length of a single metadata value — small enough that no terminal frame fits (INV10). */
export const MAX_META_VALUE_LEN = 256
/** Max number of metadata keys per entry. */
export const MAX_META_KEYS = 16
const AuditActionSchema = z.enum([
'account.create',
'account.suspend',
'host.bind',
'host.revoke',
'pairing.issue',
'pairing.redeem',
'subdomain.assign',
'node.drain',
'attach',
'manage',
'kill',
'revoke',
])
/** True if the string contains any C0 control byte (0x000x1f) — incl. ESC that begins ANSI. */
function hasControlBytes(v: string): boolean {
for (let i = 0; i < v.length; i++) {
if (v.charCodeAt(i) < 0x20) return true
}
return false
}
const MetaValue = z
.string()
.max(MAX_META_VALUE_LEN, 'meta value exceeds zero-payload cap')
.refine((v) => !hasControlBytes(v), 'meta value contains control/ANSI bytes (possible terminal payload)')
const AuditEntrySchema = z
.object({
action: AuditActionSchema,
principalId: z.string().min(1),
accountId: z.string().min(1),
hostId: z.string().nullable(),
ts: z.string().datetime({ offset: true }),
meta: z.record(z.string(), MetaValue).refine((m) => Object.keys(m).length <= MAX_META_KEYS, {
message: 'too many meta keys',
}),
})
.strict()
export interface AuditWriter {
writeAuditEvent(entry: AuditEntry): Promise<void>
queryAudit(accountId: string, from: string, to: string): Promise<readonly AuditEntry[]>
}
export function createAuditLog(store: AuditStore): AuditWriter {
return {
async writeAuditEvent(entry) {
const parsed = AuditEntrySchema.safeParse(entry)
if (!parsed.success) {
throw new Error(`audit payload guard rejected entry: ${parsed.error.issues[0]?.message ?? 'invalid'}`)
}
const row: AuditRow = { ...parsed.data }
await store.append(row) // append-only, immutable
},
async queryAudit(accountId, from, to) {
const rows = await store.query(accountId, from, to)
return rows.map((r) => ({
action: r.action as AuditAction,
principalId: r.principalId,
accountId: r.accountId,
hostId: r.hostId,
ts: r.ts,
meta: r.meta,
}))
},
}
}
/** No-op writer for tasks that treat audit as an optional injected dependency in v0.9. */
export function noopAuditWriter(): AuditWriter {
return {
async writeAuditEvent() {
/* intentionally does nothing */
},
async queryAudit() {
return []
},
}
}