Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
# `error-codes` — `AUDIT_STDOUT_*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the four stable error codes the stdout audit sink
exposes to consumers. Every failure path inside `createStdoutAuditSink`
maps onto exactly one of them, so callers can branch on `error.code`
without parsing messages or stack traces. The registry uses
`@suaveplan/error`'s `defineCodes` so the union narrows under
`instanceof AuditStdoutError`.
The codes capture the four observable failure modes of a write-only,
fire-and-forget audit pipeline: a record that fails its own Zod schema
(`INVALID_RECORD`), an option bag that fails the constructor schema
(`INVALID_OPTIONS`), a transport error from the supplied writer
(`WRITER_FAILED`), and the bounded-backpressure drop case
(`QUEUE_FULL`). The codes are surfaced through the
`AuditStdoutError` subclass, which carries a `code` property typed as
the literal union — `instanceof` narrowing flows through.
## Features
- Stable string codes via `defineCodes("AUDIT_STDOUT", [...])`.
- `AuditStdoutError` subclass with public constructor (lint rule
`check-error-subclass-constructors` enforced).
- Code union exported as `AuditStdoutErrorCode` for callers who want
to switch over every member.
- No runtime side effects — the registry is a frozen object literal.
## Usage: Basic
```ts
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
} from "@suaveplan/api-audit-stdout/error-codes";
throw new AuditStdoutError(
AUDIT_STDOUT_CODES.INVALID_RECORD,
"error.audit_stdout.invalid_record"
);
```
## Usage: Advanced
```ts
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
type AuditStdoutErrorCode,
} from "@suaveplan/api-audit-stdout/error-codes";
function handle(err: unknown): "drop" | "rethrow" {
if (!(err instanceof AuditStdoutError)) return "rethrow";
const code: AuditStdoutErrorCode = err.code;
switch (code) {
case AUDIT_STDOUT_CODES.QUEUE_FULL:
case AUDIT_STDOUT_CODES.WRITER_FAILED:
return "drop";
case AUDIT_STDOUT_CODES.INVALID_RECORD:
case AUDIT_STDOUT_CODES.INVALID_OPTIONS:
return "rethrow";
}
}
```
## API Reference
- `AUDIT_STDOUT_CODES` — frozen `{ INVALID_RECORD, WRITER_FAILED,
QUEUE_FULL, INVALID_OPTIONS }` mapping to the corresponding
`"AUDIT_STDOUT.*"` literal.
- `AuditStdoutErrorCode` — union of the four literal strings.
- `AuditStdoutError` — `SuaveplanError<AuditStdoutErrorCode>` subclass
with a public constructor accepting `(code, message, options?)`.
## Implementation Notes
`defineCodes` returns a frozen object — adding a new code requires an
OpenSpec change. The subclass declares its own public constructor so
`new AuditStdoutError(...)` is callable from outside the module (the
base class is abstract with a protected constructor). The
`auditStdoutCode` field is a typed alias of `code` for callers who
want a non-generic accessor.

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
type AuditStdoutErrorCode,
} from "./error-codes.js";
describe("AUDIT_STDOUT_CODES", () => {
it("exposes four stable string codes", { timeout: 5000 }, () => {
expect(AUDIT_STDOUT_CODES.INVALID_RECORD).toBe(
"AUDIT_STDOUT.INVALID_RECORD"
);
expect(AUDIT_STDOUT_CODES.WRITER_FAILED).toBe(
"AUDIT_STDOUT.WRITER_FAILED"
);
expect(AUDIT_STDOUT_CODES.QUEUE_FULL).toBe("AUDIT_STDOUT.QUEUE_FULL");
expect(AUDIT_STDOUT_CODES.INVALID_OPTIONS).toBe(
"AUDIT_STDOUT.INVALID_OPTIONS"
);
});
it("registry object is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(AUDIT_STDOUT_CODES)).toBe(true);
});
});
describe("AuditStdoutError", () => {
it("constructor binds code into typed alias", { timeout: 5000 }, () => {
const err = new AuditStdoutError(
AUDIT_STDOUT_CODES.INVALID_RECORD,
"error.audit_stdout.invalid_record"
);
const code: AuditStdoutErrorCode = err.code;
expect(code).toBe(AUDIT_STDOUT_CODES.INVALID_RECORD);
expect(err.auditStdoutCode).toBe(AUDIT_STDOUT_CODES.INVALID_RECORD);
expect(err).toBeInstanceOf(AuditStdoutError);
expect(err).toBeInstanceOf(Error);
});
it("forwards cause through SuaveplanError options", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new AuditStdoutError(
AUDIT_STDOUT_CODES.WRITER_FAILED,
"error.audit_stdout.writer_failed",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,50 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-audit-stdout`.
*
* - `INVALID_RECORD` — the record passed to `sink.write(...)` failed
* `AuditRecord` Zod validation at the boundary. `cause` carries the
* underlying `z.ZodError`. Records that fail validation are NEVER
* written.
* - `WRITER_FAILED` — the supplied writer threw or rejected
* asynchronously when the sink attempted to write a line. The
* record is treated as a drop; callers see the original writer
* error via `cause`.
* - `QUEUE_FULL` — the queue exceeded `maxQueueMs` of pending work
* for the current record. The record is dropped, the drop counter
* increments, and (subject to the throttle) a warning is logged.
* - `INVALID_OPTIONS` — `createStdoutAuditSink({...})` was called
* with an option bag that failed Zod validation at the boundary.
*
* Consumers branch on the string value, e.g.
* `error.code === AUDIT_STDOUT_CODES.INVALID_RECORD`.
*/
export const AUDIT_STDOUT_CODES = defineCodes("AUDIT_STDOUT", [
"INVALID_RECORD",
"WRITER_FAILED",
"QUEUE_FULL",
"INVALID_OPTIONS",
] as const);
export type AuditStdoutErrorCode =
(typeof AUDIT_STDOUT_CODES)[keyof typeof AUDIT_STDOUT_CODES];
/**
* Concrete error class for the `api-audit-stdout` package. Extends
* {@link SuaveplanError} so `instanceof AuditStdoutError` narrows
* `code` to {@link AuditStdoutErrorCode}.
*/
export class AuditStdoutError extends SuaveplanError<AuditStdoutErrorCode> {
public readonly auditStdoutCode: AuditStdoutErrorCode;
public constructor(
code: AuditStdoutErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.auditStdoutCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
AUDIT_STDOUT_CODES,
AuditStdoutError,
type AuditStdoutErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,82 @@
# `hmac` — canonical bytes + HMAC-SHA-256 signing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module produces the HMAC-SHA-256 tag attached to every audit
line. Signing operates on a *canonical* byte representation of the
record (keys serialised in a fixed order, `cause` omitted when
absent) so the same logical record always produces the same MAC under
the same key, regardless of how the source object was constructed.
Verification recomputes the MAC and constant-time-compares to the
hex-decoded signature.
The point isn't to encrypt the record — the line is plaintext JSON
written to stdout. The HMAC catches tampering downstream: a log
collector or storage tier that re-writes a field to mask an audited
action invalidates the MAC, and the auditor's verifier flags the
discrepancy. The key never leaves the host; rotation is the caller's
concern.
## Features
- `canonicalBytes(record)` — UTF-8 bytes of JSON with top-level keys
in design-doc §12.5 order and `cause` omitted when absent.
- `signRecord(record, key)` — lowercase hex HMAC-SHA-256, computed
via `@suaveplan/crypto-hash`'s `hmac("SHA-256", …)` primitive.
- `verifyRecord(record, key, signatureHex)` — constant-time match;
rejects bad-length, non-hex, and tampered inputs.
- `HMAC_FIELD` — the `"_hmac"` field name the sink appends to the
JSON line.
## Usage: Basic
```ts
import { signRecord, verifyRecord } from "@suaveplan/api-audit-stdout/hmac";
const key = new Uint8Array(32);
const tag = signRecord(record, key);
const ok = verifyRecord(record, key, tag);
```
## Usage: Advanced
```ts
import {
canonicalBytes,
HMAC_FIELD,
signRecord,
} from "@suaveplan/api-audit-stdout/hmac";
function emit(record: AuditRecord, key: Uint8Array): string {
// Equivalent to what the sink does internally, but caller-managed.
const line = JSON.parse(
new TextDecoder().decode(canonicalBytes(record))
) as Record<string, unknown>;
line[HMAC_FIELD] = signRecord(record, key);
return JSON.stringify(line);
}
```
## API Reference
- `canonicalBytes(record: AuditRecord): Uint8Array`
- `signRecord(record: AuditRecord, key: Uint8Array): string` — hex.
- `verifyRecord(record: AuditRecord, key: Uint8Array, sig: string): boolean`
- `HMAC_FIELD: "_hmac"`
## Implementation Notes
The canonical encoder lists top-level keys explicitly rather than
sorting alphabetically — the design doc fixes the order so callers
reading raw lines see fields in the documented sequence. Nested
objects use natural insertion order; their shape is locked by the
Zod schema so the JSON.stringify output is deterministic. `cause` is
omitted (not serialised as `null`) when absent so a `success` record
without `cause` and a `success` record with `cause: undefined`
produce the same MAC. Hex was chosen over base64 because hex is
case-insensitive trivially and stays ASCII-safe through every common
log pipeline.

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { AuditRecord } from "../schema/schema.js";
import { canonicalBytes, signRecord, verifyRecord } from "./hmac.js";
const KEY = new TextEncoder().encode("test-key-32-bytes-padding-padding!");
const RECORD: AuditRecord = {
ts: "2026-05-26T10:00:00.000Z",
type: "admin.plugin.disabled",
principal: { id: "user_1", scopes: ["admin"] },
tenant: "tenant_a",
request_id: "req_1",
trace_id: null,
subject: "plugin.exports",
outcome: "success",
};
const FAILURE: AuditRecord = {
...RECORD,
outcome: "failure",
cause: { code: "PLUGIN.NOT_FOUND", message: "no such plugin" },
};
describe("canonicalBytes", () => {
it("is deterministic across calls", { timeout: 5000 }, () => {
const a = canonicalBytes(RECORD);
const b = canonicalBytes(RECORD);
expect(a).toEqual(b);
});
it("omits cause when absent", { timeout: 5000 }, () => {
const json = new TextDecoder().decode(canonicalBytes(RECORD));
expect(json.includes('"cause"')).toBe(false);
});
it("includes cause when present", { timeout: 5000 }, () => {
const json = new TextDecoder().decode(canonicalBytes(FAILURE));
expect(json.includes('"cause"')).toBe(true);
});
it("emits keys in design-doc §12.5 order", { timeout: 5000 }, () => {
const json = new TextDecoder().decode(canonicalBytes(RECORD));
// Top-level keys appear in the fixed order
const order = [
'"ts"',
'"type"',
'"principal"',
'"tenant"',
'"request_id"',
'"trace_id"',
'"subject"',
'"outcome"',
];
let last = -1;
for (const k of order) {
const idx = json.indexOf(k);
expect(idx).toBeGreaterThan(last);
last = idx;
}
});
});
describe("signRecord / verifyRecord", () => {
it("HMAC verifies under the same key", { timeout: 5000 }, () => {
const tag = signRecord(RECORD, KEY);
expect(verifyRecord(RECORD, KEY, tag)).toBe(true);
});
it("HMAC is hex-encoded and length-64 for SHA-256", {
timeout: 5000,
}, () => {
const tag = signRecord(RECORD, KEY);
expect(tag.length).toBe(64);
expect(/^[0-9a-f]+$/.test(tag)).toBe(true);
});
it("tampering invalidates HMAC", { timeout: 5000 }, () => {
const tag = signRecord(RECORD, KEY);
const tampered: AuditRecord = {
...RECORD,
outcome: "failure",
cause: { code: "X", message: "y" },
};
expect(verifyRecord(tampered, KEY, tag)).toBe(false);
});
it("wrong key fails verification", { timeout: 5000 }, () => {
const tag = signRecord(RECORD, KEY);
const other = new TextEncoder().encode(
"different-key-padding-padding!!!"
);
expect(verifyRecord(RECORD, other, tag)).toBe(false);
});
it("malformed hex returns false", { timeout: 5000 }, () => {
expect(verifyRecord(RECORD, KEY, "zz")).toBe(false);
expect(verifyRecord(RECORD, KEY, "abc")).toBe(false);
});
it("length mismatch returns false", { timeout: 5000 }, () => {
expect(verifyRecord(RECORD, KEY, "ab")).toBe(false);
});
});

View File

@@ -0,0 +1,93 @@
import { hmac as cryptoHmac } from "@suaveplan/crypto-hash";
import type { AuditRecord } from "../schema/schema.js";
/**
* HMAC field name appended to the JSON line. Lower-case + underscore
* prefix so log indexers see it as metadata rather than a regular
* record field.
*/
export const HMAC_FIELD = "_hmac" as const;
/**
* Encode `record` as deterministic canonical bytes for HMAC signing.
*
* The canonical form is JSON with top-level keys sorted in fixed
* order matching design doc §12.5: `ts`, `type`, `principal`,
* `tenant`, `request_id`, `trace_id`, `subject`, `outcome`, `cause`.
* `cause` is omitted entirely when absent (rather than serialised as
* `null`) so signatures stay stable across optional-vs-undefined
* variations of the same logical record. Nested objects use their
* natural insertion order — they're already locked by the Zod schema
* so the order is deterministic at validation time.
*/
export function canonicalBytes(record: AuditRecord): Uint8Array {
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;
}
return new TextEncoder().encode(JSON.stringify(ordered));
}
/**
* Compute the lowercase hex HMAC-SHA-256 of the canonical bytes of
* `record` under `key`. The hex form keeps the JSON line ASCII-safe
* and trivially comparable in log pipelines.
*/
export function signRecord(record: AuditRecord, key: Uint8Array): string {
const mac = cryptoHmac("SHA-256", key, canonicalBytes(record));
return toHex(mac);
}
/**
* Verify a previously-emitted HMAC hex string against `record` under
* `key`. Returns `true` when the signature matches, `false`
* otherwise. Comparison is constant-time over the byte-decoded form.
*/
export function verifyRecord(
record: AuditRecord,
key: Uint8Array,
signatureHex: string
): boolean {
const expected = cryptoHmac("SHA-256", key, canonicalBytes(record));
const actual = fromHex(signatureHex);
if (actual === null) return false;
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
// Bounds-checked by the loop guard; the structural fallback
// mirrors `noUncheckedIndexedAccess` typing without a runtime
// branch.
const e = expected[i] as number;
const a = actual[i] as number;
diff |= e ^ a;
}
return diff === 0;
}
function toHex(bytes: Uint8Array): string {
let out = "";
for (const byte of bytes) {
out += byte.toString(16).padStart(2, "0");
}
return out;
}
function fromHex(hex: string): Uint8Array | null {
if (hex.length % 2 !== 0) return null;
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
const byte = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
if (Number.isNaN(byte)) return null;
out[i] = byte;
}
return out;
}

View File

@@ -0,0 +1,6 @@
export {
canonicalBytes,
HMAC_FIELD,
signRecord,
verifyRecord,
} from "./hmac.js";

View File

@@ -0,0 +1,42 @@
export {
AUDIT_STDOUT_CODES,
AuditStdoutError,
type AuditStdoutErrorCode,
} from "./error-codes/error-codes.js";
export {
canonicalBytes,
HMAC_FIELD,
signRecord,
verifyRecord,
} from "./hmac/hmac.js";
export {
createStdoutAuditInstruments,
type StdoutAuditInstruments,
type StdoutAuditInstrumentsConfig,
} from "./instruments/instruments.js";
export {
DEFAULT_MAX_QUEUE_MS,
DEFAULT_WARN_THROTTLE_MS,
type ResolvedStdoutAuditScalarOptions,
resolveStdoutAuditScalarOptions,
type StdoutAuditScalarOptions,
stdoutAuditScalarOptionsSchema,
} from "./options/options.js";
export {
type AuditCause,
type AuditOutcome,
type AuditPrincipal,
type AuditRecord,
auditCauseSchema,
auditOutcomeSchema,
auditPrincipalSchema,
auditRecordSchema,
parseAuditRecord,
} from "./schema/schema.js";
export {
adaptEnvelope,
createStdoutAuditSink,
type LineWriter,
type StdoutAuditSink,
type StdoutAuditSinkOptions,
} from "./sink/sink.js";

View File

@@ -0,0 +1,5 @@
export {
createStdoutAuditInstruments,
type StdoutAuditInstruments,
type StdoutAuditInstrumentsConfig,
} from "./instruments.js";

View File

@@ -0,0 +1,70 @@
# `instruments` — OTel counters for the stdout audit sink
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the two counters the stdout sink emits at runtime,
matching design doc §12.3's metric surface for the audit pipeline.
Operators wire the `MeterProvider` once at process boot; the sink
picks up the configured provider through this factory. When no SDK
is installed, the OTel global resolves to a no-op meter so the sink
runs cleanly in tests without any ceremony.
The factory returns a frozen bundle so the sink does zero meter
lookups on the hot path — both counters are constructed once and the
record's `type` / `outcome` labels are attached at observation time.
## Features
- `api.audit.records_total{sink, type, outcome}` — successful write
counter.
- `api.audit.dropped_total{sink, reason}` — drop counter for
queue-full and writer-failed paths.
- Default meter provider falls back to the OTel global.
- Frozen bundle — references stable for the sink's lifetime.
## Usage: Basic
```ts
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
const instruments = createStdoutAuditInstruments();
instruments.recordsTotal.add(1, {
sink: "stdout",
type: "request.success",
outcome: "success",
});
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
const meterProvider = new MeterProvider();
const instruments = createStdoutAuditInstruments({ meterProvider });
// Hand them into createStdoutAuditSink so the sink emits through the
// SDK-installed provider rather than the global no-op.
```
## API Reference
- `createStdoutAuditInstruments(config?)`
- `meterProvider?: MeterProvider` — defaults to
`metrics.getMeterProvider()`.
- Returns `StdoutAuditInstruments` with `meter`, `recordsTotal`,
`droppedTotal`.
## Implementation Notes
Counters are constructed eagerly so the first write does zero
allocations beyond the attribute object. The bundle is frozen to
prevent accidental shadowing. The `sink="stdout"` attribute is
attached at observation time by the sink so the same provider can
serve multiple sink implementations side by side (file, vault, etc.)
without conflict on the metric name.

View File

@@ -0,0 +1,90 @@
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createStdoutAuditInstruments } from "./instruments.js";
async function readCounter(
provider: MeterProvider,
reader: PeriodicExportingMetricReader,
exporter: InMemoryMetricExporter,
name: string
): Promise<number> {
exporter.reset();
await provider.forceFlush();
const records = exporter.getMetrics();
let total = 0;
for (const rm of records) {
for (const scope of rm.scopeMetrics) {
for (const m of scope.metrics) {
if (m.descriptor.name !== name) continue;
for (const dp of m.dataPoints) {
total += dp.value as number;
}
}
}
}
return total;
}
describe("createStdoutAuditInstruments", () => {
it("falls back to the OTel global meter provider", {
timeout: 5000,
}, () => {
const instruments = createStdoutAuditInstruments();
expect(instruments.recordsTotal).toBeDefined();
expect(instruments.droppedTotal).toBeDefined();
expect(instruments.meter).toBeDefined();
});
it("returned bundle is frozen", { timeout: 5000 }, () => {
const instruments = createStdoutAuditInstruments();
expect(Object.isFrozen(instruments)).toBe(true);
});
it("counters increment through a real MeterProvider", {
timeout: 5000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
const instruments = createStdoutAuditInstruments({
meterProvider: provider,
});
instruments.recordsTotal.add(1, {
sink: "stdout",
type: "x",
outcome: "success",
});
instruments.recordsTotal.add(2, {
sink: "stdout",
type: "x",
outcome: "success",
});
instruments.droppedTotal.add(1, { sink: "stdout" });
const records = await readCounter(
provider,
reader,
exporter,
"api.audit.records_total"
);
const dropped = await readCounter(
provider,
reader,
exporter,
"api.audit.dropped_total"
);
expect(records).toBe(3);
expect(dropped).toBe(1);
await provider.shutdown();
});
});

View File

@@ -0,0 +1,47 @@
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/**
* Config consumed by {@link createStdoutAuditInstruments}. The meter
* provider defaults to the OTel global (which no-ops without an
* installed SDK) so the sink works in test envs without ceremony.
*/
export interface StdoutAuditInstrumentsConfig {
readonly meterProvider?: MeterProvider;
}
/**
* Frozen bundle of the two counters this sink emits.
*
* - `api.audit.records_total{sink="stdout", type, outcome}` — one
* increment per successful write.
* - `api.audit.dropped_total{sink="stdout"}` — one increment per
* dropped record (queue-full or writer-failed). Drops are NEVER
* silent — every drop bumps this counter before the sink returns.
*/
export interface StdoutAuditInstruments {
readonly meter: Meter;
readonly recordsTotal: Counter;
readonly droppedTotal: Counter;
}
const METER_NAME = "@suaveplan/api-audit-stdout";
/**
* Build the sink's instrument registry. Counters are created once at
* construction so the hot-path writes do zero meter lookups.
*/
export function createStdoutAuditInstruments(
config: StdoutAuditInstrumentsConfig = {}
): StdoutAuditInstruments {
const meterProvider = config.meterProvider ?? metrics.getMeterProvider();
const meter = meterProvider.getMeter(METER_NAME);
const recordsTotal = meter.createCounter("api.audit.records_total", {
description:
"Audit records successfully written, by sink + type + outcome",
});
const droppedTotal = meter.createCounter("api.audit.dropped_total", {
description: "Audit records dropped by backpressure or writer failure",
});
return Object.freeze({ meter, recordsTotal, droppedTotal });
}

View File

@@ -0,0 +1,8 @@
export {
DEFAULT_MAX_QUEUE_MS,
DEFAULT_WARN_THROTTLE_MS,
type ResolvedStdoutAuditScalarOptions,
resolveStdoutAuditScalarOptions,
type StdoutAuditScalarOptions,
stdoutAuditScalarOptionsSchema,
} from "./options.js";

View File

@@ -0,0 +1,75 @@
# `options` — constructor option schema and defaults
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the Zod schema and defaults for the scalar slice of
`createStdoutAuditSink({...})`'s input. Validation happens at the
boundary — a typo on `maxQueueMs` raises
`AUDIT_STDOUT.INVALID_OPTIONS` rather than silently being ignored.
The schema is `.strict()` so unknown keys are rejected (same rationale
as the `AuditRecord` schema).
The non-scalar slots — writer, clock, timers, logger, instruments,
hmacKey — bypass Zod and are checked structurally by the sink itself
at construction. Mixing the two validation styles keeps the typed
surface narrow while still rejecting obvious typos.
## Features
- Strict Zod schema for scalar option keys.
- Defaults: `maxQueueMs = 100`, `warnThrottleMs = 60_000`,
`sinkLabel = "stdout"`.
- Resolver wraps any `ZodError` as `AUDIT_STDOUT.INVALID_OPTIONS`.
- Constants exported so callers can reference them when computing
test throughput budgets.
## Usage: Basic
```ts
import { resolveStdoutAuditScalarOptions } from "@suaveplan/api-audit-stdout/options";
const resolved = resolveStdoutAuditScalarOptions({ maxQueueMs: 250 });
// resolved.warnThrottleMs === 60_000 (default)
```
## Usage: Advanced
```ts
import {
DEFAULT_MAX_QUEUE_MS,
resolveStdoutAuditScalarOptions,
type StdoutAuditScalarOptions,
} from "@suaveplan/api-audit-stdout/options";
function tightenedForTests(): StdoutAuditScalarOptions {
return {
maxQueueMs: Math.max(1, DEFAULT_MAX_QUEUE_MS / 10),
warnThrottleMs: 1_000,
};
}
const resolved = resolveStdoutAuditScalarOptions(tightenedForTests());
```
## API Reference
- `stdoutAuditScalarOptionsSchema` — strict Zod object.
- `StdoutAuditScalarOptions` — inferred type.
- `ResolvedStdoutAuditScalarOptions` — defaults-applied shape.
- `resolveStdoutAuditScalarOptions(raw)` — boundary parser.
- `DEFAULT_MAX_QUEUE_MS`, `DEFAULT_WARN_THROTTLE_MS` — constants.
## Implementation Notes
`maxQueueMs` is bounded to positive integers because a value of 0
would mean "never queue" — that's a different semantics (synchronous
backpressure) and would need its own option. `warnThrottleMs`
governs the once-per-window log; tests use a `FakeClock` plus a
short throttle to exercise the throttle without sleeping. The
`sinkLabel` exists so a future spinoff (e.g. journald) can reuse
this module while still attaching a distinct `sink="…"` label to its
counters.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
} from "../error-codes/error-codes.js";
import {
DEFAULT_MAX_QUEUE_MS,
DEFAULT_WARN_THROTTLE_MS,
resolveStdoutAuditScalarOptions,
stdoutAuditScalarOptionsSchema,
} from "./options.js";
describe("resolveStdoutAuditScalarOptions", () => {
it("applies defaults when no input is provided", { timeout: 5000 }, () => {
const r = resolveStdoutAuditScalarOptions(undefined);
expect(r.maxQueueMs).toBe(DEFAULT_MAX_QUEUE_MS);
expect(r.warnThrottleMs).toBe(DEFAULT_WARN_THROTTLE_MS);
expect(r.sinkLabel).toBe("stdout");
});
it("merges supplied scalars over defaults", { timeout: 5000 }, () => {
const r = resolveStdoutAuditScalarOptions({
maxQueueMs: 250,
sinkLabel: "stdout-test",
});
expect(r.maxQueueMs).toBe(250);
expect(r.warnThrottleMs).toBe(DEFAULT_WARN_THROTTLE_MS);
expect(r.sinkLabel).toBe("stdout-test");
});
it("rejects unknown keys with INVALID_OPTIONS", { timeout: 5000 }, () => {
let caught: unknown;
try {
resolveStdoutAuditScalarOptions({
// @ts-expect-error — testing runtime rejection
bogus: 1,
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(AuditStdoutError);
if (caught instanceof AuditStdoutError) {
expect(caught.code).toBe(AUDIT_STDOUT_CODES.INVALID_OPTIONS);
}
});
it("rejects negative maxQueueMs", { timeout: 5000 }, () => {
expect(() =>
resolveStdoutAuditScalarOptions({ maxQueueMs: -1 })
).toThrow(AuditStdoutError);
});
it("rejects empty sinkLabel", { timeout: 5000 }, () => {
expect(() =>
resolveStdoutAuditScalarOptions({ sinkLabel: "" })
).toThrow(AuditStdoutError);
});
});
describe("stdoutAuditScalarOptionsSchema", () => {
it("is strict (extra keys rejected)", { timeout: 5000 }, () => {
const r = stdoutAuditScalarOptionsSchema.safeParse({
maxQueueMs: 1,
bogus: 2,
});
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,66 @@
import z from "zod";
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
} from "../error-codes/error-codes.js";
/** Default upper bound for queue latency before a record is dropped. */
export const DEFAULT_MAX_QUEUE_MS = 100;
/** Default throttle window for the drop-warning log (one warn per minute). */
export const DEFAULT_WARN_THROTTLE_MS = 60_000;
/**
* Strict Zod schema for {@link StdoutAuditSinkOptions}'s parsed
* scalar fields. Object-typed slots (writer, instruments, clock,
* timers, logger, hmacKey) are validated via separate `instanceof` /
* structural checks because Zod's runtime model can't express
* arbitrary external interfaces cleanly.
*/
export const stdoutAuditScalarOptionsSchema = z
.object({
maxQueueMs: z.number().int().positive().optional(),
warnThrottleMs: z.number().int().positive().optional(),
sinkLabel: z.string().min(1).optional(),
})
.strict();
export type StdoutAuditScalarOptions = z.infer<
typeof stdoutAuditScalarOptionsSchema
>;
/**
* Internal resolved-options shape. Every optional scalar has a
* concrete value; the writer + clock + logger + timers + instruments
* are resolved by the sink at construction time using the same
* defaults documented on {@link createStdoutAuditSink}.
*/
export interface ResolvedStdoutAuditScalarOptions {
readonly maxQueueMs: number;
readonly warnThrottleMs: number;
readonly sinkLabel: string;
}
/**
* Parse + apply defaults on the scalar slice of the constructor
* input. Any Zod failure is repackaged as
* `AUDIT_STDOUT.INVALID_OPTIONS` so callers see the package's own
* error type rather than a raw `ZodError`.
*/
export function resolveStdoutAuditScalarOptions(
raw: StdoutAuditScalarOptions | undefined
): ResolvedStdoutAuditScalarOptions {
const parsed = stdoutAuditScalarOptionsSchema.safeParse(raw ?? {});
if (!parsed.success) {
throw new AuditStdoutError(
AUDIT_STDOUT_CODES.INVALID_OPTIONS,
"error.audit_stdout.invalid_options",
{ cause: parsed.error }
);
}
return {
maxQueueMs: parsed.data.maxQueueMs ?? DEFAULT_MAX_QUEUE_MS,
warnThrottleMs: parsed.data.warnThrottleMs ?? DEFAULT_WARN_THROTTLE_MS,
sinkLabel: parsed.data.sinkLabel ?? "stdout",
};
}

View File

@@ -0,0 +1,11 @@
export {
type AuditCause,
type AuditOutcome,
type AuditPrincipal,
type AuditRecord,
auditCauseSchema,
auditOutcomeSchema,
auditPrincipalSchema,
auditRecordSchema,
parseAuditRecord,
} from "./schema.js";

View File

@@ -0,0 +1,95 @@
# `schema` — `AuditRecord` Zod schema (design doc §12.5)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module locks the wire shape of the JSON line the stdout sink
writes. The shape is exactly what design doc §12.5 specifies for the
admin / audit pipeline: `ts`, `type`, `principal`, `tenant`,
`request_id`, `trace_id`, `subject`, `outcome`, and a conditional
`cause`. Every write goes through `parseAuditRecord(...)` so a
malformed record raises `AUDIT_STDOUT.INVALID_RECORD` *before* a line
is written — log pipelines downstream never see a half-finished
record.
The schema is `.strict()` (unknown top-level fields are rejected) and
carries a `refine` enforcing the §12.5 invariant that `cause` is
REQUIRED when `outcome === "failure"`. Optional fields surface via
`exactOptionalPropertyTypes` parity so a missing `cause` on a
`success` record is distinct from `cause: undefined` on a `failure`
record. The principal slice is its own sub-schema so the same shape
can be reused by other audit sinks that target the same wire format.
## Features
- Strict shape — extra keys rejected.
- `outcome === "failure"` requires `cause` (refine rule).
- Sub-schemas for `cause`, `principal`, and `outcome` so consumers can
type their own DTOs against the same atoms.
- `parseAuditRecord(input)` returns the validated record or throws
`AUDIT_STDOUT.INVALID_RECORD` wrapping the underlying `ZodError`.
- All types are `z.infer<...>` — no hand-written interfaces drift.
## Usage: Basic
```ts
import { parseAuditRecord } from "@suaveplan/api-audit-stdout/schema";
const record = parseAuditRecord({
ts: "2026-05-26T10:00:00.000Z",
type: "admin.plugin.disabled",
principal: { id: "user_1", scopes: ["admin"] },
tenant: "tenant_a",
request_id: "req_1",
trace_id: null,
subject: "plugin.exports",
outcome: "success",
});
```
## Usage: Advanced
```ts
import {
auditRecordSchema,
type AuditRecord,
} from "@suaveplan/api-audit-stdout/schema";
function adapt(envelope: HostEnvelope): AuditRecord {
return auditRecordSchema.parse({
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 } : {}),
});
}
```
## API Reference
- `auditRecordSchema` — strict Zod object with the §12.5 invariant
refined.
- `auditCauseSchema`, `auditPrincipalSchema`, `auditOutcomeSchema` —
sub-schemas reused by consumers.
- `AuditRecord`, `AuditCause`, `AuditPrincipal`, `AuditOutcome` —
inferred types.
- `parseAuditRecord(input: unknown): AuditRecord` — boundary parser
that throws `AuditStdoutError(AUDIT_STDOUT.INVALID_RECORD, …)` on
failure.
## Implementation Notes
`.strict()` blocks unknown fields at the boundary — if a future
design-doc revision adds fields, this schema must be updated in the
same OpenSpec change as the spec. The `outcome === "failure"` /
`cause` refine is enforced as a Zod `.refine` rather than a
discriminated union so the failure path returns a `ZodError` with a
single readable `path: ["cause"]` issue.

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
} from "../error-codes/error-codes.js";
import {
auditOutcomeSchema,
auditRecordSchema,
parseAuditRecord,
} from "./schema.js";
const baseValid = {
ts: "2026-05-26T10:00:00.000Z",
type: "admin.plugin.disabled",
principal: { id: "user_1", scopes: ["admin"] as const },
tenant: "tenant_a",
request_id: "req_1",
trace_id: null,
subject: "plugin.exports",
outcome: "success" as const,
};
describe("auditRecordSchema", () => {
it("accepts a complete success record", { timeout: 5000 }, () => {
const ok = auditRecordSchema.safeParse(baseValid);
expect(ok.success).toBe(true);
});
it("accepts a failure record carrying cause", { timeout: 5000 }, () => {
const ok = auditRecordSchema.safeParse({
...baseValid,
outcome: "failure",
cause: { code: "PLUGIN.NOT_FOUND", message: "no such plugin" },
});
expect(ok.success).toBe(true);
});
it("missing request_id is rejected", { timeout: 5000 }, () => {
const { request_id: _r, ...rest } = baseValid;
const bad = auditRecordSchema.safeParse(rest);
expect(bad.success).toBe(false);
});
it("unknown field is rejected", { timeout: 5000 }, () => {
const bad = auditRecordSchema.safeParse({
...baseValid,
extra: "nope",
});
expect(bad.success).toBe(false);
});
it("failure without cause is rejected", { timeout: 5000 }, () => {
const bad = auditRecordSchema.safeParse({
...baseValid,
outcome: "failure",
});
expect(bad.success).toBe(false);
if (!bad.success) {
expect(bad.error.issues[0]?.path).toEqual(["cause"]);
}
});
it("principal may be null", { timeout: 5000 }, () => {
const ok = auditRecordSchema.safeParse({
...baseValid,
principal: null,
});
expect(ok.success).toBe(true);
});
it("tenant may be null", { timeout: 5000 }, () => {
const ok = auditRecordSchema.safeParse({
...baseValid,
tenant: null,
});
expect(ok.success).toBe(true);
});
});
describe("auditOutcomeSchema", () => {
it("rejects unknown outcome literals", { timeout: 5000 }, () => {
expect(auditOutcomeSchema.safeParse("partial").success).toBe(false);
expect(auditOutcomeSchema.safeParse("success").success).toBe(true);
expect(auditOutcomeSchema.safeParse("failure").success).toBe(true);
});
});
describe("parseAuditRecord", () => {
it("returns validated record on success", { timeout: 5000 }, () => {
const result = parseAuditRecord(baseValid);
expect(result.request_id).toBe("req_1");
});
it("throws AuditStdoutError on invalid input", { timeout: 5000 }, () => {
let caught: unknown;
try {
parseAuditRecord({ ...baseValid, request_id: "" });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(AuditStdoutError);
if (caught instanceof AuditStdoutError) {
expect(caught.code).toBe(AUDIT_STDOUT_CODES.INVALID_RECORD);
}
});
});

View File

@@ -0,0 +1,97 @@
import z from "zod";
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
} from "../error-codes/error-codes.js";
/**
* Audit-record outcome enum. Mirrors the request-pipeline's
* `RequestAuditEnvelope.outcome` so the host can hand its envelope
* directly to the sink after `RequestAuditEnvelope ↔ AuditRecord`
* adaption.
*/
export const auditOutcomeSchema = z.enum(["success", "failure"]);
export type AuditOutcome = z.infer<typeof auditOutcomeSchema>;
/**
* Cause attached to non-2xx audit records. Locked to two short
* strings so log pipelines can index on `cause.code` cheaply.
*/
export const auditCauseSchema = z
.object({
code: z.string().min(1),
message: z.string(),
})
.strict();
export type AuditCause = z.infer<typeof auditCauseSchema>;
/**
* Principal slice carried by an audit record. Anonymous requests
* resolve to `null` so the field is explicit at the wire — never
* stripped.
*/
export const auditPrincipalSchema = z
.object({
id: z.string().min(1),
scopes: z.array(z.string()).readonly(),
})
.strict();
export type AuditPrincipal = z.infer<typeof auditPrincipalSchema>;
/**
* Canonical audit record shape (design doc §12.5).
*
* Field names match the wire-format the design doc locks: `ts`,
* `type`, `principal`, `tenant`, `request_id`, `trace_id`, `subject`,
* `outcome`, `cause`. The schema is `.strict()` — unknown top-level
* fields are rejected at the boundary so typos in caller code surface
* as `AUDIT_STDOUT.INVALID_RECORD` rather than silently riding along
* to log storage.
*/
export const auditRecordSchema = z
.object({
/** RFC 3339 timestamp in UTC. */
ts: z.string().min(1),
/** Event type, e.g. `"request.error"`, `"admin.plugin.disabled"`. */
type: z.string().min(1),
/** Principal slice or `null` for anonymous. */
principal: auditPrincipalSchema.nullable(),
/** Tenant id or `null` for cross-tenant / unscoped actions. */
tenant: z.string().nullable(),
/** Per-request id from the host's request pipeline. */
request_id: z.string().min(1),
/** W3C trace id when present, `null` otherwise. */
trace_id: z.string().nullable(),
/** Action subject — route, plugin, resource id, etc. */
subject: z.string().min(1),
/** `success` or `failure`. */
outcome: auditOutcomeSchema,
/** Cause is REQUIRED when `outcome === "failure"`; OPTIONAL otherwise. */
cause: auditCauseSchema.optional(),
})
.strict()
.refine((r) => !(r.outcome === "failure" && r.cause === undefined), {
message: "cause is required when outcome === 'failure'",
path: ["cause"],
});
export type AuditRecord = z.infer<typeof auditRecordSchema>;
/**
* Parse + validate an audit record at the write boundary. On failure,
* the underlying `z.ZodError` is wrapped as
* `AUDIT_STDOUT.INVALID_RECORD`. The validated record is returned
* with its strict shape preserved (so the sink can serialise it
* directly).
*/
export function parseAuditRecord(input: unknown): AuditRecord {
const parsed = auditRecordSchema.safeParse(input);
if (!parsed.success) {
throw new AuditStdoutError(
AUDIT_STDOUT_CODES.INVALID_RECORD,
"error.audit_stdout.invalid_record",
{ cause: parsed.error }
);
}
return parsed.data;
}

View File

@@ -0,0 +1,7 @@
export {
adaptEnvelope,
createStdoutAuditSink,
type LineWriter,
type StdoutAuditSink,
type StdoutAuditSinkOptions,
} from "./sink.js";

View File

@@ -0,0 +1,108 @@
# `sink` — `createStdoutAuditSink`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module wires the schema, HMAC signer, instruments, and options
into a `RequestAuditSink` implementation that writes JSON-per-line
audit records to a `LineWriter` (default `process.stdout`). It is the
default audit sink shipped with `@suaveplan/api-host`: container
runtimes already redirect stdout into a log pipeline, so operators
inherit the records without touching host configuration.
The sink is single-flight: at most one write is on the wire at any
time. Subsequent records wait for the slot, but only up to
`maxQueueMs` (default 100ms) before being dropped. Drops are NEVER
silent — every drop increments `api.audit.dropped_total{sink,reason}`
and (subject to the once-per-`warnThrottleMs` throttle) emits a
single warning log. Validation, HMAC signing, and serialisation
happen on the calling side of the queue so a malformed record is
rejected synchronously without consuming the slot.
## Features
- Implements `RequestAuditSink` (`emit(envelope)`) from
`@suaveplan/api-host/request-pipeline` — host accepts the instance
directly.
- `write(record: AuditRecord)` overload for admin paths that already
hold a canonical record.
- Boundary Zod validation — `AUDIT_STDOUT.INVALID_RECORD` on a bad
record.
- Bounded queue — `AUDIT_STDOUT.QUEUE_FULL` on timeout, drop counter
+ throttled warning.
- HMAC-SHA-256 over canonical bytes appended as `_hmac` field.
- `dispose()` drains the in-flight write and rejects subsequent
writes as `closed` drops.
- Pluggable `clock`, `timers`, `logger`, `writer`, `instruments`.
## Usage: Basic
```ts
import { createStdoutAuditSink } from "@suaveplan/api-audit-stdout/sink";
const sink = createStdoutAuditSink({
hmacKey: new TextEncoder().encode(process.env["AUDIT_HMAC_KEY"]!),
});
await sink.write({
ts: new Date().toISOString(),
type: "admin.plugin.disabled",
principal: { id: "user_1", scopes: ["admin"] },
tenant: "tenant_a",
request_id: "req_1",
trace_id: null,
subject: "plugin.exports",
outcome: "success",
});
```
## Usage: Advanced
```ts
import { createStdoutAuditSink } from "@suaveplan/api-audit-stdout/sink";
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const meterProvider = new MeterProvider({ readers: [...] });
const sink = createStdoutAuditSink({
hmacKey,
writer: {
async write(line) {
await myStream.write(line);
},
},
maxQueueMs: 250,
warnThrottleMs: 30_000,
instruments: createStdoutAuditInstruments({ meterProvider }),
logger: myLogger,
});
// Hand into createHost({ audit: sink }) per host wiring.
await sink.emit(envelope);
await sink.dispose();
```
## API Reference
- `createStdoutAuditSink(options)` → `StdoutAuditSink`.
- `StdoutAuditSinkOptions` — `{ hmacKey, writer?, clock?, timers?,
logger?, instruments?, maxQueueMs?, warnThrottleMs?, sinkLabel? }`.
- `StdoutAuditSink` — extends `RequestAuditSink`; adds `write(record)`
and `dispose()`.
- `LineWriter` — `{ write(line: string): void | Promise<void> }`.
- `adaptEnvelope(envelope)` — exposed for tests and bespoke
re-emission flows.
## Implementation Notes
The slot reservation uses an in-flight `Promise<void>` rather than a
queue + worker so cancellation flows trivially through GC — there's
no worker loop to shut down. `acquireSlot` races the in-flight write
against the `timers.setTimer` callback; whichever wins clears the
loser. The `closed` flag is checked before validation so a disposed
sink rejects fast without spending CPU on schema work.
`adaptEnvelope` is the only place the host's camelCase envelope
shape meets the canonical snake_case record — keeping that mapping
isolated lets the design-doc wire shape evolve without rippling
through unrelated callers.

View File

@@ -0,0 +1,323 @@
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
} from "../error-codes/error-codes.js";
import { createStdoutAuditInstruments } from "../instruments/instruments.js";
import type { AuditRecord } from "../schema/schema.js";
import { createStdoutAuditSink, type LineWriter } from "./sink.js";
const KEY = new TextEncoder().encode("a-32-byte-key-for-tests-padding!");
function makeRecord(over: Partial<AuditRecord> = {}): AuditRecord {
return {
ts: "2026-05-26T10:00:00.000Z",
type: "admin.plugin.disabled",
principal: { id: "user_1", scopes: ["admin"] },
tenant: "tenant_a",
request_id: "req_1",
trace_id: null,
subject: "plugin.exports",
outcome: "success",
...over,
};
}
/**
* Writer whose first write blocks until `release()` is called so the
* sink slot is held while subsequent writes are dispatched. This is
* the only way to deterministically trigger the queue-full path
* without relying on real timers.
*/
class HangingWriter implements LineWriter {
public readonly lines: string[] = [];
private resolveFirst: (() => void) | null = null;
private firstWritten = false;
public firstPending: Promise<void>;
public constructor() {
this.firstPending = new Promise((res) => {
this.resolveFirst = res;
});
}
public release(): void {
this.resolveFirst?.();
}
public async write(line: string): Promise<void> {
this.lines.push(line);
if (!this.firstWritten) {
this.firstWritten = true;
await this.firstPending;
}
}
}
/**
* Macrotask-deferred timer. Fires on `setImmediate` (which runs
* AFTER all queued microtasks) so a free slot's `inflight.then`
* microtask always wins the race, while a held slot lets the timer
* trigger the queue-full path deterministically.
*/
function deferredTimers(): TimersLike {
let id = 1;
const handles = new Map<number, NodeJS.Immediate>();
return {
setTimer: (cb) => {
const handle = id++;
const imm = setImmediate(() => {
handles.delete(handle);
cb();
});
handles.set(handle, imm);
return handle;
},
clearTimer: (h) => {
const imm = handles.get(h);
if (imm !== undefined) {
clearImmediate(imm);
handles.delete(h);
}
},
};
}
async function readCounterValue(
provider: MeterProvider,
reader: PeriodicExportingMetricReader,
exporter: InMemoryMetricExporter,
name: string,
matchAttr?: (attrs: Record<string, unknown>) => boolean
): Promise<number> {
exporter.reset();
await provider.forceFlush();
const records = exporter.getMetrics();
let total = 0;
for (const rm of records) {
for (const scope of rm.scopeMetrics) {
for (const m of scope.metrics) {
if (m.descriptor.name !== name) continue;
for (const dp of m.dataPoints) {
if (
matchAttr === undefined ||
matchAttr(dp.attributes as Record<string, unknown>)
) {
total += dp.value as number;
}
}
}
}
}
return total;
}
function makeProviderAndReader(): {
provider: MeterProvider;
reader: PeriodicExportingMetricReader;
exporter: InMemoryMetricExporter;
} {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
return { provider, reader, exporter };
}
describe("stdout sink — conformance", () => {
it("successful write increments records_total with type and outcome labels", {
timeout: 5000,
}, async () => {
const { provider, reader, exporter } = makeProviderAndReader();
const instruments = createStdoutAuditInstruments({
meterProvider: provider,
});
const writer: LineWriter = { write: () => undefined };
const sink = createStdoutAuditSink({
hmacKey: KEY,
writer,
instruments,
});
await sink.write(makeRecord({ type: "admin.x", outcome: "success" }));
const total = await readCounterValue(
provider,
reader,
exporter,
"api.audit.records_total",
(a) =>
a["sink"] === "stdout" &&
a["type"] === "admin.x" &&
a["outcome"] === "success"
);
expect(total).toBe(1);
await sink.dispose();
await provider.shutdown();
});
it("drop increments dropped_total", { timeout: 5000 }, async () => {
const { provider, reader, exporter } = makeProviderAndReader();
const instruments = createStdoutAuditInstruments({
meterProvider: provider,
});
const writer = new HangingWriter();
const sink = createStdoutAuditSink({
hmacKey: KEY,
writer,
instruments,
timers: deferredTimers(),
maxQueueMs: 50,
});
// First write holds the slot
const first = sink.write(makeRecord({ request_id: "r1" }));
// Yield to let first acquire the slot
await Promise.resolve();
await Promise.resolve();
// Second write attempts to acquire and times out immediately
let caught: unknown;
try {
await sink.write(makeRecord({ request_id: "r2" }));
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(AuditStdoutError);
if (caught instanceof AuditStdoutError) {
expect(caught.code).toBe(AUDIT_STDOUT_CODES.QUEUE_FULL);
}
writer.release();
await first;
const dropped = await readCounterValue(
provider,
reader,
exporter,
"api.audit.dropped_total",
(a) => a["sink"] === "stdout" && a["reason"] === "queue_full"
);
expect(dropped).toBe(1);
await sink.dispose();
await provider.shutdown();
});
it("warning log throttled to once per minute", {
timeout: 5000,
}, async () => {
const warnings: {
msg: string;
ctx?: Readonly<Record<string, unknown>>;
}[] = [];
const logger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: (msg, ctx) => {
warnings.push({ msg, ...(ctx !== undefined ? { ctx } : {}) });
},
error: () => undefined,
};
let now = 0;
const clock: Clock = () => now;
const writer = new HangingWriter();
const sink = createStdoutAuditSink({
hmacKey: KEY,
writer,
timers: deferredTimers(),
maxQueueMs: 50,
warnThrottleMs: 60_000,
clock,
logger,
});
const first = sink.write(makeRecord({ request_id: "r1" }));
await Promise.resolve();
await Promise.resolve();
// Fire 5 drops at simulated now=0..30_000 (all inside one minute)
for (let i = 0; i < 5; i++) {
now = i * 6_000;
try {
await sink.write(makeRecord({ request_id: `rx_${i}` }));
} catch {
/* expected */
}
}
expect(warnings.length).toBe(1);
// Advance the clock past the throttle window — the next drop
// should produce a fresh warning.
now = 120_000;
try {
await sink.write(makeRecord({ request_id: "ry" }));
} catch {
/* expected */
}
expect(warnings.length).toBe(2);
writer.release();
await first;
await sink.dispose();
});
it("dropped_total tracks writer_failed reason separately", {
timeout: 5000,
}, async () => {
const { provider, reader, exporter } = makeProviderAndReader();
const instruments = createStdoutAuditInstruments({
meterProvider: provider,
});
const writer: LineWriter = {
write: () => {
throw new Error("disk_full");
},
};
const sink = createStdoutAuditSink({
hmacKey: KEY,
writer,
instruments,
});
try {
await sink.write(makeRecord());
} catch {
/* expected */
}
const dropped = await readCounterValue(
provider,
reader,
exporter,
"api.audit.dropped_total",
(a) => a["reason"] === "writer_failed"
);
expect(dropped).toBe(1);
await sink.dispose();
await provider.shutdown();
});
it("closed sink increments dropped_total with reason=closed", {
timeout: 5000,
}, async () => {
const { provider, reader, exporter } = makeProviderAndReader();
const instruments = createStdoutAuditInstruments({
meterProvider: provider,
});
const writer: LineWriter = { write: () => undefined };
const sink = createStdoutAuditSink({
hmacKey: KEY,
writer,
instruments,
});
await sink.dispose();
await sink.write(makeRecord());
const dropped = await readCounterValue(
provider,
reader,
exporter,
"api.audit.dropped_total",
(a) => a["reason"] === "closed"
);
expect(dropped).toBe(1);
await provider.shutdown();
});
});

View File

@@ -0,0 +1,326 @@
import type { RequestAuditEnvelope } from "@suaveplan/api-host/request-pipeline";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { Clock, TimersLike } from "@suaveplan/types";
import {
AUDIT_STDOUT_CODES,
AuditStdoutError,
} from "../error-codes/error-codes.js";
import { HMAC_FIELD, verifyRecord } from "../hmac/hmac.js";
import type { AuditRecord } from "../schema/schema.js";
import {
adaptEnvelope,
createStdoutAuditSink,
type LineWriter,
type StdoutAuditSink,
} from "./sink.js";
const KEY = new TextEncoder().encode("a-32-byte-key-for-tests-padding!");
function makeRecord(over: Partial<AuditRecord> = {}): AuditRecord {
return {
ts: "2026-05-26T10:00:00.000Z",
type: "admin.plugin.disabled",
principal: { id: "user_1", scopes: ["admin"] },
tenant: "tenant_a",
request_id: "req_1",
trace_id: null,
subject: "plugin.exports",
outcome: "success",
...over,
};
}
class CollectingWriter implements LineWriter {
public readonly lines: string[] = [];
public throwOnNext = false;
public async write(line: string): Promise<void> {
if (this.throwOnNext) {
this.throwOnNext = false;
throw new Error("boom");
}
this.lines.push(line);
}
}
describe("createStdoutAuditSink", () => {
it("writes JSON-per-line with HMAC signature", {
timeout: 5000,
}, async () => {
const writer = new CollectingWriter();
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
await sink.write(makeRecord());
expect(writer.lines.length).toBe(1);
const line = writer.lines[0] ?? "";
expect(line.endsWith("\n")).toBe(true);
const parsed = JSON.parse(line) as Record<string, unknown>;
expect(typeof parsed[HMAC_FIELD]).toBe("string");
const { _hmac, ...rest } = parsed as { _hmac: string } & AuditRecord;
expect(verifyRecord(rest as AuditRecord, KEY, _hmac)).toBe(true);
await sink.dispose();
});
it("rejects records that fail Zod validation", {
timeout: 5000,
}, async () => {
const writer = new CollectingWriter();
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
let caught: unknown;
try {
// @ts-expect-error — testing runtime rejection
await sink.write({ ...makeRecord(), request_id: undefined });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(AuditStdoutError);
if (caught instanceof AuditStdoutError) {
expect(caught.code).toBe(AUDIT_STDOUT_CODES.INVALID_RECORD);
}
expect(writer.lines.length).toBe(0);
await sink.dispose();
});
it("emit() adapts a RequestAuditEnvelope to canonical record", {
timeout: 5000,
}, async () => {
const writer = new CollectingWriter();
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
const envelope: RequestAuditEnvelope = {
at: "2026-05-26T10:00:00.000Z",
requestId: "req_1",
traceId: null,
route: "/admin/plugin",
plugin: "@core/admin",
method: "POST",
principal: { id: "u1", scopes: ["admin"] },
tenantId: "t1",
status: 200,
outcome: "success",
};
await sink.emit(envelope);
expect(writer.lines.length).toBe(1);
const parsed = JSON.parse(writer.lines[0] ?? "") as Record<
string,
unknown
>;
expect(parsed["subject"]).toBe("POST /admin/plugin");
expect(parsed["type"]).toBe("request.success");
expect(parsed["request_id"]).toBe("req_1");
await sink.dispose();
});
it("emit() forwards cause on failure envelopes", {
timeout: 5000,
}, async () => {
const writer = new CollectingWriter();
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
await sink.emit({
at: "2026-05-26T10:00:00.000Z",
requestId: "req_2",
traceId: "trace_1",
route: "/admin/plugin",
plugin: "@core/admin",
method: "POST",
principal: null,
tenantId: null,
status: 500,
outcome: "failure",
cause: { code: "PLUGIN.BOOM", message: "kaboom" },
});
const parsed = JSON.parse(writer.lines[0] ?? "") as Record<
string,
unknown
>;
expect(parsed["outcome"]).toBe("failure");
expect(parsed["cause"]).toEqual({
code: "PLUGIN.BOOM",
message: "kaboom",
});
await sink.dispose();
});
it("writer rejection raises WRITER_FAILED", { timeout: 5000 }, async () => {
const writer = new CollectingWriter();
writer.throwOnNext = true;
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
let caught: unknown;
try {
await sink.write(makeRecord());
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(AuditStdoutError);
if (caught instanceof AuditStdoutError) {
expect(caught.code).toBe(AUDIT_STDOUT_CODES.WRITER_FAILED);
expect(caught.cause).toBeInstanceOf(Error);
}
await sink.dispose();
});
it("non-Error writer throw still raises WRITER_FAILED without cause", {
timeout: 5000,
}, async () => {
const writer: LineWriter = {
write: () => {
throw "literal-string-throw";
},
};
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
let caught: unknown;
try {
await sink.write(makeRecord());
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(AuditStdoutError);
if (caught instanceof AuditStdoutError) {
expect(caught.code).toBe(AUDIT_STDOUT_CODES.WRITER_FAILED);
expect(caught.cause).toBeUndefined();
}
await sink.dispose();
});
it("dispose makes subsequent writes silent no-ops", {
timeout: 5000,
}, async () => {
const writer = new CollectingWriter();
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
await sink.dispose();
await sink.write(makeRecord());
expect(writer.lines.length).toBe(0);
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
const writer = new CollectingWriter();
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
await sink.dispose();
await sink.dispose();
expect(writer.lines.length).toBe(0);
});
it("rejects an empty hmacKey", { timeout: 5000 }, () => {
expect(() =>
createStdoutAuditSink({ hmacKey: new Uint8Array(0) })
).toThrow(AuditStdoutError);
});
it("rejects a non-Uint8Array hmacKey", { timeout: 5000 }, () => {
expect(() =>
createStdoutAuditSink({
// @ts-expect-error — testing runtime rejection
hmacKey: "not-bytes",
})
).toThrow(AuditStdoutError);
});
it("rejects invalid scalar options", { timeout: 5000 }, () => {
expect(() =>
createStdoutAuditSink({ hmacKey: KEY, maxQueueMs: -1 })
).toThrow(AuditStdoutError);
});
it("uses the default stdout writer when none is provided", {
timeout: 5000,
}, async () => {
const original = process.stdout.write.bind(process.stdout);
const captured: string[] = [];
const stub: typeof process.stdout.write = ((
chunk: string | Uint8Array
): boolean => {
captured.push(
typeof chunk === "string"
? chunk
: new TextDecoder().decode(chunk)
);
return true;
}) as typeof process.stdout.write;
process.stdout.write = stub;
try {
const sink = createStdoutAuditSink({ hmacKey: KEY });
await sink.write(makeRecord());
await sink.dispose();
} finally {
process.stdout.write = original;
}
expect(captured.length).toBe(1);
expect((captured[0] ?? "").endsWith("\n")).toBe(true);
});
it("typed surface satisfies RequestAuditSink", { timeout: 5000 }, () => {
const sink: StdoutAuditSink = createStdoutAuditSink({ hmacKey: KEY });
expectTypeOf(sink.emit).toBeFunction();
});
});
describe("adaptEnvelope", () => {
it("strips cause when envelope omits it", { timeout: 5000 }, () => {
const r = adaptEnvelope({
at: "2026-05-26T10:00:00.000Z",
requestId: "r",
traceId: null,
route: "/x",
plugin: "p",
method: "GET",
principal: null,
tenantId: null,
status: 200,
outcome: "success",
});
expect(r.cause).toBeUndefined();
});
});
describe("clock + timers wiring", () => {
it("uses injected clock for warning throttle", {
timeout: 5000,
}, async () => {
// Reach for the clock by forcing a writer error so the warning
// path runs. The throttle uses `clock()` for `lastWarnAt`.
let now = 0;
const clock: Clock = () => now;
const writer = new CollectingWriter();
writer.throwOnNext = true;
const sink = createStdoutAuditSink({ hmacKey: KEY, writer, clock });
try {
await sink.write(makeRecord());
} catch {
/* expected */
}
now = 1_000_000;
// Should not throw on the second attempt with a fresh writer.
await sink.write(makeRecord());
expect(writer.lines.length).toBe(1);
await sink.dispose();
});
it("uses injected timers for queue-full detection", {
timeout: 5000,
}, async () => {
let lastDelay = -1;
let cleared = 0;
let counter = 1;
const timers: TimersLike = {
setTimer: (cb, ms) => {
lastDelay = ms;
const id = counter++;
// Defer to macrotask so a free slot's inflight.then
// microtask resolves first.
setImmediate(cb);
return id;
},
clearTimer: () => {
cleared += 1;
},
};
const writer = new CollectingWriter();
const sink = createStdoutAuditSink({
hmacKey: KEY,
writer,
timers,
maxQueueMs: 500,
});
await sink.write(makeRecord());
expect(lastDelay).toBe(500);
expect(cleared).toBeGreaterThanOrEqual(1);
await sink.dispose();
});
});

View 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`;
}