first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
# @suaveplan/api-audit-stdout - Stdout `RequestAuditSink` for @suaveplan/api-host. Writes JSON-per-line audit records to a Writable (default process.stdout) with an HMAC-SHA-256 signature over canonical bytes. Implements the design-doc §12.5 audit-record shape, Zod-validated at the boundary, with bounded backpressure (maxQueueMs default 100ms), observable drop counter, and a once-per-minute warning throttle. OTel counters api.audit.records_total{sink=stdout,type,outcome} and api.audit.dropped_total{sink=stdout}. Server-tier — Bun + Node.
Stdout audit sink for `@suaveplan/api-host`. Writes JSON-per-line
audit records to a `LineWriter` (default `process.stdout`) with an
HMAC-SHA-256 signature appended as a `_hmac` field. Implements
`RequestAuditSink` from `@suaveplan/api-host/request-pipeline`, so the
host's request pipeline accepts the instance directly via its `audit`
capability slot.
The record shape is the design-doc §12.5 canonical wire format —
`ts`, `type`, `principal`, `tenant`, `request_id`, `trace_id`,
`subject`, `outcome`, and a conditional `cause`. Each record is
Zod-validated at the boundary before signing, so a malformed record
never reaches the log pipeline.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — This module declares the four stable error codes the stdout audit sink exposes to consumers. Every failure path inside…
- [error-codes](./src/error-codes/error-codes.md) — This module declares the four stable error codes the stdout audit sink exposes to consumers. Every failure path inside…
- [hmac](./src/hmac/hmac.md) — This module produces the HMAC-SHA-256 tag attached to every audit line. Signing operates on a *canonical* byte represen…
- [hmac](./src/hmac/hmac.md) — This module produces the HMAC-SHA-256 tag attached to every audit line. Signing operates on a *canonical* byte represen…
- [instruments](./src/instruments/instruments.md) — This module owns the two counters the stdout sink emits at runtime, matching design doc §12.3's metric surface for the…
- [instruments](./src/instruments/instruments.md) — This module owns the two counters the stdout sink emits at runtime, matching design doc §12.3's metric surface for the…
- [options](./src/options/options.md) — This module owns the Zod schema and defaults for the scalar slice of `createStdoutAuditSink({...})`'s input. Validation…
- [options](./src/options/options.md) — This module owns the Zod schema and defaults for the scalar slice of `createStdoutAuditSink({...})`'s input. Validation…
- [schema](./src/schema/schema.md) — This module locks the wire shape of the JSON line the stdout sink writes. The shape is exactly what design doc §12.5 sp…
- [schema](./src/schema/schema.md) — This module locks the wire shape of the JSON line the stdout sink writes. The shape is exactly what design doc §12.5 sp…
- [sink](./src/sink/sink.md) — This module wires the schema, HMAC signer, instruments, and options into a `RequestAuditSink` implementation that write…
- [sink](./src/sink/sink.md) — This module wires the schema, HMAC signer, instruments, and options into a `RequestAuditSink` implementation that write…
<!-- end:auto-docs -->
## Modules
- [`./sink`](./src/sink/sink.md) — `createStdoutAuditSink`, the
factory that ties everything together.
- [`./schema`](./src/schema/schema.md) — `AuditRecord` Zod schema and
boundary parser.
- [`./hmac`](./src/hmac/hmac.md) — canonical bytes and HMAC-SHA-256
signer / verifier.
- [`./instruments`](./src/instruments/instruments.md) — OTel counters
(`api.audit.records_total`, `api.audit.dropped_total`).
- [`./options`](./src/options/options.md) — scalar option schema +
defaults (`maxQueueMs = 100`, `warnThrottleMs = 60_000`).
- [`./error-codes`](./src/error-codes/error-codes.md) —
`AUDIT_STDOUT.*` registry (`INVALID_RECORD`, `WRITER_FAILED`,
`QUEUE_FULL`, `INVALID_OPTIONS`).
## Quick start
```ts
import { createStdoutAuditSink } from "@suaveplan/api-audit-stdout";
const sink = createStdoutAuditSink({
hmacKey: new TextEncoder().encode(process.env["AUDIT_HMAC_KEY"]!),
});
await sink.emit({
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",
});
```
## Drop policy
Backpressure is bounded by `maxQueueMs` (default 100ms). When the
queue wait exceeds the bound the record is dropped, the
`api.audit.dropped_total{sink,reason}` counter increments, and
(subject to a once-per-`warnThrottleMs` throttle) a single warning is
logged. Drops are NEVER silent.

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,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,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,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,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,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.