271 lines
9.0 KiB
TypeScript
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`;
|
|
}
|