Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
# @suaveplan/api-audit-file — audit sink writing JSON-per-line, HMAC-SHA-256 signed records to a rotated file. Size + time rotation, maxFiles retention, bounded-queue backpressure with observable drops, append + strict-sync write modes. Implements `RequestAuditSink` from `@suaveplan/api-host/request-pipeline`.
File-rotating, HMAC-signed audit sink for `@suaveplan/api-host`.
Implements `RequestAuditSink` from
`@suaveplan/api-host/request-pipeline`: writes one JSON line per
record with an HMAC-SHA-256 signature, rolls the file on a size OR age
trigger, and prunes the oldest rotated siblings to a configurable
`maxFiles` retention cap.
Suitable for single-host deployments, on-prem environments without a
log shipper, and any host that prefers durable local storage of the
audit trail. The sibling `@suaveplan/api-audit-stdout` covers the
log-shipped use case; both sinks share the wire shape so dashboards
union on metric name and partition by the `sink` attribute.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Frozen registry of the six stable `AUDIT_FILE.*` error codes raised by the file-rotating audit sink, plus the concrete…
- [error-codes](./src/error-codes/error-codes.md) — Frozen registry of the six stable `AUDIT_FILE.*` error codes raised by the file-rotating audit sink, plus the concrete…
- [fs-helpers](./src/fs-helpers/fs-helpers.md) — Isolates the `node:fs` surface the sink touches behind an injectable `FsAdapter` interface and concrete `nodeFsAdapter`…
- [fs-helpers](./src/fs-helpers/fs-helpers.md) — Isolates the `node:fs` surface the sink touches behind an injectable `FsAdapter` interface and concrete `nodeFsAdapter`…
- [hmac](./src/hmac/hmac.md) — Wraps `@suaveplan/crypto-hash` HMAC-SHA-256 into the two operations the file sink needs: signing a canonical record on…
- [hmac](./src/hmac/hmac.md) — Wraps `@suaveplan/crypto-hash` HMAC-SHA-256 into the two operations the file sink needs: signing a canonical record on…
- [instruments](./src/instruments/instruments.md) — Constructs the OTel counter bundle the file sink emits and exposes the canonical `sink="file"` attribute set. Every obs…
- [instruments](./src/instruments/instruments.md) — Constructs the OTel counter bundle the file sink emits and exposes the canonical `sink="file"` attribute set. Every obs…
- [retention](./src/retention/retention.md) — Enforces the `maxFiles` retention dimension of the rotation policy by deleting the oldest rotated files until at most `…
- [retention](./src/retention/retention.md) — Enforces the `maxFiles` retention dimension of the rotation policy by deleting the oldest rotated files until at most `…
- [rotation](./src/rotation/rotation.md) — Resolves the rotation policy carried by every `createFileAuditSink` instance and implements the rotation primitive itse…
- [rotation](./src/rotation/rotation.md) — Resolves the rotation policy carried by every `createFileAuditSink` instance and implements the rotation primitive itse…
- [schema](./src/schema/schema.md) — Defines the Zod schema and canonical-bytes function that bracket every audit record before it is signed and written. Th…
- [schema](./src/schema/schema.md) — Defines the Zod schema and canonical-bytes function that bracket every audit record before it is signed and written. Th…
- [sink](./src/sink/sink.md) — The public entry point of `@suaveplan/api-audit-file`. The `createFileAuditSink` factory composes the schema, hmac, rot…
- [sink](./src/sink/sink.md) — The public entry point of `@suaveplan/api-audit-file`. The `createFileAuditSink` factory composes the schema, hmac, rot…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-audit-file
```
## Quickstart
```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 ?? ""),
rotation: { maxSizeBytes: 100 * 1024 * 1024, maxAgeMs: 86_400_000, maxFiles: 30 },
appendMode: "strict-sync",
});
await sink.emit(envelopeFromHostPipeline);
await sink.dispose();
```
## Modules
- `./sink` — `createFileAuditSink`, `FileAuditSink`, `CreateFileAuditSinkOptions`, `AppendMode`.
- `./rotation` — `RotationPolicy`, `RotationState`, `resolveRotationPolicy`, `shouldRotate`, `rotate`.
- `./retention` — `enforceRetention`.
- `./schema` — `AuditRecord`, `AuditRecordSchema`, `canonicalise`, `buildSignedLine`.
- `./hmac` — `signRecord`, `verifyRecord`.
- `./instruments` — `AuditFileInstruments`, `createAuditFileInstruments`, `SINK_ATTRIBUTE`.
- `./fs-helpers` — `FsAdapter`, `nodeFsAdapter`, `listRotatedFiles`, `rotatedFilePathAt`.
- `./error-codes` — `AUDIT_FILE_CODES`, `AuditFileError`, `AuditFileCode`.
## Error codes
`AUDIT_FILE.INVALID_RECORD`, `AUDIT_FILE.WRITER_FAILED`,
`AUDIT_FILE.QUEUE_FULL`, `AUDIT_FILE.ROTATION_FAILED`,
`AUDIT_FILE.RETENTION_FAILED`, `AUDIT_FILE.PATH_NOT_WRITEABLE`.
## OTel instruments
- `api.audit.records_total{sink="file"}`
- `api.audit.dropped_total{sink="file"}`
- `api.audit.rotation_evicted_total{sink="file"}`
## Tier
Server-tier — depends on `node:fs/promises`.

View File

@@ -0,0 +1,83 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Frozen registry of the six stable `AUDIT_FILE.*` error codes raised by
the file-rotating audit sink, plus the concrete
`AuditFileError extends SuaveplanError` subclass that carries them.
Operators rely on the codes for dashboard slicing; package consumers
rely on the typed subclass to differentiate sink failures from generic
host errors without parsing message strings.
The registry is built with `defineCodes("AUDIT_FILE", [...])` from
`@suaveplan/error`, so every code is recorded in the global registry
exactly once at module load. The OpenSpec conformance gate verifies
every entry here is also raised by a production code path elsewhere in
`src/` — no dead codes are permitted.
## Features
- Six stable codes: `INVALID_RECORD`, `WRITER_FAILED`, `QUEUE_FULL`,
`ROTATION_FAILED`, `RETENTION_FAILED`, `PATH_NOT_WRITEABLE`.
- Typed union `AuditFileCode` for narrowing on `error.code`.
- `AuditFileError` subclass with stable `error.name === "AuditFileError"`.
- Forwards `cause`, `context`, `redacted`, and other
`SuaveplanErrorOptions` to the base constructor unchanged.
## Usage: Basic
```ts
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "@suaveplan/api-audit-file/error-codes";
throw new AuditFileError(
AUDIT_FILE_CODES.PATH_NOT_WRITEABLE,
"error.audit.file.path_not_writeable",
{ context: { path: "/var/log/suaveplan/audit.log" } }
);
```
## Usage: Advanced
Narrow on the union after catching to drive code-specific recovery:
```ts
import {
AUDIT_FILE_CODES,
type AuditFileCode,
AuditFileError,
} from "@suaveplan/api-audit-file/error-codes";
function recover(err: unknown): "retry" | "drop" {
if (!(err instanceof AuditFileError)) throw err;
const code: AuditFileCode = err.code;
if (code === AUDIT_FILE_CODES.QUEUE_FULL) return "drop";
if (code === AUDIT_FILE_CODES.WRITER_FAILED) return "retry";
if (code === AUDIT_FILE_CODES.ROTATION_FAILED) return "retry";
throw err;
}
```
## API Reference
- `AUDIT_FILE_CODES: Readonly<Record<Name, "AUDIT_FILE.<Name>">>`
- `type AuditFileCode = (typeof AUDIT_FILE_CODES)[keyof typeof AUDIT_FILE_CODES]`
- `AUDIT_FILE_ERROR_NAME = "AuditFileError"`
- `class AuditFileError extends SuaveplanError<AuditFileCode>`
- `new AuditFileError(code, messageKey, options?)`
## Implementation Notes
The codes are declared `as const` so TypeScript freezes both the keys
and the fully-qualified string values. The `name` literal is exported
separately because some structured loggers group errors on `name`
before they inspect `code` — pinning a constant keeps that grouping
stable across releases. The module is side-effect-imported from
`src/index.ts` so a consumer who only loads a subpath
(`@suaveplan/api-audit-file/sink`) still triggers code registration.

View File

@@ -0,0 +1,86 @@
# `fs-helpers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Isolates the `node:fs` surface the sink touches behind an injectable
`FsAdapter` interface and concrete `nodeFsAdapter` implementation. The
indirection lets tests run the sink against a real filesystem under a
temp directory (the default for this package's test suite) while
keeping a clean seam for in-memory adapters used by external consumers.
The module also exports the rotated-file enumeration helper used by
the retention module — keeping the file-name conventions next to the
filesystem primitives means rotation, retention, and sink all share
the same view of "what counts as a rotated sibling of this base path".
## Features
- `FsAdapter` — six-method surface: `appendLine`, `fileSize`, `rename`,
`unlink`, `readdir`, `ensureWriteableDir`.
- `nodeFsAdapter` — production adapter against `node:fs/promises`.
- `listRotatedFiles(fs, basePath)` — returns full paths of every
rotated sibling, sorted oldest-first by embedded timestamp.
- `rotatedFilePathAt(basePath, iso)` — builds the rotated path for the
supplied ISO timestamp; `:` is replaced with `-` so the name is
Windows-safe and grep-friendly.
## Usage: Basic
```ts
import { nodeFsAdapter } from "@suaveplan/api-audit-file/fs-helpers";
await nodeFsAdapter.ensureWriteableDir("/var/log/suaveplan");
await nodeFsAdapter.appendLine("/var/log/suaveplan/audit.log", "...\n", false);
```
## Usage: Advanced
Provide an injectable adapter to drive the sink against an in-memory
filesystem:
```ts
import type { FsAdapter } from "@suaveplan/api-audit-file/fs-helpers";
const inMemory = new Map<string, string>();
const adapter: FsAdapter = {
async appendLine(p, line) {
inMemory.set(p, (inMemory.get(p) ?? "") + line);
},
async fileSize(p) {
return Buffer.byteLength(inMemory.get(p) ?? "", "utf8");
},
async rename(from, to) {
inMemory.set(to, inMemory.get(from) ?? "");
inMemory.delete(from);
},
async unlink(p) {
inMemory.delete(p);
},
async readdir() {
return [...inMemory.keys()];
},
async ensureWriteableDir() {
/* always writeable */
},
};
```
## API Reference
- `interface FsAdapter` — see Features above.
- `const nodeFsAdapter: FsAdapter`
- `listRotatedFiles(fs: FsAdapter, basePath: string): Promise<string[]>`
- `rotatedFilePathAt(basePath: string, isoTimestamp: string): string`
## Implementation Notes
`ensureWriteableDir` calls `mkdir(..., { recursive: true })` then a
`W_OK` `access` probe so a fresh boot creates the directory and a
borked permission setup throws `PATH_NOT_WRITEABLE` synchronously
during sink construction. `fileSize` swallows `ENOENT` and returns `0`
so the rotation logic naturally treats a not-yet-created file as
empty — that simplifies the open-on-first-write code path.

View File

@@ -0,0 +1,76 @@
# `hmac`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps `@suaveplan/crypto-hash` HMAC-SHA-256 into the two operations the
file sink needs: signing a canonical record on write and verifying a
record + signature on read. The wrapper hex-encodes the digest so the
on-disk line stays human-greppable and ASCII-clean, and exposes a
constant-time verifier so tampering checks do not leak position
information through timing.
## Features
- `signRecord(key, bytes): string` — 64-char lowercase hex of
HMAC-SHA-256 over `bytes` using `key`.
- `verifyRecord(key, bytes, hmacHex): boolean` — constant-time
comparison of recomputed HMAC against the provided hex.
- Empty-key guard raises a typed `Error` so misconfiguration cannot
silently produce a deterministic null-MAC.
## Usage: Basic
```ts
import { signRecord, verifyRecord } from "@suaveplan/api-audit-file/hmac";
const key = new TextEncoder().encode("super-secret-rotating-key");
const bytes = new TextEncoder().encode('{"requestId":"r-1"}');
const hmac = signRecord(key, bytes);
verifyRecord(key, bytes, hmac); // → true
```
## Usage: Advanced
Use with the canonical-bytes helper to verify an on-disk line:
```ts
import {
AuditRecordSchema,
canonicalise,
} from "@suaveplan/api-audit-file/schema";
import { verifyRecord } from "@suaveplan/api-audit-file/hmac";
for await (const line of lines) {
const { record, hmac } = JSON.parse(line) as {
record: unknown;
hmac: string;
};
const parsed = AuditRecordSchema.parse(record);
const ok = verifyRecord(key, canonicalise(parsed), hmac);
if (!ok) throw new Error("hmac mismatch");
}
```
## API Reference
- `signRecord(key: Uint8Array, bytes: Uint8Array): string`
- `verifyRecord(key: Uint8Array, bytes: Uint8Array, hmacHex: string): boolean`
## Implementation Notes
The hex encoder is hand-rolled rather than borrowed from
`@suaveplan/crypto-utils` so this module stays a leaf of the dependency
graph — the sink runs on every audit-emitting request, and keeping the
hot path's transitive deps minimal helps cold-start latency on
serverless deploys. Constant-time comparison XORs each character code
and accumulates into a bitmask; the boolean conversion at the end keeps
the early-return branch (length mismatch) bounded to attacker-visible
metadata only. The 64-character length check up front is intentionally
outside the constant-time region because the length of the supplied
HMAC is not secret — an attacker who supplies a malformed hex string
sees an immediate rejection but learns nothing about the recomputed
digest.

View File

@@ -0,0 +1,78 @@
# `instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Constructs the OTel counter bundle the file sink emits and exposes the
canonical `sink="file"` attribute set. Every observation carries the
attribute so a dashboard tracking `api.audit.records_total` across the
sink family — `stdout`, `file`, and future siblings — can union the
metric name and partition by `sink`. The three counters answer the
operator questions "are records flowing?", "are records dropping?",
and "is retention pruning old files?".
## Features
- `createAuditFileInstruments({ meterProvider? })` — returns a frozen
bundle of three counters bound to the supplied or global
`MeterProvider`.
- `SINK_ATTRIBUTE` — frozen `{ sink: "file" }` literal exported for
reuse by the sink, retention, and test harnesses.
- Counters: `api.audit.records_total`, `api.audit.dropped_total`,
`api.audit.rotation_evicted_total`.
## Usage: Basic
```ts
import { createAuditFileInstruments } from "@suaveplan/api-audit-file/instruments";
const instruments = createAuditFileInstruments();
instruments.recordsTotal.add(1, { sink: "file" });
```
## Usage: Advanced
Install a real `MeterProvider` and assert deltas in tests:
```ts
import {
MeterProvider,
InMemoryMetricExporter,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { createAuditFileInstruments } from "@suaveplan/api-audit-file/instruments";
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({ exporter });
const provider = new MeterProvider({ readers: [reader] });
const instruments = createAuditFileInstruments({ meterProvider: provider });
instruments.recordsTotal.add(1, { sink: "file" });
await reader.collect();
```
## API Reference
- `interface AuditFileInstruments`
- `meter: Meter`
- `recordsTotal: Counter`
- `droppedTotal: Counter`
- `rotationEvictedTotal: Counter`
- `interface AuditFileInstrumentsConfig`
- `meterProvider?: MeterProvider`
- `createAuditFileInstruments(config?): AuditFileInstruments`
- `SINK_ATTRIBUTE: Readonly<{ sink: "file" }>`
## Implementation Notes
The bundle is intentionally NOT module-level cached — every sink
instance gets its own bundle so an operator who reconstructs the host
under a fresh `MeterProvider` (a test-host pattern in
`@suaveplan/api-host/testing`) sees deltas against the right provider.
Counters from the sibling `api-audit-stdout` sink share the metric
NAMES with this package so a single dashboard panel covers the family;
the `sink="file"` attribute distinguishes the source.

View File

@@ -0,0 +1,67 @@
# `retention`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Enforces the `maxFiles` retention dimension of the rotation policy by
deleting the oldest rotated files until at most `maxFiles` remain.
Retention runs immediately after each rotation succeeds so the disk
footprint is bounded across long-running deployments — an audit
directory left to grow unchecked is the most common operator pain
point with file-based sinks, and this module's existence is exactly
the answer to it.
The increment on `api.audit.rotation_evicted_total{sink="file"}` makes
eviction observable. Operators alert on an unexpectedly high rate so a
runaway rotation cycle (e.g. clock skew, misconfigured `maxAgeMs`) is
visible before the retention sweep drops historical data.
## Features
- `enforceRetention(fs, basePath, maxFiles, instruments)` — deletes
the oldest rotated files until at most `maxFiles` remain. Idempotent:
re-running with no surplus is a no-op.
- Counter increment per deletion with the canonical `sink="file"`
attribute set.
- Surfaces `AUDIT_FILE.RETENTION_FAILED` on a delete failure with the
offending path in `context.path` and the underlying `cause`.
## Usage: Basic
```ts
import { enforceRetention } from "@suaveplan/api-audit-file/retention";
await enforceRetention(fs, "/var/log/audit.log", 30, instruments);
```
## Usage: Advanced
Call from a custom rotation cadence — e.g. an external cron that wants
to prune without rotating:
```ts
import { enforceRetention } from "@suaveplan/api-audit-file/retention";
// Cron job: every hour, prune to a smaller retention than the sink's
// default to claw back disk during high-traffic windows.
setInterval(async () => {
await enforceRetention(fs, basePath, 10, instruments);
}, 60 * 60 * 1000);
```
## API Reference
- `enforceRetention(fs: FsAdapter, basePath: string, maxFiles: number, instruments: AuditFileInstruments): Promise<void>`
## Implementation Notes
`listRotatedFiles` returns paths sorted oldest-first by the embedded
ISO timestamp; we slice off the first `(count - maxFiles)` entries and
unlink them. Sorting is lexicographic on the ISO string, which is also
chronological for the `YYYY-MM-DDTHH-MM-SS.sssZ` shape — no extra
parsing required. If `unlink` fails midway, the previously-evicted
deletions are not undone; the throw surfaces the failure and the next
rotation re-attempts retention against whatever survived.

View File

@@ -0,0 +1,95 @@
# `rotation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Resolves the rotation policy carried by every `createFileAuditSink`
instance and implements the rotation primitive itself — the
`rename(currentFile → currentFile.<utc-iso>.log)` step plus the
post-rotation state reset. The module owns both dimensions documented
in the spec: size-based (`maxSizeBytes`, default 100 MiB) and
time-based (`maxAgeMs`, default 24h). Either condition reaching its
limit triggers a rotation on the next write.
Rotation lives in a separate module so the sink stays focused on
queueing + writing. The retention module reads the policy through the
same `RotationState` so the operator-facing knobs are documented in
exactly one place.
## Features
- `resolveRotationPolicy(input?)` — Zod-equivalent validator returning
a frozen `RotationPolicy`. Defaults filled from the design doc.
- `shouldRotate(state, nowMs)` — pure predicate; size OR age satisfies.
- `rotate(fs, basePath, state, clock)` — performs the rename, updates
state, returns the rotated path for retention to inspect.
- Raises `AUDIT_FILE.ROTATION_FAILED` on rename failure with full
`cause` for forensic logging.
## Usage: Basic
```ts
import {
resolveRotationPolicy,
rotate,
shouldRotate,
} from "@suaveplan/api-audit-file/rotation";
const policy = resolveRotationPolicy({ maxSizeBytes: 1024 * 1024 });
const state = { openedAtMs: Date.now(), bytesWritten: 0, policy };
if (shouldRotate(state, Date.now())) {
await rotate(fs, "/var/log/audit.log", state, () => Date.now());
}
```
## Usage: Advanced
Disable a dimension by setting it to `Number.POSITIVE_INFINITY` —
useful when an operator wants pure time rotation regardless of size:
```ts
const policy = resolveRotationPolicy({
maxSizeBytes: Number.POSITIVE_INFINITY,
maxAgeMs: 60 * 60 * 1000, // hourly
});
```
In tests, drive rotation deterministically with a `FakeClock` from
`@suaveplan/testing`:
```ts
import { FakeClock } from "@suaveplan/testing";
const clock = new FakeClock(0);
const state = { openedAtMs: 0, bytesWritten: 0, policy };
clock.advance(61_000);
await rotate(fs, basePath, state, clock.read);
```
## API Reference
- `interface RotationPolicy`
- `maxSizeBytes: number` — bytes before size rotation; default 100 MiB.
- `maxAgeMs: number` — ms before time rotation; default 24h.
- `maxFiles: number` — kept rotated files; default 30.
- `interface RotationState`
- `openedAtMs: number` — wall-clock open time of current segment.
- `bytesWritten: number` — running byte count.
- `policy: RotationPolicy` — frozen at construction.
- `resolveRotationPolicy(input?): RotationPolicy`
- `shouldRotate(state, nowMs): boolean`
- `rotate(fs, basePath, state, clock): Promise<string>` — returns the
rotated file path.
## Implementation Notes
The rotated suffix replaces `:` with `-` so the resulting filename is
Windows-portable and grep-friendly across shells. State mutation
happens AFTER the rename succeeds — a failed rename leaves the original
state intact, so the next call retries against the same segment rather
than corrupting accounting. `bytesWritten` resets to 0 instead of
re-stat'ing the new empty file; the next successful append updates it
in lock-step with the on-disk length.

View File

@@ -0,0 +1,79 @@
# `schema`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod schema and canonical-bytes function that bracket every
audit record before it is signed and written. The schema mirrors the
`RequestAuditEnvelope` interface exported by
`@suaveplan/api-host/request-pipeline`; the canonical-bytes function
gives every other module a deterministic input for HMAC signing,
file-line construction, and signature verification.
The package re-validates at the sink boundary even though the host
constructs envelopes from typed data — operators frequently hand-craft
audit records in tests, replay tooling, or migration scripts. The
boundary check makes corrupt records fail loudly with a typed
`AUDIT_FILE.INVALID_RECORD` instead of silently bypassing the HMAC.
## Features
- `AuditRecordSchema` — strict Zod object accepting only the
envelope's documented fields. Unknown keys reject.
- `AuditRecord` — `z.infer<typeof AuditRecordSchema>`; structurally
assignable to `RequestAuditEnvelope`.
- `canonicalise(record)` — UTF-8 bytes of the record JSON-stringified
with sorted top-level keys.
- `buildSignedLine(bytes, hmacHex)` — emits the on-disk JSON line
carrying the record + HMAC, with a trailing `\n`.
## Usage: Basic
```ts
import {
AuditRecordSchema,
buildSignedLine,
canonicalise,
} from "@suaveplan/api-audit-file/schema";
const record = AuditRecordSchema.parse(rawEnvelope);
const bytes = canonicalise(record);
const line = buildSignedLine(bytes, "deadbeef...");
```
## Usage: Advanced
Verify an on-disk line:
```ts
import { hmac } from "@suaveplan/crypto-hash";
import { AuditRecordSchema, canonicalise } from "@suaveplan/api-audit-file/schema";
const { record, hmac: hmacHex } = JSON.parse(lineFromDisk);
const parsed = AuditRecordSchema.parse(record);
const bytes = canonicalise(parsed);
const expected = Buffer.from(hmac("SHA-256", key, bytes)).toString("hex");
if (expected !== hmacHex) {
throw new Error("hmac mismatch — record was tampered");
}
```
## API Reference
- `AuditRecordSchema: z.ZodObject` (strict)
- `type AuditRecord = z.infer<typeof AuditRecordSchema>`
- `canonicalise(record: AuditRecord): Uint8Array`
- `buildSignedLine(canonicalBytes: Uint8Array, hmacHex: string): string`
## Implementation Notes
The canonical bytes drop `undefined` values for the optional `cause`
slot so the absence of an error never affects the signature. Top-level
keys are sorted alphabetically to give every implementation (TypeScript,
Go log-reader, Python forensic tooling) the same input bytes for
verification regardless of construction order. `principal.scopes` is
preserved in array order — the order is semantically meaningful in the
pipeline so we do not collapse it.

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.