/** * 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> } /** 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 queryAudit(accountId: string, from: string, to: string): Promise } 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 [] }, } }