Initial Commit
This commit is contained in:
270
packages/api-stack/api/api-audit-stdout/src/sink/sink.ts
Normal file
270
packages/api-stack/api/api-audit-stdout/src/sink/sink.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user