import type { RequestAuditEnvelope, RequestAuditSink, } from "@suaveplan/api-host/request-pipeline"; import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types"; import { AUDIT_STDOUT_CODES, AuditStdoutError, } from "../error-codes/error-codes.js"; import { HMAC_FIELD, signRecord } from "../hmac/hmac.js"; import { createStdoutAuditInstruments, type StdoutAuditInstruments, } from "../instruments/instruments.js"; import { resolveStdoutAuditScalarOptions, type StdoutAuditScalarOptions, } from "../options/options.js"; import { type AuditRecord, parseAuditRecord } from "../schema/schema.js"; /** * Minimal one-line writer the sink writes to. `write` MAY be sync or * async; sync writers (e.g. `process.stdout.write`) are awaited * trivially. The sink appends a terminating `\n` so callers MUST NOT * include one in the supplied string. */ export interface LineWriter { write(line: string): void | Promise; } /** * Options accepted by {@link createStdoutAuditSink}. */ export interface StdoutAuditSinkOptions extends StdoutAuditScalarOptions { /** HMAC-SHA-256 key. Required. */ readonly hmacKey: Uint8Array; /** Writer. Defaults to a `process.stdout`-backed adapter. */ readonly writer?: LineWriter; /** Wall-clock used for the warning throttle. Defaults to `Date.now`. */ readonly clock?: Clock; /** Timer pair used to bound queue waits. Defaults to global `setTimeout`/`clearTimeout`. */ readonly timers?: TimersLike; /** Logger for drop warnings. Defaults to a no-op. */ readonly logger?: LoggerLike; /** OTel instruments. Defaults to {@link createStdoutAuditInstruments}'s global-provider bundle. */ readonly instruments?: StdoutAuditInstruments; } /** * Public surface of the stdout audit sink. Extends the * `RequestAuditSink` contract from `@suaveplan/api-host/request-pipeline` * so the host's request pipeline accepts the instance directly, and * adds a `write` overload taking a canonical `AuditRecord` for * admin-API code paths. */ export interface StdoutAuditSink extends RequestAuditSink { /** Write a canonical {@link AuditRecord}. */ write(record: AuditRecord): Promise; /** * Dispose the sink. Pending writes finish before resolution; * subsequent calls to `emit` / `write` are no-ops and increment * `dropped_total{reason="closed"}`. */ dispose(): Promise; } const defaultStdoutWriter: LineWriter = { write(line) { process.stdout.write(line); }, }; const defaultTimers: TimersLike = { setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number, clearTimer: (h) => clearTimeout(h as unknown as ReturnType), }; /** * Construct a stdout audit sink. The sink writes one JSON line per * record with an `_hmac` field, validates each record against the * §12.5 schema, and bounds backpressure by dropping records whose * queue wait exceeds `maxQueueMs`. Drops are NEVER silent — every * drop bumps `api.audit.dropped_total{sink}` and (subject to the * once-per-`warnThrottleMs` throttle) emits a single warning log. * * The factory throws `AUDIT_STDOUT.INVALID_OPTIONS` on a bad option * bag. Runtime `write` / `emit` failures throw * `AUDIT_STDOUT.INVALID_RECORD` (boundary validation), * `AUDIT_STDOUT.QUEUE_FULL` (backpressure), or * `AUDIT_STDOUT.WRITER_FAILED` (writer rejection). */ export function createStdoutAuditSink( options: StdoutAuditSinkOptions ): StdoutAuditSink { if ( !(options.hmacKey instanceof Uint8Array) || options.hmacKey.length === 0 ) { throw new AuditStdoutError( AUDIT_STDOUT_CODES.INVALID_OPTIONS, "error.audit_stdout.invalid_options", { context: { reason: "hmacKey must be a non-empty Uint8Array" } } ); } const { hmacKey, writer = defaultStdoutWriter, clock = Date.now, timers = defaultTimers, logger, instruments = createStdoutAuditInstruments(), ...rest } = options; const scalar = resolveStdoutAuditScalarOptions(rest); let lastWarnAt = Number.NEGATIVE_INFINITY; let inflight: Promise = Promise.resolve(); let closed = false; const tryWarn = (reason: string): void => { if (logger === undefined) return; const now = clock(); if (now - lastWarnAt >= scalar.warnThrottleMs) { lastWarnAt = now; logger.warn("audit_stdout.dropped", { sink: scalar.sinkLabel, reason, }); } }; const bumpDropped = (reason: string): void => { instruments.droppedTotal.add(1, { sink: scalar.sinkLabel, reason, }); }; /** * Wait for the current in-flight write OR `maxQueueMs`, whichever * comes first. Returns `true` when the slot is free, `false` * when the wait timed out. */ const acquireSlot = (): Promise => new Promise((resolve) => { let settled = false; const handle = timers.setTimer(() => { if (settled) return; settled = true; resolve(false); }, scalar.maxQueueMs); // `inflight` is always a `Promise` that resolves // (never rejects) — see the `slot` promise wired into // `inflight` below. We attach a single fulfilment handler. inflight.then(() => { if (settled) return; settled = true; timers.clearTimer(handle); resolve(true); }); }); const write = async (record: AuditRecord): Promise => { if (closed) { bumpDropped("closed"); tryWarn("closed"); return; } const validated = parseAuditRecord(record); const acquired = await acquireSlot(); if (!acquired) { bumpDropped("queue_full"); tryWarn("queue_full"); throw new AuditStdoutError( AUDIT_STDOUT_CODES.QUEUE_FULL, "error.audit_stdout.queue_full", { context: { sink: scalar.sinkLabel, maxQueueMs: scalar.maxQueueMs, }, } ); } // Reserve the slot for the duration of this write. let release!: () => void; const slot = new Promise((res) => { release = res; }); inflight = slot; const tag = signRecord(validated, hmacKey); const line = serialiseLine(validated, tag); try { await Promise.resolve(writer.write(line)); instruments.recordsTotal.add(1, { sink: scalar.sinkLabel, type: validated.type, outcome: validated.outcome, }); } catch (cause) { bumpDropped("writer_failed"); tryWarn("writer_failed"); throw new AuditStdoutError( AUDIT_STDOUT_CODES.WRITER_FAILED, "error.audit_stdout.writer_failed", { cause: cause instanceof Error ? cause : undefined } ); } finally { release(); } }; const emit = (envelope: RequestAuditEnvelope): Promise => { const record = adaptEnvelope(envelope); return write(record); }; const dispose = async (): Promise => { closed = true; try { await inflight; } catch { // Errors from the prior write surface to their own caller; // dispose just waits for the slot to drain. } }; return { emit, write, dispose }; } /** * Adapt a host `RequestAuditEnvelope` to the §12.5 canonical * `AuditRecord` shape. The host envelope uses camelCase while the * canonical wire-record uses snake_case — this is the single point * the two shapes are reconciled. */ export function adaptEnvelope(envelope: RequestAuditEnvelope): AuditRecord { const base: AuditRecord = { ts: envelope.at, type: `request.${envelope.outcome}`, principal: envelope.principal, tenant: envelope.tenantId, request_id: envelope.requestId, trace_id: envelope.traceId, subject: `${envelope.method} ${envelope.route}`, outcome: envelope.outcome, ...(envelope.cause !== undefined ? { cause: envelope.cause } : {}), }; return base; } function serialiseLine(record: AuditRecord, tag: string): string { const ordered: Record = { ts: record.ts, type: record.type, principal: record.principal, tenant: record.tenant, request_id: record.request_id, trace_id: record.trace_id, subject: record.subject, outcome: record.outcome, }; if (record.cause !== undefined) { ordered["cause"] = record.cause; } ordered[HMAC_FIELD] = tag; return `${JSON.stringify(ordered)}\n`; }