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,128 @@
# `sink`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The public entry point of `@suaveplan/api-audit-file`. The
`createFileAuditSink` factory composes the schema, hmac, rotation,
retention, instruments, and fs-helpers modules into a single object
that implements the `RequestAuditSink` contract from
`@suaveplan/api-host/request-pipeline`. The sink writes each accepted
record as one JSON line with an HMAC-SHA-256 signature, rolls the file
when size OR age limits are reached, and keeps at most `maxFiles`
rotated siblings.
The sink is queue-buffered: callers `await emit()` and resolve as soon
as the record has been appended to disk. A bounded queue protects the
host from a stalled filesystem: records arriving while the queue is at
`maxQueueSize` are dropped with a typed `AUDIT_FILE.QUEUE_FULL` and an
`api.audit.dropped_total{sink="file"}` increment. `dispose()` flushes
the queue and is idempotent.
## Features
- `createFileAuditSink({ path, hmacKey, ... })` — async factory.
Returns a sink ready to `emit()`.
- Schema validation at the boundary: invalid records throw
`AUDIT_FILE.INVALID_RECORD` and increment `dropped_total`.
- `strict-sync` (default) and `append` modes for the durability /
throughput trade-off.
- Boot-time `mkdir -p` of the parent directory + `W_OK` probe;
unwriteable targets throw `AUDIT_FILE.PATH_NOT_WRITEABLE` from the
factory itself so misconfiguration is visible at host startup.
- Size + time rotation; `maxFiles` retention; per-eviction OTel counter.
- Idempotent `dispose()`; observable `queueDepth` getter for probes.
## Usage: Basic
```ts
import { createFileAuditSink } from "@suaveplan/api-audit-file/sink";
const sink = await createFileAuditSink({
path: "/var/log/suaveplan/audit.log",
hmacKey: new TextEncoder().encode(process.env.AUDIT_HMAC_KEY ?? ""),
});
await sink.emit({
at: new Date().toISOString(),
requestId: "r-1",
traceId: null,
route: "/admin/users",
plugin: "core-admin",
method: "POST",
principal: { id: "user-1", scopes: ["admin"] },
tenantId: "tenant-a",
status: 200,
outcome: "success",
});
await sink.dispose();
```
## Usage: Advanced
Run with a custom rotation policy, `append` mode, real `MeterProvider`,
and an injected `FakeClock` for deterministic tests:
```ts
import { FakeClock } from "@suaveplan/testing";
import {
MeterProvider,
InMemoryMetricExporter,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { createFileAuditSink } from "@suaveplan/api-audit-file/sink";
import { createAuditFileInstruments } from "@suaveplan/api-audit-file/instruments";
const clock = new FakeClock(0);
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({ exporter });
const meterProvider = new MeterProvider({ readers: [reader] });
const sink = await createFileAuditSink({
path: "/tmp/audit.log",
hmacKey: new TextEncoder().encode("secret"),
clock: clock.read,
rotation: { maxSizeBytes: 1024, maxAgeMs: 60_000, maxFiles: 5 },
appendMode: "append",
maxQueueSize: 256,
instruments: createAuditFileInstruments({ meterProvider }),
});
```
## API Reference
- `interface CreateFileAuditSinkOptions`
- `path: string` (required)
- `hmacKey: Uint8Array` (required, non-empty)
- `clock?: Clock` — default `() => Date.now()`
- `rotation?: Partial<RotationPolicy>`
- `appendMode?: "strict-sync" | "append"` — default `"strict-sync"`
- `maxQueueSize?: number` — default 1024
- `fs?: FsAdapter` — default `nodeFsAdapter`
- `logger?: LoggerLike` — default no-op
- `instruments?: AuditFileInstruments` — default global meter
- `interface FileAuditSink`
- `emit(record): Promise<void>`
- `dispose(): Promise<void>`
- `readonly queueDepth: number`
- `createFileAuditSink(options): Promise<FileAuditSink>`
## Implementation Notes
The drain loop is mutex-style: a single in-flight drain promise covers
every concurrent `emit()` call so writes are serialised against the
file handle even when callers fan out. Backpressure is signalled
synchronously — `emit()` throws `QUEUE_FULL` before any await when the
bounded queue is at capacity, so the host can apply load shedding
without observing a write-latency spike. Rotation runs in the same
drain frame as the triggering write so the rename always happens
before the segment grows past the cap by one extra line. Retention
runs after rotation; a retention failure is logged but does not
propagate — the sink stays available even if `unlink` is transiently
denied. The on-disk format is intentionally trivial (`{ record, hmac }`
per line) so an operator with `jq` and `openssl dgst -sha256 -hmac` can
verify integrity without any Suaveplan tooling on hand.