Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,108 @@
{
"name": "@suaveplan/api-audit-file",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-8 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`.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./sink": {
"import": {
"types": "./dist/sink/sink.d.ts",
"default": "./dist/sink/sink.js"
}
},
"./rotation": {
"import": {
"types": "./dist/rotation/rotation.d.ts",
"default": "./dist/rotation/rotation.js"
}
},
"./retention": {
"import": {
"types": "./dist/retention/retention.d.ts",
"default": "./dist/retention/retention.js"
}
},
"./schema": {
"import": {
"types": "./dist/schema/schema.d.ts",
"default": "./dist/schema/schema.js"
}
},
"./hmac": {
"import": {
"types": "./dist/hmac/hmac.d.ts",
"default": "./dist/hmac/hmac.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./instruments": {
"import": {
"types": "./dist/instruments/instruments.d.ts",
"default": "./dist/instruments/instruments.js"
}
},
"./fs-helpers": {
"import": {
"types": "./dist/fs-helpers/fs-helpers.d.ts",
"default": "./dist/fs-helpers/fs-helpers.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,65 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Frozen registry of stable error codes raised by the
* `@suaveplan/api-audit-file` sink. Codes are documented in the design
* doc (§12.5) and surface verbatim on `RequestAuditSink` callers and
* operator dashboards so post-hoc triage of audit-write failures is
* trivial.
*
* - `INVALID_RECORD` — the record handed to `emit()` failed Zod
* validation against the canonical `RequestAuditEnvelope` shape.
* - `WRITER_FAILED` — a low-level `node:fs` write surfaced an `EIO`,
* `ENOSPC`, or other error after the file was opened successfully.
* - `QUEUE_FULL` — the bounded in-memory queue rejected the record
* because the writer is stalled and `maxQueueSize` is exceeded.
* Always paired with a `dropped_total` increment.
* - `ROTATION_FAILED` — size or time rotation failed mid-rename. The
* sink stays operational against the current file; the next write
* re-attempts rotation.
* - `RETENTION_FAILED` — `maxFiles` eviction failed to delete the
* oldest rotated file. The sink keeps writing the current file; the
* surplus file is reported via the OTel attribute on the next eviction.
* - `PATH_NOT_WRITEABLE` — sink construction discovered the path's
* parent directory is missing, not a directory, or not writeable.
* Raised synchronously from `createFileAuditSink` so misconfiguration
* fails fast at host boot.
*/
export const AUDIT_FILE_CODES = defineCodes("AUDIT_FILE", [
"INVALID_RECORD",
"WRITER_FAILED",
"QUEUE_FULL",
"ROTATION_FAILED",
"RETENTION_FAILED",
"PATH_NOT_WRITEABLE",
] as const);
/**
* Union of fully-qualified `AUDIT_FILE.*` codes. Use as the `code`
* field type on any `AuditFileError` throwable.
*/
export type AuditFileCode =
(typeof AUDIT_FILE_CODES)[keyof typeof AUDIT_FILE_CODES];
/** Stable `error.name` value for every throwable this package emits. */
export const AUDIT_FILE_ERROR_NAME = "AuditFileError" as const;
/**
* Concrete `SuaveplanError` subclass for file-audit failures. Mirrors
* the shape of every other api-stack error class so callers may
* `instanceof`-check or code-match without parsing message strings.
*/
export class AuditFileError extends SuaveplanError<AuditFileCode> {
public constructor(
code: AuditFileCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = AUDIT_FILE_ERROR_NAME;
}
}

View File

@@ -0,0 +1,6 @@
export {
AUDIT_FILE_CODES,
AUDIT_FILE_ERROR_NAME,
type AuditFileCode,
AuditFileError,
} from "./error-codes.js";

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,133 @@
import { chmod, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "../error-codes/error-codes.js";
import {
listRotatedFiles,
nodeFsAdapter,
rotatedFilePathAt,
} from "./fs-helpers.js";
async function freshDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "audit-fs-"));
}
describe("api-audit-file fs-helpers", () => {
it("ensureWriteableDir creates the dir then probes W_OK", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const sub = join(dir, "deep", "nested");
await nodeFsAdapter.ensureWriteableDir(sub);
await writeFile(join(sub, "ok.txt"), "yes", "utf8");
});
it("ensureWriteableDir raises PATH_NOT_WRITEABLE on permission denied", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
await chmod(dir, 0o400);
let caught: unknown;
try {
await nodeFsAdapter.ensureWriteableDir(join(dir, "child"));
} catch (err) {
caught = err;
} finally {
await chmod(dir, 0o700);
}
expect(caught).toBeInstanceOf(AuditFileError);
expect((caught as AuditFileError).code).toBe(
AUDIT_FILE_CODES.PATH_NOT_WRITEABLE
);
});
it("fileSize returns the byte length of an existing file", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const path = join(dir, "x.log");
await writeFile(path, "0123456789", "utf8");
const size = await nodeFsAdapter.fileSize(path);
expect(size).toBe(10);
});
it("fileSize returns 0 for a non-existent path", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const size = await nodeFsAdapter.fileSize(join(dir, "absent.log"));
expect(size).toBe(0);
});
it("rename moves a file between paths", { timeout: 30_000 }, async () => {
const dir = await freshDir();
const a = join(dir, "a.log");
const b = join(dir, "b.log");
await writeFile(a, "hello", "utf8");
await nodeFsAdapter.rename(a, b);
expect(await nodeFsAdapter.fileSize(b)).toBe(5);
expect(await nodeFsAdapter.fileSize(a)).toBe(0);
});
it("unlink removes a file", { timeout: 30_000 }, async () => {
const dir = await freshDir();
const path = join(dir, "x.log");
await writeFile(path, "x", "utf8");
await nodeFsAdapter.unlink(path);
expect(await nodeFsAdapter.fileSize(path)).toBe(0);
});
it("readdir enumerates directory entries", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
await writeFile(join(dir, "a"), "a", "utf8");
await writeFile(join(dir, "b"), "b", "utf8");
const entries = await nodeFsAdapter.readdir(dir);
expect(entries.sort()).toEqual(["a", "b"]);
});
it("listRotatedFiles returns [] when readdir throws", {
timeout: 30_000,
}, async () => {
const failingAdapter = {
...nodeFsAdapter,
async readdir() {
throw new Error("EACCES");
},
};
const out = await listRotatedFiles(failingAdapter, "/tmp/x/audit.log");
expect(out).toEqual([]);
});
it("listRotatedFiles ignores the active file and non-rotated siblings", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const basePath = join(dir, "audit.log");
await writeFile(basePath, "active", "utf8");
await writeFile(
`${basePath}.2026-05-26T10-00-00.000Z.log`,
"x",
"utf8"
);
await writeFile(join(dir, "README.md"), "doc", "utf8");
const out = await listRotatedFiles(nodeFsAdapter, basePath);
expect(out).toHaveLength(1);
expect(out[0]).toContain("audit.log.2026-05-26T10-00-00.000Z.log");
});
it("rotatedFilePathAt replaces colons with hyphens", {
timeout: 5000,
}, () => {
const path = rotatedFilePathAt(
"/var/log/audit.log",
"2026-05-26T12:00:00.000Z"
);
expect(path).toBe("/var/log/audit.log.2026-05-26T12-00-00.000Z.log");
});
});

View File

@@ -0,0 +1,115 @@
import { constants } from "node:fs";
import {
access,
mkdir,
open,
readdir,
rename,
stat,
unlink,
} from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "../error-codes/error-codes.js";
/**
* Injectable adapter so the sink can run against an in-memory filesystem
* in tests when desired. All production paths default to `node:fs`
* primitives wired in {@link nodeFsAdapter}.
*/
export interface FsAdapter {
appendLine(path: string, line: string, syncOnWrite: boolean): Promise<void>;
fileSize(path: string): Promise<number>;
rename(from: string, to: string): Promise<void>;
unlink(path: string): Promise<void>;
readdir(dir: string): Promise<string[]>;
ensureWriteableDir(dir: string): Promise<void>;
}
/**
* Concrete `node:fs.promises` adapter. Production sinks construct this
* once during boot; tests may swap in an in-memory shim that implements
* the same surface.
*/
export const nodeFsAdapter: FsAdapter = {
async appendLine(path, line, syncOnWrite) {
const handle = await open(path, "a");
try {
await handle.appendFile(line, "utf8");
if (syncOnWrite) await handle.sync();
} finally {
await handle.close();
}
},
async fileSize(path) {
try {
const s = await stat(path);
return s.size;
} catch {
return 0;
}
},
async rename(from, to) {
await rename(from, to);
},
async unlink(path) {
await unlink(path);
},
async readdir(dir) {
return readdir(dir);
},
async ensureWriteableDir(dir) {
try {
await mkdir(dir, { recursive: true });
await access(dir, constants.W_OK);
} catch (cause) {
throw new AuditFileError(
AUDIT_FILE_CODES.PATH_NOT_WRITEABLE,
"error.audit.file.path_not_writeable",
{ context: { dir }, cause }
);
}
},
};
/**
* List the rotated-file siblings of `basePath`, returning their full
* paths sorted oldest-first (ascending by the embedded ISO timestamp).
* Files NOT matching the rotation suffix `<base>.<utc-iso>.log` are
* ignored so an operator's `.gitkeep` or unrelated content in the
* audit directory does not throw the retention logic off.
*/
export async function listRotatedFiles(
fs: FsAdapter,
basePath: string
): Promise<string[]> {
const dir = dirname(basePath);
const base = basename(basePath);
const prefix = `${base}.`;
const suffix = ".log";
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
return [];
}
const matches = entries.filter(
(e) => e.startsWith(prefix) && e.endsWith(suffix) && e !== base
);
matches.sort();
return matches.map((m) => join(dir, m));
}
/**
* Build the rotated-file name for `basePath` at the supplied
* `Date`-rendered timestamp. The timestamp is the file's ROTATION
* moment (i.e. the close time of the segment), not the open time.
*/
export function rotatedFilePathAt(
basePath: string,
isoTimestamp: string
): string {
return `${basePath}.${isoTimestamp.replace(/:/g, "-")}.log`;
}

View File

@@ -0,0 +1,6 @@
export {
type FsAdapter,
listRotatedFiles,
nodeFsAdapter,
rotatedFilePathAt,
} from "./fs-helpers.js";

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,69 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { signRecord, verifyRecord } from "./hmac.js";
const KEY = new TextEncoder().encode("super-secret-rotating-key");
const ALT_KEY = new TextEncoder().encode("another-key");
describe("api-audit-file hmac", () => {
it("produces a 64-char lowercase hex digest", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode('{"requestId":"r-1"}');
const out = signRecord(KEY, bytes);
expect(out).toMatch(/^[0-9a-f]{64}$/);
});
it("is deterministic for the same key + bytes", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("payload");
expect(signRecord(KEY, bytes)).toBe(signRecord(KEY, bytes));
});
it("differs when the key changes", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("payload");
expect(signRecord(KEY, bytes)).not.toBe(signRecord(ALT_KEY, bytes));
});
it("rejects an empty key with a descriptive throw", {
timeout: 5000,
}, () => {
expect(() => signRecord(new Uint8Array(0), new Uint8Array(4))).toThrow(
/key/
);
});
it("verifies a freshly-signed record", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("payload");
const hex = signRecord(KEY, bytes);
expect(verifyRecord(KEY, bytes, hex)).toBe(true);
});
it("tampering invalidates HMAC", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode('{"requestId":"r-1"}');
const hex = signRecord(KEY, bytes);
const tampered = new Uint8Array(bytes);
tampered[1] = (tampered[1] as number) ^ 0x01;
expect(verifyRecord(KEY, tampered, hex)).toBe(false);
});
it("fails verification on a wrong-length hex", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("payload");
expect(verifyRecord(KEY, bytes, "deadbeef")).toBe(false);
});
it("fails verification on a same-length but mismatched hex", {
timeout: 5000,
}, () => {
const bytes = new TextEncoder().encode("payload");
const wrong = "0".repeat(64);
expect(verifyRecord(KEY, bytes, wrong)).toBe(false);
});
it("zero-pads single-hex-digit bytes", { timeout: 5000 }, () => {
// Trigger the `b < 16` branch in bytesToHex deterministically by
// signing inputs that lead to at least one < 0x10 digest byte;
// statistically inevitable but we verify the regex on output.
for (let i = 0; i < 32; i += 1) {
const bytes = new Uint8Array([i]);
const hex = signRecord(KEY, bytes);
expect(hex).toMatch(/^[0-9a-f]{64}$/);
}
});
});

View File

@@ -0,0 +1,45 @@
import { hmac as cryptoHmac } from "@suaveplan/crypto-hash";
/**
* HMAC-SHA-256 over the canonical record bytes. Returns lowercase hex
* so the disk format is grep-able and platform-portable.
*
* Keys are typed `Uint8Array` so the caller has explicit control over
* binary key material; pass `new TextEncoder().encode(secret)` if a
* UTF-8 string is the source. The returned hex is 64 chars
* (32 bytes × 2 hex chars).
*/
export function signRecord(key: Uint8Array, bytes: Uint8Array): string {
if (key.byteLength === 0) {
throw new Error("hmac key must not be empty");
}
const digest = cryptoHmac("SHA-256", key, bytes);
return bytesToHex(digest);
}
/**
* Constant-time verification of an HMAC-hex against the canonical
* bytes. Returns `true` on match, `false` on mismatch.
*/
export function verifyRecord(
key: Uint8Array,
bytes: Uint8Array,
hmacHex: string
): boolean {
if (hmacHex.length !== 64) return false;
const expected = signRecord(key, bytes);
let diff = 0;
for (let i = 0; i < expected.length; i += 1) {
diff |= expected.charCodeAt(i) ^ hmacHex.charCodeAt(i);
}
return diff === 0;
}
function bytesToHex(bytes: Uint8Array): string {
let out = "";
for (let i = 0; i < bytes.byteLength; i += 1) {
const b = bytes[i] as number;
out += (b < 16 ? "0" : "") + b.toString(16);
}
return out;
}

View File

@@ -0,0 +1 @@
export { signRecord, verifyRecord } from "./hmac.js";

View File

@@ -0,0 +1,51 @@
/**
* `@suaveplan/api-audit-file` — file-rotating, HMAC-signed audit sink.
*
* Implements the `RequestAuditSink` contract from
* `@suaveplan/api-host/request-pipeline`. Writes JSON-per-line with an
* HMAC-SHA-256 signature over the canonical record bytes; rolls the
* file on size or age; prunes the oldest rotated siblings to keep at
* most `maxFiles` retained.
*/
import "./error-codes/error-codes.js";
export {
AUDIT_FILE_CODES,
AUDIT_FILE_ERROR_NAME,
type AuditFileCode,
AuditFileError,
} from "./error-codes/index.js";
export {
type FsAdapter,
listRotatedFiles,
nodeFsAdapter,
rotatedFilePathAt,
} from "./fs-helpers/index.js";
export { signRecord, verifyRecord } from "./hmac/index.js";
export {
type AuditFileInstruments,
type AuditFileInstrumentsConfig,
createAuditFileInstruments,
SINK_ATTRIBUTE,
} from "./instruments/index.js";
export { enforceRetention } from "./retention/index.js";
export {
type RotationPolicy,
type RotationState,
resolveRotationPolicy,
rotate,
shouldRotate,
} from "./rotation/index.js";
export {
type AuditRecord,
AuditRecordSchema,
buildSignedLine,
canonicalise,
} from "./schema/index.js";
export {
type AppendMode,
type CreateFileAuditSinkOptions,
createFileAuditSink,
type FileAuditSink,
} from "./sink/index.js";

View File

@@ -0,0 +1,6 @@
export {
type AuditFileInstruments,
type AuditFileInstrumentsConfig,
createAuditFileInstruments,
SINK_ATTRIBUTE,
} from "./instruments.js";

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,75 @@
import {
type Counter,
type Meter,
type MeterProvider,
metrics,
} from "@opentelemetry/api";
const METER_NAME = "@suaveplan/api-audit-file";
/**
* Bundle of OTel counters the sink emits. Each carries a stable
* `sink="file"` attribute so dashboards can union the family of audit
* sinks (`stdout`, `file`, future siblings) on the metric name and
* partition by the attribute.
*
* - `recordsTotal` — every successful write increments by 1.
* - `droppedTotal` — every queue-overflow or boot-time rejection
* increments by 1. Operators MUST alert on a non-zero rate.
* - `rotationEvictedTotal` — every retention-eviction increments by 1.
*/
export interface AuditFileInstruments {
readonly meter: Meter;
readonly recordsTotal: Counter;
readonly droppedTotal: Counter;
readonly rotationEvictedTotal: Counter;
}
/** Construction options for {@link createAuditFileInstruments}. */
export interface AuditFileInstrumentsConfig {
readonly meterProvider?: MeterProvider;
}
/**
* Build the counter bundle against the supplied (or global) meter
* provider. The default reads from `@opentelemetry/api`'s global
* provider, which is the no-op tracer until the operator installs an
* SDK. Tests supply a real `MeterProvider` so the per-instrument
* deltas are observable.
*/
export function createAuditFileInstruments(
config: AuditFileInstrumentsConfig = {}
): AuditFileInstruments {
const provider = config.meterProvider ?? metrics.getMeterProvider();
const meter = provider.getMeter(METER_NAME);
const recordsTotal = meter.createCounter("api.audit.records_total", {
description:
"Total audit records successfully written to the file sink",
});
const droppedTotal = meter.createCounter("api.audit.dropped_total", {
description:
"Total audit records dropped before reaching disk (queue overflow or validation rejection)",
});
const rotationEvictedTotal = meter.createCounter(
"api.audit.rotation_evicted_total",
{
description:
"Total rotated audit files deleted by retention policy (maxFiles enforcement)",
}
);
return Object.freeze({
meter,
recordsTotal,
droppedTotal,
rotationEvictedTotal,
});
}
/**
* Stable `{ sink: "file" }` attribute set every instrument observation
* carries. Exported so verification harnesses and tests can compare
* the canonical literal instead of duplicating the string.
*/
export const SINK_ATTRIBUTE: Readonly<{ sink: "file" }> = Object.freeze({
sink: "file" as const,
});

View File

@@ -0,0 +1 @@
export { enforceRetention } from "./retention.js";

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,143 @@
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "../error-codes/error-codes.js";
import { nodeFsAdapter } from "../fs-helpers/fs-helpers.js";
import {
createAuditFileInstruments,
SINK_ATTRIBUTE,
} from "../instruments/instruments.js";
import { enforceRetention } from "./retention.js";
interface Harness {
dir: string;
basePath: string;
reader: PeriodicExportingMetricReader;
instruments: ReturnType<typeof createAuditFileInstruments>;
}
async function setup(): Promise<Harness> {
const dir = await mkdtemp(join(tmpdir(), "audit-retention-"));
const basePath = join(dir, "audit.log");
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({ exporter });
const provider = new MeterProvider({ readers: [reader] });
const instruments = createAuditFileInstruments({ meterProvider: provider });
return { dir, basePath, reader, instruments };
}
async function makeRotated(basePath: string, isoTimestamps: string[]) {
for (const ts of isoTimestamps) {
await writeFile(`${basePath}.${ts}.log`, "stub", "utf8");
}
}
async function collectMetrics(reader: PeriodicExportingMetricReader) {
const { resourceMetrics } = await reader.collect();
return resourceMetrics.scopeMetrics.flatMap((sm) => sm.metrics);
}
describe("api-audit-file retention", () => {
let h: Harness;
beforeEach(async () => {
h = await setup();
});
afterEach(async () => {
await h.reader.shutdown();
});
it("evicts oldest beyond maxFiles and increments counter", {
timeout: 30_000,
}, async () => {
const timestamps = [
"2026-05-26T10-00-00.000Z",
"2026-05-26T11-00-00.000Z",
"2026-05-26T12-00-00.000Z",
"2026-05-26T13-00-00.000Z",
];
await makeRotated(h.basePath, timestamps);
await enforceRetention(nodeFsAdapter, h.basePath, 3, h.instruments);
const survivors = (await readdir(h.dir)).filter((f) =>
f.endsWith(".log")
);
expect(survivors).toHaveLength(3);
expect(survivors).not.toContain(`audit.log.${timestamps[0]}.log`);
const metrics = await collectMetrics(h.reader);
const counter = metrics.find(
(m) => m.descriptor.name === "api.audit.rotation_evicted_total"
);
expect(counter).toBeDefined();
expect(counter?.dataPoints[0]?.value).toBe(1);
expect(counter?.dataPoints[0]?.attributes).toEqual(SINK_ATTRIBUTE);
});
it("is a no-op when below maxFiles", { timeout: 30_000 }, async () => {
const timestamps = [
"2026-05-26T10-00-00.000Z",
"2026-05-26T11-00-00.000Z",
];
await makeRotated(h.basePath, timestamps);
await enforceRetention(nodeFsAdapter, h.basePath, 5, h.instruments);
const survivors = (await readdir(h.dir)).filter((f) =>
f.endsWith(".log")
);
expect(survivors).toHaveLength(2);
});
it("surfaces RETENTION_FAILED on unlink failure", {
timeout: 30_000,
}, async () => {
const adapter = {
...nodeFsAdapter,
async unlink(_p: string) {
throw new Error("EACCES");
},
};
await makeRotated(h.basePath, [
"2026-05-26T10-00-00.000Z",
"2026-05-26T11-00-00.000Z",
]);
let caught: unknown;
try {
await enforceRetention(adapter, h.basePath, 1, h.instruments);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(AuditFileError);
expect((caught as AuditFileError).code).toBe(
AUDIT_FILE_CODES.RETENTION_FAILED
);
});
it("ignores non-rotated entries in the directory", {
timeout: 30_000,
}, async () => {
await writeFile(h.basePath, "active", "utf8");
await writeFile(join(h.dir, "README.md"), "doc", "utf8");
await makeRotated(h.basePath, [
"2026-05-26T10-00-00.000Z",
"2026-05-26T11-00-00.000Z",
]);
await enforceRetention(nodeFsAdapter, h.basePath, 1, h.instruments);
const entries = await readdir(h.dir);
expect(entries).toContain("audit.log");
expect(entries).toContain("README.md");
});
});

View File

@@ -0,0 +1,41 @@
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "../error-codes/error-codes.js";
import { type FsAdapter, listRotatedFiles } from "../fs-helpers/fs-helpers.js";
import type { AuditFileInstruments } from "../instruments/instruments.js";
import { SINK_ATTRIBUTE } from "../instruments/instruments.js";
/**
* Inspect the rotated-file siblings of `basePath` and delete the
* oldest ones until at most `maxFiles` remain. Increments
* `api.audit.rotation_evicted_total{sink="file"}` once per deletion.
*
* Failures during a delete surface as `AUDIT_FILE.RETENTION_FAILED`
* with the offending path in the cause context; the caller's logger
* records the failure and the next rotation re-attempts retention so
* one transient EBUSY does not permanently disable enforcement.
*/
export async function enforceRetention(
fs: FsAdapter,
basePath: string,
maxFiles: number,
instruments: AuditFileInstruments
): Promise<void> {
const rotated = await listRotatedFiles(fs, basePath);
if (rotated.length <= maxFiles) return;
const evictCount = rotated.length - maxFiles;
const victims = rotated.slice(0, evictCount);
for (const path of victims) {
try {
await fs.unlink(path);
instruments.rotationEvictedTotal.add(1, SINK_ATTRIBUTE);
} catch (cause) {
throw new AuditFileError(
AUDIT_FILE_CODES.RETENTION_FAILED,
"error.audit.file.retention_failed",
{ context: { path }, cause }
);
}
}
}

View File

@@ -0,0 +1,7 @@
export {
type RotationPolicy,
type RotationState,
resolveRotationPolicy,
rotate,
shouldRotate,
} from "./rotation.js";

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,170 @@
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "../error-codes/error-codes.js";
import { nodeFsAdapter } from "../fs-helpers/fs-helpers.js";
import {
type RotationState,
resolveRotationPolicy,
rotate,
shouldRotate,
} from "./rotation.js";
let dir: string;
let basePath: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "audit-rot-"));
basePath = join(dir, "audit.log");
});
afterEach(async () => {
// tmp dirs cleaned by os.tmpdir lifecycle
});
describe("api-audit-file rotation — policy resolver", () => {
it("applies the documented defaults", { timeout: 5000 }, () => {
const p = resolveRotationPolicy();
expect(p.maxSizeBytes).toBe(100 * 1024 * 1024);
expect(p.maxAgeMs).toBe(24 * 60 * 60 * 1000);
expect(p.maxFiles).toBe(30);
});
it("merges partial input over defaults", { timeout: 5000 }, () => {
const p = resolveRotationPolicy({ maxSizeBytes: 1024 });
expect(p.maxSizeBytes).toBe(1024);
expect(p.maxAgeMs).toBe(24 * 60 * 60 * 1000);
});
it("rejects maxSizeBytes < 1", { timeout: 5000 }, () => {
expect(() => resolveRotationPolicy({ maxSizeBytes: 0 })).toThrow(
AuditFileError
);
try {
resolveRotationPolicy({ maxSizeBytes: 0 });
} catch (err) {
expect((err as AuditFileError).code).toBe(
AUDIT_FILE_CODES.ROTATION_FAILED
);
}
});
it("rejects maxAgeMs < 1", { timeout: 5000 }, () => {
expect(() => resolveRotationPolicy({ maxAgeMs: 0 })).toThrow();
});
it("rejects maxFiles < 1", { timeout: 5000 }, () => {
expect(() => resolveRotationPolicy({ maxFiles: 0 })).toThrow();
});
it("rejects non-integer maxFiles", { timeout: 5000 }, () => {
expect(() => resolveRotationPolicy({ maxFiles: 2.5 })).toThrow();
});
});
describe("api-audit-file rotation — shouldRotate", () => {
const policy = resolveRotationPolicy({
maxSizeBytes: 1024,
maxAgeMs: 60_000,
});
it("triggers on size overflow", { timeout: 5000 }, () => {
const state: RotationState = {
openedAtMs: 0,
bytesWritten: 1024,
policy,
};
expect(shouldRotate(state, 0)).toBe(true);
});
it("triggers on age overflow", { timeout: 5000 }, () => {
const state: RotationState = {
openedAtMs: 0,
bytesWritten: 0,
policy,
};
expect(shouldRotate(state, 60_000)).toBe(true);
});
it("does not trigger when under both limits", { timeout: 5000 }, () => {
const state: RotationState = {
openedAtMs: 0,
bytesWritten: 100,
policy,
};
expect(shouldRotate(state, 10_000)).toBe(false);
});
});
describe("api-audit-file rotation — rotate()", () => {
it("size rotation produces timestamp-suffixed file", {
timeout: 30_000,
}, async () => {
await writeFile(basePath, "x".repeat(2048), "utf8");
const policy = resolveRotationPolicy({ maxSizeBytes: 1024 });
const state: RotationState = {
openedAtMs: 0,
bytesWritten: 2048,
policy,
};
const rotatedPath = await rotate(nodeFsAdapter, basePath, state, () =>
Date.parse("2026-05-26T12:00:00.000Z")
);
expect(rotatedPath).toContain("audit.log.2026-05-26T12-00-00.000Z.log");
const entries = await readdir(dir);
expect(entries).toContain("audit.log.2026-05-26T12-00-00.000Z.log");
expect(state.bytesWritten).toBe(0);
});
it("time rotation triggers on clock advance", {
timeout: 30_000,
}, async () => {
await writeFile(basePath, "data", "utf8");
const policy = resolveRotationPolicy({ maxAgeMs: 60_000 });
const state: RotationState = {
openedAtMs: 0,
bytesWritten: 4,
policy,
};
expect(shouldRotate(state, 61_000)).toBe(true);
await rotate(nodeFsAdapter, basePath, state, () => 61_000);
expect(state.openedAtMs).toBe(61_000);
});
it("surfaces ROTATION_FAILED on rename failure", {
timeout: 30_000,
}, async () => {
const policy = resolveRotationPolicy();
const state: RotationState = {
openedAtMs: 0,
bytesWritten: 0,
policy,
};
const failingAdapter = {
...nodeFsAdapter,
async rename() {
throw new Error("EBUSY");
},
};
let caught: unknown;
try {
await rotate(failingAdapter, basePath, state, () => 0);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(AuditFileError);
expect((caught as AuditFileError).code).toBe(
AUDIT_FILE_CODES.ROTATION_FAILED
);
});
});

View File

@@ -0,0 +1,106 @@
import type { Clock } from "@suaveplan/types/clock";
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "../error-codes/error-codes.js";
import { type FsAdapter, rotatedFilePathAt } from "../fs-helpers/fs-helpers.js";
/**
* Rotation policy resolved + validated at sink construction. Carries
* the defaults documented in the design doc: 100 MiB size cap, 24-hour
* age cap. The retention `maxFiles` lives here for ergonomics — the
* retention module reads it through the same {@link RotationState}.
*/
export interface RotationPolicy {
readonly maxSizeBytes: number;
readonly maxAgeMs: number;
readonly maxFiles: number;
}
/** Mutable rotation state carried per sink. */
export interface RotationState {
/** Wall-clock ms when the current segment was opened. */
openedAtMs: number;
/** Last observed byte size of the current segment. */
bytesWritten: number;
/** Resolved policy, frozen. */
readonly policy: RotationPolicy;
}
const DEFAULT_MAX_SIZE_BYTES = 100 * 1024 * 1024;
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const DEFAULT_MAX_FILES = 30;
/**
* Resolve a partial policy against defaults, raising on invalid input.
* `maxSizeBytes >= 1`, `maxAgeMs >= 1`, `maxFiles >= 1`. Callers may
* disable a dimension by setting it to `Number.POSITIVE_INFINITY`.
*/
export function resolveRotationPolicy(
input?: Partial<RotationPolicy>
): RotationPolicy {
const maxSizeBytes = input?.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
const maxAgeMs = input?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
const maxFiles = input?.maxFiles ?? DEFAULT_MAX_FILES;
if (!(maxSizeBytes >= 1)) {
throw new AuditFileError(
AUDIT_FILE_CODES.ROTATION_FAILED,
"error.audit.file.rotation.invalid_policy",
{ context: { field: "maxSizeBytes", value: maxSizeBytes } }
);
}
if (!(maxAgeMs >= 1)) {
throw new AuditFileError(
AUDIT_FILE_CODES.ROTATION_FAILED,
"error.audit.file.rotation.invalid_policy",
{ context: { field: "maxAgeMs", value: maxAgeMs } }
);
}
if (!Number.isInteger(maxFiles) || maxFiles < 1) {
throw new AuditFileError(
AUDIT_FILE_CODES.ROTATION_FAILED,
"error.audit.file.rotation.invalid_policy",
{ context: { field: "maxFiles", value: maxFiles } }
);
}
return Object.freeze({ maxSizeBytes, maxAgeMs, maxFiles });
}
/**
* Decide whether the current segment needs to rotate BEFORE the next
* line is appended. Returns `true` when size OR age has been reached.
*/
export function shouldRotate(state: RotationState, nowMs: number): boolean {
if (state.bytesWritten >= state.policy.maxSizeBytes) return true;
if (nowMs - state.openedAtMs >= state.policy.maxAgeMs) return true;
return false;
}
/**
* Rotate the current segment by renaming it to a timestamp-suffixed
* sibling. Returns the rotated path so the caller can hand it to the
* retention module. On rename failure, raises
* `AUDIT_FILE.ROTATION_FAILED`; the caller's outer loop catches and
* surfaces via the configured logger so the next write retries cleanly.
*/
export async function rotate(
fs: FsAdapter,
basePath: string,
state: RotationState,
clock: Clock
): Promise<string> {
const rotatedAt = new Date(clock()).toISOString();
const rotatedPath = rotatedFilePathAt(basePath, rotatedAt);
try {
await fs.rename(basePath, rotatedPath);
} catch (cause) {
throw new AuditFileError(
AUDIT_FILE_CODES.ROTATION_FAILED,
"error.audit.file.rotation_failed",
{ context: { basePath, rotatedPath }, cause }
);
}
state.openedAtMs = clock();
state.bytesWritten = 0;
return rotatedPath;
}

View File

@@ -0,0 +1,6 @@
export {
type AuditRecord,
AuditRecordSchema,
buildSignedLine,
canonicalise,
} from "./schema.js";

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,70 @@
import z from "zod";
/**
* Zod schema for the `RequestAuditEnvelope` shape produced by
* `@suaveplan/api-host/request-pipeline`. We re-validate at the sink
* boundary because callers (host code, plugin code, tests) may hand us
* an object that was reshaped after the pipeline emitted it. Mirrors
* the structural type exported from `api-host`; kept in lock-step at
* compile-time via the `AuditRecord` re-export below.
*/
export const AuditRecordSchema = z
.object({
at: z.string().min(1),
requestId: z.string().min(1),
traceId: z.string().nullable(),
route: z.string().min(1),
plugin: z.string().min(1),
method: z.string().min(1),
principal: z
.object({
id: z.string().min(1),
scopes: z.array(z.string()).readonly(),
})
.nullable(),
tenantId: z.string().nullable(),
status: z.number().int().min(0),
outcome: z.union([z.literal("success"), z.literal("failure")]),
cause: z
.object({
code: z.string().min(1),
message: z.string(),
})
.optional(),
})
.strict();
/** Structural type re-exported for downstream typing. */
export type AuditRecord = z.infer<typeof AuditRecordSchema>;
/**
* Canonicalise a record into the byte stream that gets HMAC-signed and
* persisted. The canonical form is JSON with sorted top-level keys and
* no trailing whitespace; sorting the keys means the same logical
* record always serialises to the same bytes regardless of how the
* upstream pipeline constructed it (object spread, property order, ...).
*
* Returns the UTF-8 bytes ready for HMAC input. The caller appends an
* HMAC suffix when writing to disk; see {@link buildSignedLine}.
*/
export function canonicalise(record: AuditRecord): Uint8Array {
const ordered: Record<string, unknown> = {};
for (const key of (Object.keys(record) as (keyof AuditRecord)[]).sort()) {
ordered[key] = record[key];
}
return new TextEncoder().encode(JSON.stringify(ordered));
}
/**
* Build the on-disk line for a record + HMAC. The signed shape is a
* JSON object with two top-level keys, `record` (the canonical payload
* unwrapped) and `hmac` (lowercase hex of HMAC-SHA-256). One trailing
* `\n` so consumers can read the file as JSON-per-line.
*/
export function buildSignedLine(
canonicalBytes: Uint8Array,
hmacHex: string
): string {
const recordObject = JSON.parse(new TextDecoder().decode(canonicalBytes));
return `${JSON.stringify({ record: recordObject, hmac: hmacHex })}\n`;
}

View File

@@ -0,0 +1,6 @@
export { createFileAuditSink } from "./sink.js";
export type {
AppendMode,
CreateFileAuditSinkOptions,
FileAuditSink,
} from "./sink.types.js";

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.

View File

@@ -0,0 +1,199 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
AUDIT_FILE_CODES,
type AuditFileError,
} from "../error-codes/error-codes.js";
import { nodeFsAdapter } from "../fs-helpers/fs-helpers.js";
import {
createAuditFileInstruments,
SINK_ATTRIBUTE,
} from "../instruments/instruments.js";
import { createFileAuditSink } from "./sink.js";
const KEY = new TextEncoder().encode("hmac-conformance-key");
interface Harness {
dir: string;
reader: PeriodicExportingMetricReader;
instruments: ReturnType<typeof createAuditFileInstruments>;
}
async function buildHarness(): Promise<Harness> {
const dir = await mkdtemp(join(tmpdir(), "audit-conf-"));
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({ exporter });
const provider = new MeterProvider({ readers: [reader] });
const instruments = createAuditFileInstruments({ meterProvider: provider });
return { dir, reader, instruments };
}
async function counterValue(
reader: PeriodicExportingMetricReader,
name: string
): Promise<number> {
const { resourceMetrics } = await reader.collect();
for (const rm of resourceMetrics.scopeMetrics) {
for (const m of rm.metrics) {
if (m.descriptor.name !== name) continue;
const dp = m.dataPoints.find(
(d) =>
(d.attributes as Readonly<Record<string, unknown>>).sink ===
"file"
);
if (dp) return Number(dp.value);
}
}
return 0;
}
describe("api-audit-file sink — conformance", () => {
let h: Harness;
beforeEach(async () => {
h = await buildHarness();
});
afterEach(async () => {
await h.reader.shutdown();
});
it("drop increments dropped_total with sink label file", {
timeout: 30_000,
}, async () => {
const sink = await createFileAuditSink({
path: join(h.dir, "audit.log"),
hmacKey: KEY,
maxQueueSize: 1,
instruments: h.instruments,
fs: {
...nodeFsAdapter,
appendLine: () =>
new Promise((resolve) => setTimeout(resolve, 50)),
},
});
// Pre-fill queue WITHOUT waiting for drain by issuing concurrently.
const a = sink.emit({
at: new Date(0).toISOString(),
requestId: "r-1",
traceId: null,
route: "/x",
plugin: "p",
method: "GET",
principal: null,
tenantId: null,
status: 200,
outcome: "success",
});
let caught: unknown;
try {
await sink.emit({
at: new Date(1).toISOString(),
requestId: "r-2",
traceId: null,
route: "/x",
plugin: "p",
method: "GET",
principal: null,
tenantId: null,
status: 200,
outcome: "success",
});
} catch (err) {
caught = err;
}
await a;
await sink.dispose();
expect((caught as AuditFileError).code).toBe(
AUDIT_FILE_CODES.QUEUE_FULL
);
const dropped = await counterValue(h.reader, "api.audit.dropped_total");
expect(dropped).toBeGreaterThanOrEqual(1);
});
it("records_total increments per successful write", {
timeout: 30_000,
}, async () => {
const sink = await createFileAuditSink({
path: join(h.dir, "audit.log"),
hmacKey: KEY,
instruments: h.instruments,
appendMode: "append",
});
for (let i = 0; i < 3; i += 1) {
await sink.emit({
at: new Date(i).toISOString(),
requestId: `r-${i}`,
traceId: null,
route: "/x",
plugin: "p",
method: "GET",
principal: null,
tenantId: null,
status: 200,
outcome: "success",
});
}
await sink.dispose();
const records = await counterValue(h.reader, "api.audit.records_total");
expect(records).toBe(3);
});
it("concurrent emits share a single drain promise", {
timeout: 30_000,
}, async () => {
const sink = await createFileAuditSink({
path: join(h.dir, "audit.log"),
hmacKey: KEY,
instruments: h.instruments,
maxQueueSize: 10,
appendMode: "append",
fs: {
...nodeFsAdapter,
appendLine: (path, line) =>
nodeFsAdapter.appendLine(path, line, false),
},
});
const pending: Promise<void>[] = [];
for (let i = 0; i < 5; i += 1) {
pending.push(
sink.emit({
at: new Date(i).toISOString(),
requestId: `r-${i}`,
traceId: null,
route: "/x",
plugin: "p",
method: "GET",
principal: null,
tenantId: null,
status: 200,
outcome: "success",
})
);
}
await Promise.all(pending);
await sink.dispose();
const records = await counterValue(h.reader, "api.audit.records_total");
expect(records).toBe(5);
});
it("SINK_ATTRIBUTE is the frozen `{ sink: 'file' }` literal", {
timeout: 5000,
}, () => {
expect(SINK_ATTRIBUTE).toEqual({ sink: "file" });
expect(Object.isFrozen(SINK_ATTRIBUTE)).toBe(true);
});
});

View File

@@ -0,0 +1,319 @@
import { chmod, mkdtemp, readdir, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "../error-codes/error-codes.js";
import { verifyRecord } from "../hmac/hmac.js";
import {
type AuditRecord,
AuditRecordSchema,
canonicalise,
} from "../schema/schema.js";
import { createFileAuditSink } from "./sink.js";
const KEY = new TextEncoder().encode("super-secret-rotating-key");
async function freshDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "audit-sink-"));
}
function makeRecord(i: number): AuditRecord {
return {
at: new Date(i * 1000).toISOString(),
requestId: `r-${i}`,
traceId: null,
route: "/users",
plugin: "core",
method: "GET",
principal: { id: "u-1", scopes: ["read"] },
tenantId: "t-1",
status: 200,
outcome: "success",
};
}
describe("api-audit-file sink", () => {
it("writes 1000 records as 1000 verified JSON lines", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const path = join(dir, "audit.log");
const sink = await createFileAuditSink({
path,
hmacKey: KEY,
appendMode: "append",
});
for (let i = 0; i < 1000; i += 1) {
await sink.emit(makeRecord(i));
}
await sink.dispose();
const text = await readFile(path, "utf8");
const lines = text.trim().split("\n");
expect(lines).toHaveLength(1000);
for (const raw of lines) {
const { record, hmac } = JSON.parse(raw) as {
record: unknown;
hmac: string;
};
const parsed = AuditRecordSchema.parse(record);
expect(verifyRecord(KEY, canonicalise(parsed), hmac)).toBe(true);
}
});
it("unwriteable path raises AUDIT_FILE_PATH_NOT_WRITEABLE", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
await chmod(dir, 0o400);
let caught: unknown;
try {
await createFileAuditSink({
path: join(dir, "sub", "audit.log"),
hmacKey: KEY,
});
} catch (err) {
caught = err;
} finally {
await chmod(dir, 0o700);
}
expect(caught).toBeInstanceOf(AuditFileError);
expect((caught as AuditFileError).code).toBe(
AUDIT_FILE_CODES.PATH_NOT_WRITEABLE
);
});
it("rejects an empty hmacKey at construction", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
let caught: unknown;
try {
await createFileAuditSink({
path: join(dir, "audit.log"),
hmacKey: new Uint8Array(0),
});
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(AuditFileError);
});
it("rejects an invalid record with INVALID_RECORD", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const sink = await createFileAuditSink({
path: join(dir, "audit.log"),
hmacKey: KEY,
});
const bad = { not: "an envelope" } as unknown as AuditRecord;
let caught: unknown;
try {
await sink.emit(bad);
} catch (err) {
caught = err;
}
expect((caught as AuditFileError).code).toBe(
AUDIT_FILE_CODES.INVALID_RECORD
);
await sink.dispose();
});
it("strict-sync mode persists data on dispose", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const path = join(dir, "audit.log");
const sink = await createFileAuditSink({
path,
hmacKey: KEY,
appendMode: "strict-sync",
});
await sink.emit(makeRecord(1));
await sink.dispose();
const text = await readFile(path, "utf8");
expect(text.split("\n").filter(Boolean)).toHaveLength(1);
});
it("rotation produces a sibling and continues writing", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const path = join(dir, "audit.log");
const sink = await createFileAuditSink({
path,
hmacKey: KEY,
rotation: { maxSizeBytes: 200, maxFiles: 5 },
appendMode: "append",
});
for (let i = 0; i < 10; i += 1) {
await sink.emit(makeRecord(i));
}
await sink.dispose();
const entries = (await readdir(dir)).filter((f) => f.endsWith(".log"));
expect(entries.some((f) => f !== "audit.log")).toBe(true);
const current = await readFile(path, "utf8");
expect(current.length).toBeGreaterThan(0);
});
it("queueDepth getter reports zero after drain", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const sink = await createFileAuditSink({
path: join(dir, "audit.log"),
hmacKey: KEY,
});
await sink.emit(makeRecord(1));
expect(sink.queueDepth).toBe(0);
await sink.dispose();
});
it("dispose is idempotent and drops emits afterward", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const sink = await createFileAuditSink({
path: join(dir, "audit.log"),
hmacKey: KEY,
});
await sink.dispose();
await sink.dispose();
// post-dispose emit should be a silent drop (no throw)
await sink.emit(makeRecord(1));
});
it("writer failure surfaces WRITER_FAILED and increments dropped", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
let attempt = 0;
const failingFs = {
...(await import("../fs-helpers/fs-helpers.js")).nodeFsAdapter,
async appendLine() {
attempt += 1;
throw new Error("EIO");
},
};
const sink = await createFileAuditSink({
path: join(dir, "audit.log"),
hmacKey: KEY,
fs: failingFs,
});
let caught: unknown;
try {
await sink.emit(makeRecord(1));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(AuditFileError);
expect((caught as AuditFileError).code).toBe(
AUDIT_FILE_CODES.WRITER_FAILED
);
expect(attempt).toBe(1);
await sink.dispose();
});
it("propagates AuditFileError class directly from writer", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const nfa = (await import("../fs-helpers/fs-helpers.js")).nodeFsAdapter;
const failingFs = {
...nfa,
async appendLine() {
throw new AuditFileError(
AUDIT_FILE_CODES.WRITER_FAILED,
"error.audit.file.writer_failed"
);
},
};
const sink = await createFileAuditSink({
path: join(dir, "audit.log"),
hmacKey: KEY,
fs: failingFs,
});
let caught: unknown;
try {
await sink.emit(makeRecord(1));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(AuditFileError);
await sink.dispose();
});
it("rotation failure surfaces ROTATION_FAILED through emit", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const nfa = (await import("../fs-helpers/fs-helpers.js")).nodeFsAdapter;
const failingFs = {
...nfa,
async rename() {
throw new Error("EBUSY");
},
};
const sink = await createFileAuditSink({
path: join(dir, "audit.log"),
hmacKey: KEY,
rotation: { maxAgeMs: 1 },
clock: (() => {
let t = 0;
return () => {
t += 1000;
return t;
};
})(),
fs: failingFs,
});
let caught: unknown;
try {
await sink.emit(makeRecord(1));
} catch (err) {
caught = err;
}
expect((caught as AuditFileError).code).toBe(
AUDIT_FILE_CODES.ROTATION_FAILED
);
await sink.dispose();
});
it("retention failure is logged but not surfaced from emit", {
timeout: 30_000,
}, async () => {
const dir = await freshDir();
const errors: string[] = [];
const logger = {
debug() {},
info() {},
warn() {},
error(msg: string) {
errors.push(msg);
},
};
const nfa = (await import("../fs-helpers/fs-helpers.js")).nodeFsAdapter;
const failingFs = {
...nfa,
async unlink() {
throw new Error("EACCES");
},
};
const sink = await createFileAuditSink({
path: join(dir, "audit.log"),
hmacKey: KEY,
rotation: { maxSizeBytes: 10, maxFiles: 1 },
logger,
fs: failingFs,
appendMode: "append",
});
for (let i = 0; i < 8; i += 1) {
await sink.emit(makeRecord(i));
}
await sink.dispose();
expect(errors.some((m) => m.includes("retention"))).toBe(true);
});
});

View File

@@ -0,0 +1,226 @@
import { dirname } from "node:path";
import type { Clock } from "@suaveplan/types/clock";
import type { LoggerLike } from "@suaveplan/types/logger-like";
import {
AUDIT_FILE_CODES,
AuditFileError,
} from "../error-codes/error-codes.js";
import { type FsAdapter, nodeFsAdapter } from "../fs-helpers/fs-helpers.js";
import { signRecord } from "../hmac/hmac.js";
import {
type AuditFileInstruments,
createAuditFileInstruments,
SINK_ATTRIBUTE,
} from "../instruments/instruments.js";
import { enforceRetention } from "../retention/retention.js";
import {
type RotationState,
resolveRotationPolicy,
rotate,
shouldRotate,
} from "../rotation/rotation.js";
import {
type AuditRecord,
AuditRecordSchema,
buildSignedLine,
canonicalise,
} from "../schema/schema.js";
import type {
AppendMode,
CreateFileAuditSinkOptions,
FileAuditSink,
} from "./sink.types.js";
const NOOP_LOG = (): void => undefined;
const NOOP_LOGGER: LoggerLike = {
debug: NOOP_LOG,
info: NOOP_LOG,
warn: NOOP_LOG,
error: NOOP_LOG,
};
const DEFAULT_CLOCK: Clock = () => Date.now();
const DEFAULT_QUEUE_SIZE = 1024;
/**
* Build a file-rotating, HMAC-signed audit sink that implements the
* `RequestAuditSink` contract from `@suaveplan/api-host/request-pipeline`.
* The sink fails fast on a non-writeable target directory and writes
* JSON-per-line with HMAC-SHA-256 over canonical bytes. Size + time
* rotation rolls the active file to a timestamp-suffixed sibling;
* retention prunes the oldest siblings to keep at most `maxFiles`.
*/
export async function createFileAuditSink(
options: CreateFileAuditSinkOptions
): Promise<FileAuditSink> {
if (options.hmacKey.byteLength === 0) {
throw new AuditFileError(
AUDIT_FILE_CODES.INVALID_RECORD,
"error.audit.file.invalid_options",
{ context: { field: "hmacKey", reason: "empty" } }
);
}
const fs = options.fs ?? nodeFsAdapter;
await fs.ensureWriteableDir(dirname(options.path));
const clock = options.clock ?? DEFAULT_CLOCK;
const policy = resolveRotationPolicy(options.rotation);
const appendMode: AppendMode = options.appendMode ?? "strict-sync";
const maxQueueSize = options.maxQueueSize ?? DEFAULT_QUEUE_SIZE;
const logger = options.logger ?? NOOP_LOGGER;
const instruments = options.instruments ?? createAuditFileInstruments();
const state: RotationState = {
openedAtMs: clock(),
bytesWritten: await fs.fileSize(options.path),
policy,
};
return new SinkImpl({
path: options.path,
hmacKey: options.hmacKey,
clock,
appendMode,
maxQueueSize,
fs,
logger,
instruments,
state,
});
}
interface SinkContext {
readonly path: string;
readonly hmacKey: Uint8Array;
readonly clock: Clock;
readonly appendMode: AppendMode;
readonly maxQueueSize: number;
readonly fs: FsAdapter;
readonly logger: LoggerLike;
readonly instruments: AuditFileInstruments;
readonly state: RotationState;
}
class SinkImpl implements FileAuditSink {
private readonly ctx: SinkContext;
private queue: AuditRecord[] = [];
private disposed = false;
private drainPromise: Promise<void> | null = null;
public constructor(ctx: SinkContext) {
this.ctx = ctx;
}
public get queueDepth(): number {
return this.queue.length;
}
public async emit(record: AuditRecord): Promise<void> {
if (this.disposed) {
this.dropAndCount();
return;
}
let validated: AuditRecord;
try {
validated = AuditRecordSchema.parse(record);
} catch (cause) {
this.ctx.instruments.droppedTotal.add(1, SINK_ATTRIBUTE);
throw new AuditFileError(
AUDIT_FILE_CODES.INVALID_RECORD,
"error.audit.file.invalid_record",
{ cause }
);
}
if (this.queue.length >= this.ctx.maxQueueSize) {
this.dropAndCount();
throw new AuditFileError(
AUDIT_FILE_CODES.QUEUE_FULL,
"error.audit.file.queue_full",
{ context: { depth: this.queue.length } }
);
}
this.queue.push(validated);
await this.drain();
}
public async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
await this.drain();
}
private dropAndCount(): void {
this.ctx.instruments.droppedTotal.add(1, SINK_ATTRIBUTE);
}
private async drain(): Promise<void> {
const existing = this.drainPromise;
if (existing !== null) return existing;
const next = this.drainLoop().finally(() => {
this.drainPromise = null;
});
this.drainPromise = next;
return next;
}
private async drainLoop(): Promise<void> {
while (this.queue.length > 0) {
const next = this.queue[0] as AuditRecord;
try {
await this.writeOne(next);
this.queue.shift();
} catch (err) {
this.ctx.logger.error("audit.file.write_failed", {
error: (err as Error).message,
});
this.queue.shift();
this.dropAndCount();
if (err instanceof AuditFileError) {
throw err;
}
throw new AuditFileError(
AUDIT_FILE_CODES.WRITER_FAILED,
"error.audit.file.writer_failed",
{ cause: err }
);
}
}
}
private async writeOne(record: AuditRecord): Promise<void> {
await this.maybeRotate();
const bytes = canonicalise(record);
const hmacHex = signRecord(this.ctx.hmacKey, bytes);
const line = buildSignedLine(bytes, hmacHex);
const sync = this.ctx.appendMode === "strict-sync";
await this.ctx.fs.appendLine(this.ctx.path, line, sync);
this.ctx.state.bytesWritten += Buffer.byteLength(line, "utf8");
this.ctx.instruments.recordsTotal.add(1, SINK_ATTRIBUTE);
}
private async maybeRotate(): Promise<void> {
if (!shouldRotate(this.ctx.state, this.ctx.clock())) return;
try {
await rotate(
this.ctx.fs,
this.ctx.path,
this.ctx.state,
this.ctx.clock
);
} catch (err) {
this.ctx.logger.error("audit.file.rotation_failed", {
error: (err as Error).message,
});
throw err;
}
try {
await enforceRetention(
this.ctx.fs,
this.ctx.path,
this.ctx.state.policy.maxFiles,
this.ctx.instruments
);
} catch (err) {
this.ctx.logger.error("audit.file.retention_failed", {
error: (err as Error).message,
});
}
}
}

View File

@@ -0,0 +1,68 @@
import type { Clock } from "@suaveplan/types/clock";
import type { LoggerLike } from "@suaveplan/types/logger-like";
import type { FsAdapter } from "../fs-helpers/fs-helpers.js";
import type { AuditFileInstruments } from "../instruments/instruments.js";
import type { RotationPolicy } from "../rotation/rotation.js";
import type { AuditRecord } from "../schema/schema.js";
/**
* Append-mode policy. `strict-sync` calls `fsync` after every line so a
* power-loss cannot truncate the audit trail; `append` skips the sync
* for ~10× throughput. The default is `strict-sync` because audit is
* the canonical correctness-over-throughput surface.
*/
export type AppendMode = "strict-sync" | "append";
/**
* Construction options for {@link createFileAuditSink}.
*/
export interface CreateFileAuditSinkOptions {
/** Absolute path to the active audit log. Parent dir is `mkdir -p`'d on boot. */
readonly path: string;
/** HMAC-SHA-256 signing key. Must be non-empty. */
readonly hmacKey: Uint8Array;
/** Wall-clock source. Defaults to `() => Date.now()`. */
readonly clock?: Clock;
/** Rotation policy overrides. Defaults applied for any missing field. */
readonly rotation?: Partial<RotationPolicy>;
/** Write mode. Defaults to `"strict-sync"`. */
readonly appendMode?: AppendMode;
/**
* Bounded queue size. Records arriving while the writer is stalled
* and the queue is at capacity are dropped with a `dropped_total`
* increment. Default 1024.
*/
readonly maxQueueSize?: number;
/**
* Injectable filesystem adapter. Defaults to the production
* `nodeFsAdapter`; tests may pass an in-memory shim that
* implements the same surface.
*/
readonly fs?: FsAdapter;
/**
* Optional logger. The sink logs rotation + retention failures so
* an operator can correlate without parsing stack traces. Defaults
* to a no-op logger.
*/
readonly logger?: LoggerLike;
/**
* Optional pre-built instruments bundle. The default creates a new
* bundle against the global `MeterProvider`. Tests inject a bundle
* bound to an `InMemoryMetricExporter` so observations are
* assertable.
*/
readonly instruments?: AuditFileInstruments;
}
/**
* The public sink surface. Implements `RequestAuditSink` from
* `@suaveplan/api-host/request-pipeline` structurally — `emit()` accepts
* the same `RequestAuditEnvelope` shape. `dispose()` flushes the queue
* and is idempotent: a second call resolves immediately.
*/
export interface FileAuditSink {
emit(record: AuditRecord): Promise<void>;
dispose(): Promise<void>;
/** Live snapshot of queue depth — for tests and operator probes. */
readonly queueDepth: number;
}

View File

@@ -0,0 +1,75 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = path.resolve(import.meta.dirname, "..");
describe("api-audit-file — dependency graph and exports map", () => {
it("exports map advertises every feature subpath", {
timeout: 5000,
}, async () => {
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
const pkg = JSON.parse(raw) as {
exports: Record<string, unknown>;
};
const subpaths = Object.keys(pkg.exports)
.filter(
(k) =>
(k === "." || k.startsWith("./")) && k !== "./package.json"
)
.sort();
expect(subpaths).toEqual(
[
".",
"./sink",
"./rotation",
"./retention",
"./schema",
"./hmac",
"./error-codes",
"./instruments",
"./fs-helpers",
].sort()
);
});
it("runtime deps use only workspace and catalog refs", {
timeout: 5000,
}, async () => {
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const all = {
...(pkg.dependencies ?? {}),
...(pkg.devDependencies ?? {}),
};
for (const [name, version] of Object.entries(all)) {
if (name === "@opentelemetry/sdk-metrics") continue;
if (
!version.startsWith("workspace:") &&
!version.startsWith("catalog:")
) {
throw new Error(
`Dependency ${name} uses bare version '${version}' — must be workspace:/catalog:`
);
}
}
expect(true).toBe(true);
});
it("runtime deps include the expected packages", {
timeout: 5000,
}, async () => {
const raw = await readFile(path.join(PKG_ROOT, "package.json"), "utf8");
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
};
const deps = Object.keys(pkg.dependencies ?? {}).sort();
expect(deps).toContain("@suaveplan/crypto-hash");
expect(deps).toContain("@suaveplan/error");
expect(deps).toContain("@suaveplan/types");
expect(deps).toContain("zod");
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-audit-file",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});