Initial Commit
This commit is contained in:
85
packages/api-stack/api/api-audit-file/README.md
Normal file
85
packages/api-stack/api/api-audit-file/README.md
Normal 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`.
|
||||
|
||||
108
packages/api-stack/api/api-audit-file/package.json
Normal file
108
packages/api-stack/api/api-audit-file/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
AUDIT_FILE_CODES,
|
||||
AUDIT_FILE_ERROR_NAME,
|
||||
type AuditFileCode,
|
||||
AuditFileError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type FsAdapter,
|
||||
listRotatedFiles,
|
||||
nodeFsAdapter,
|
||||
rotatedFilePathAt,
|
||||
} from "./fs-helpers.js";
|
||||
76
packages/api-stack/api/api-audit-file/src/hmac/hmac.md
Normal file
76
packages/api-stack/api/api-audit-file/src/hmac/hmac.md
Normal 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.
|
||||
69
packages/api-stack/api/api-audit-file/src/hmac/hmac.test.ts
Normal file
69
packages/api-stack/api/api-audit-file/src/hmac/hmac.test.ts
Normal 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}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
45
packages/api-stack/api/api-audit-file/src/hmac/hmac.ts
Normal file
45
packages/api-stack/api/api-audit-file/src/hmac/hmac.ts
Normal 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;
|
||||
}
|
||||
1
packages/api-stack/api/api-audit-file/src/hmac/index.ts
Normal file
1
packages/api-stack/api/api-audit-file/src/hmac/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { signRecord, verifyRecord } from "./hmac.js";
|
||||
51
packages/api-stack/api/api-audit-file/src/index.ts
Normal file
51
packages/api-stack/api/api-audit-file/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type AuditFileInstruments,
|
||||
type AuditFileInstrumentsConfig,
|
||||
createAuditFileInstruments,
|
||||
SINK_ATTRIBUTE,
|
||||
} from "./instruments.js";
|
||||
@@ -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,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,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { enforceRetention } from "./retention.js";
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type RotationPolicy,
|
||||
type RotationState,
|
||||
resolveRotationPolicy,
|
||||
rotate,
|
||||
shouldRotate,
|
||||
} from "./rotation.js";
|
||||
@@ -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,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
|
||||
);
|
||||
});
|
||||
});
|
||||
106
packages/api-stack/api/api-audit-file/src/rotation/rotation.ts
Normal file
106
packages/api-stack/api/api-audit-file/src/rotation/rotation.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type AuditRecord,
|
||||
AuditRecordSchema,
|
||||
buildSignedLine,
|
||||
canonicalise,
|
||||
} from "./schema.js";
|
||||
79
packages/api-stack/api/api-audit-file/src/schema/schema.md
Normal file
79
packages/api-stack/api/api-audit-file/src/schema/schema.md
Normal 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.
|
||||
70
packages/api-stack/api/api-audit-file/src/schema/schema.ts
Normal file
70
packages/api-stack/api/api-audit-file/src/schema/schema.ts
Normal 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`;
|
||||
}
|
||||
6
packages/api-stack/api/api-audit-file/src/sink/index.ts
Normal file
6
packages/api-stack/api/api-audit-file/src/sink/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { createFileAuditSink } from "./sink.js";
|
||||
export type {
|
||||
AppendMode,
|
||||
CreateFileAuditSinkOptions,
|
||||
FileAuditSink,
|
||||
} from "./sink.types.js";
|
||||
128
packages/api-stack/api/api-audit-file/src/sink/sink.md
Normal file
128
packages/api-stack/api/api-audit-file/src/sink/sink.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
319
packages/api-stack/api/api-audit-file/src/sink/sink.test.ts
Normal file
319
packages/api-stack/api/api-audit-file/src/sink/sink.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
226
packages/api-stack/api/api-audit-file/src/sink/sink.ts
Normal file
226
packages/api-stack/api/api-audit-file/src/sink/sink.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
68
packages/api-stack/api/api-audit-file/src/sink/sink.types.ts
Normal file
68
packages/api-stack/api/api-audit-file/src/sink/sink.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-audit-file/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-audit-file/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
36
packages/api-stack/api/api-audit-file/vite.config.ts
Normal file
36
packages/api-stack/api/api-audit-file/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
29
packages/api-stack/api/api-audit-file/vitest.config.ts
Normal file
29
packages/api-stack/api/api-audit-file/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user