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.
119 lines
3.3 KiB
TypeScript
119 lines
3.3 KiB
TypeScript
/**
|
||
* 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 (0x00–0x1f) — 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 []
|
||
},
|
||
}
|
||
}
|