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:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user