first commit
This commit is contained in:
79
docs/api-stack/api/api-audit-stdout/README.md
Normal file
79
docs/api-stack/api/api-audit-stdout/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
82
docs/api-stack/api/api-audit-stdout/src/hmac/hmac.md
Normal file
82
docs/api-stack/api/api-audit-stdout/src/hmac/hmac.md
Normal 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.
|
||||
@@ -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.
|
||||
75
docs/api-stack/api/api-audit-stdout/src/options/options.md
Normal file
75
docs/api-stack/api/api-audit-stdout/src/options/options.md
Normal 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.
|
||||
95
docs/api-stack/api/api-audit-stdout/src/schema/schema.md
Normal file
95
docs/api-stack/api/api-audit-stdout/src/schema/schema.md
Normal 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.
|
||||
108
docs/api-stack/api/api-audit-stdout/src/sink/sink.md
Normal file
108
docs/api-stack/api/api-audit-stdout/src/sink/sink.md
Normal 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.
|
||||
Reference in New Issue
Block a user