Files
suaveplan/packages/api-stack/api/api-audit-stdout/src/sink/sink.ts
2026-05-27 23:29:53 +01:00

271 lines
9.0 KiB
TypeScript

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<void>;
}
/**
* 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<void>;
/**
* Dispose the sink. Pending writes finish before resolution;
* subsequent calls to `emit` / `write` are no-ops and increment
* `dropped_total{reason="closed"}`.
*/
dispose(): Promise<void>;
}
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<typeof setTimeout>),
};
/**
* 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<void> = 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<boolean> =>
new Promise<boolean>((resolve) => {
let settled = false;
const handle = timers.setTimer(() => {
if (settled) return;
settled = true;
resolve(false);
}, scalar.maxQueueMs);
// `inflight` is always a `Promise<void>` 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<void> => {
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<void>((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<void> => {
const record = adaptEnvelope(envelope);
return write(record);
};
const dispose = async (): Promise<void> => {
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<string, unknown> = {
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`;
}