Initial Commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# `error-codes` — `AUDIT_STDOUT_*` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the four stable error codes the stdout audit sink
|
||||
exposes to consumers. Every failure path inside `createStdoutAuditSink`
|
||||
maps onto exactly one of them, so callers can branch on `error.code`
|
||||
without parsing messages or stack traces. The registry uses
|
||||
`@suaveplan/error`'s `defineCodes` so the union narrows under
|
||||
`instanceof AuditStdoutError`.
|
||||
|
||||
The codes capture the four observable failure modes of a write-only,
|
||||
fire-and-forget audit pipeline: a record that fails its own Zod schema
|
||||
(`INVALID_RECORD`), an option bag that fails the constructor schema
|
||||
(`INVALID_OPTIONS`), a transport error from the supplied writer
|
||||
(`WRITER_FAILED`), and the bounded-backpressure drop case
|
||||
(`QUEUE_FULL`). The codes are surfaced through the
|
||||
`AuditStdoutError` subclass, which carries a `code` property typed as
|
||||
the literal union — `instanceof` narrowing flows through.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable string codes via `defineCodes("AUDIT_STDOUT", [...])`.
|
||||
- `AuditStdoutError` subclass with public constructor (lint rule
|
||||
`check-error-subclass-constructors` enforced).
|
||||
- Code union exported as `AuditStdoutErrorCode` for callers who want
|
||||
to switch over every member.
|
||||
- No runtime side effects — the registry is a frozen object literal.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
} from "@suaveplan/api-audit-stdout/error-codes";
|
||||
|
||||
throw new AuditStdoutError(
|
||||
AUDIT_STDOUT_CODES.INVALID_RECORD,
|
||||
"error.audit_stdout.invalid_record"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
type AuditStdoutErrorCode,
|
||||
} from "@suaveplan/api-audit-stdout/error-codes";
|
||||
|
||||
function handle(err: unknown): "drop" | "rethrow" {
|
||||
if (!(err instanceof AuditStdoutError)) return "rethrow";
|
||||
const code: AuditStdoutErrorCode = err.code;
|
||||
switch (code) {
|
||||
case AUDIT_STDOUT_CODES.QUEUE_FULL:
|
||||
case AUDIT_STDOUT_CODES.WRITER_FAILED:
|
||||
return "drop";
|
||||
case AUDIT_STDOUT_CODES.INVALID_RECORD:
|
||||
case AUDIT_STDOUT_CODES.INVALID_OPTIONS:
|
||||
return "rethrow";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIT_STDOUT_CODES` — frozen `{ INVALID_RECORD, WRITER_FAILED,
|
||||
QUEUE_FULL, INVALID_OPTIONS }` mapping to the corresponding
|
||||
`"AUDIT_STDOUT.*"` literal.
|
||||
- `AuditStdoutErrorCode` — union of the four literal strings.
|
||||
- `AuditStdoutError` — `SuaveplanError<AuditStdoutErrorCode>` subclass
|
||||
with a public constructor accepting `(code, message, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a frozen object — adding a new code requires an
|
||||
OpenSpec change. The subclass declares its own public constructor so
|
||||
`new AuditStdoutError(...)` is callable from outside the module (the
|
||||
base class is abstract with a protected constructor). The
|
||||
`auditStdoutCode` field is a typed alias of `code` for callers who
|
||||
want a non-generic accessor.
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
type AuditStdoutErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("AUDIT_STDOUT_CODES", () => {
|
||||
it("exposes four stable string codes", { timeout: 5000 }, () => {
|
||||
expect(AUDIT_STDOUT_CODES.INVALID_RECORD).toBe(
|
||||
"AUDIT_STDOUT.INVALID_RECORD"
|
||||
);
|
||||
expect(AUDIT_STDOUT_CODES.WRITER_FAILED).toBe(
|
||||
"AUDIT_STDOUT.WRITER_FAILED"
|
||||
);
|
||||
expect(AUDIT_STDOUT_CODES.QUEUE_FULL).toBe("AUDIT_STDOUT.QUEUE_FULL");
|
||||
expect(AUDIT_STDOUT_CODES.INVALID_OPTIONS).toBe(
|
||||
"AUDIT_STDOUT.INVALID_OPTIONS"
|
||||
);
|
||||
});
|
||||
|
||||
it("registry object is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(AUDIT_STDOUT_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuditStdoutError", () => {
|
||||
it("constructor binds code into typed alias", { timeout: 5000 }, () => {
|
||||
const err = new AuditStdoutError(
|
||||
AUDIT_STDOUT_CODES.INVALID_RECORD,
|
||||
"error.audit_stdout.invalid_record"
|
||||
);
|
||||
const code: AuditStdoutErrorCode = err.code;
|
||||
expect(code).toBe(AUDIT_STDOUT_CODES.INVALID_RECORD);
|
||||
expect(err.auditStdoutCode).toBe(AUDIT_STDOUT_CODES.INVALID_RECORD);
|
||||
expect(err).toBeInstanceOf(AuditStdoutError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("forwards cause through SuaveplanError options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new AuditStdoutError(
|
||||
AUDIT_STDOUT_CODES.WRITER_FAILED,
|
||||
"error.audit_stdout.writer_failed",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-audit-stdout`.
|
||||
*
|
||||
* - `INVALID_RECORD` — the record passed to `sink.write(...)` failed
|
||||
* `AuditRecord` Zod validation at the boundary. `cause` carries the
|
||||
* underlying `z.ZodError`. Records that fail validation are NEVER
|
||||
* written.
|
||||
* - `WRITER_FAILED` — the supplied writer threw or rejected
|
||||
* asynchronously when the sink attempted to write a line. The
|
||||
* record is treated as a drop; callers see the original writer
|
||||
* error via `cause`.
|
||||
* - `QUEUE_FULL` — the queue exceeded `maxQueueMs` of pending work
|
||||
* for the current record. The record is dropped, the drop counter
|
||||
* increments, and (subject to the throttle) a warning is logged.
|
||||
* - `INVALID_OPTIONS` — `createStdoutAuditSink({...})` was called
|
||||
* with an option bag that failed Zod validation at the boundary.
|
||||
*
|
||||
* Consumers branch on the string value, e.g.
|
||||
* `error.code === AUDIT_STDOUT_CODES.INVALID_RECORD`.
|
||||
*/
|
||||
export const AUDIT_STDOUT_CODES = defineCodes("AUDIT_STDOUT", [
|
||||
"INVALID_RECORD",
|
||||
"WRITER_FAILED",
|
||||
"QUEUE_FULL",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type AuditStdoutErrorCode =
|
||||
(typeof AUDIT_STDOUT_CODES)[keyof typeof AUDIT_STDOUT_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the `api-audit-stdout` package. Extends
|
||||
* {@link SuaveplanError} so `instanceof AuditStdoutError` narrows
|
||||
* `code` to {@link AuditStdoutErrorCode}.
|
||||
*/
|
||||
export class AuditStdoutError extends SuaveplanError<AuditStdoutErrorCode> {
|
||||
public readonly auditStdoutCode: AuditStdoutErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: AuditStdoutErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.auditStdoutCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
type AuditStdoutErrorCode,
|
||||
} from "./error-codes.js";
|
||||
82
packages/api-stack/api/api-audit-stdout/src/hmac/hmac.md
Normal file
82
packages/api-stack/api/api-audit-stdout/src/hmac/hmac.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# `hmac` — canonical bytes + HMAC-SHA-256 signing
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module produces the HMAC-SHA-256 tag attached to every audit
|
||||
line. Signing operates on a *canonical* byte representation of the
|
||||
record (keys serialised in a fixed order, `cause` omitted when
|
||||
absent) so the same logical record always produces the same MAC under
|
||||
the same key, regardless of how the source object was constructed.
|
||||
Verification recomputes the MAC and constant-time-compares to the
|
||||
hex-decoded signature.
|
||||
|
||||
The point isn't to encrypt the record — the line is plaintext JSON
|
||||
written to stdout. The HMAC catches tampering downstream: a log
|
||||
collector or storage tier that re-writes a field to mask an audited
|
||||
action invalidates the MAC, and the auditor's verifier flags the
|
||||
discrepancy. The key never leaves the host; rotation is the caller's
|
||||
concern.
|
||||
|
||||
## Features
|
||||
|
||||
- `canonicalBytes(record)` — UTF-8 bytes of JSON with top-level keys
|
||||
in design-doc §12.5 order and `cause` omitted when absent.
|
||||
- `signRecord(record, key)` — lowercase hex HMAC-SHA-256, computed
|
||||
via `@suaveplan/crypto-hash`'s `hmac("SHA-256", …)` primitive.
|
||||
- `verifyRecord(record, key, signatureHex)` — constant-time match;
|
||||
rejects bad-length, non-hex, and tampered inputs.
|
||||
- `HMAC_FIELD` — the `"_hmac"` field name the sink appends to the
|
||||
JSON line.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { signRecord, verifyRecord } from "@suaveplan/api-audit-stdout/hmac";
|
||||
|
||||
const key = new Uint8Array(32);
|
||||
const tag = signRecord(record, key);
|
||||
const ok = verifyRecord(record, key, tag);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
canonicalBytes,
|
||||
HMAC_FIELD,
|
||||
signRecord,
|
||||
} from "@suaveplan/api-audit-stdout/hmac";
|
||||
|
||||
function emit(record: AuditRecord, key: Uint8Array): string {
|
||||
// Equivalent to what the sink does internally, but caller-managed.
|
||||
const line = JSON.parse(
|
||||
new TextDecoder().decode(canonicalBytes(record))
|
||||
) as Record<string, unknown>;
|
||||
line[HMAC_FIELD] = signRecord(record, key);
|
||||
return JSON.stringify(line);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `canonicalBytes(record: AuditRecord): Uint8Array`
|
||||
- `signRecord(record: AuditRecord, key: Uint8Array): string` — hex.
|
||||
- `verifyRecord(record: AuditRecord, key: Uint8Array, sig: string): boolean`
|
||||
- `HMAC_FIELD: "_hmac"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical encoder lists top-level keys explicitly rather than
|
||||
sorting alphabetically — the design doc fixes the order so callers
|
||||
reading raw lines see fields in the documented sequence. Nested
|
||||
objects use natural insertion order; their shape is locked by the
|
||||
Zod schema so the JSON.stringify output is deterministic. `cause` is
|
||||
omitted (not serialised as `null`) when absent so a `success` record
|
||||
without `cause` and a `success` record with `cause: undefined`
|
||||
produce the same MAC. Hex was chosen over base64 because hex is
|
||||
case-insensitive trivially and stays ASCII-safe through every common
|
||||
log pipeline.
|
||||
103
packages/api-stack/api/api-audit-stdout/src/hmac/hmac.test.ts
Normal file
103
packages/api-stack/api/api-audit-stdout/src/hmac/hmac.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { AuditRecord } from "../schema/schema.js";
|
||||
import { canonicalBytes, signRecord, verifyRecord } from "./hmac.js";
|
||||
|
||||
const KEY = new TextEncoder().encode("test-key-32-bytes-padding-padding!");
|
||||
|
||||
const RECORD: AuditRecord = {
|
||||
ts: "2026-05-26T10:00:00.000Z",
|
||||
type: "admin.plugin.disabled",
|
||||
principal: { id: "user_1", scopes: ["admin"] },
|
||||
tenant: "tenant_a",
|
||||
request_id: "req_1",
|
||||
trace_id: null,
|
||||
subject: "plugin.exports",
|
||||
outcome: "success",
|
||||
};
|
||||
|
||||
const FAILURE: AuditRecord = {
|
||||
...RECORD,
|
||||
outcome: "failure",
|
||||
cause: { code: "PLUGIN.NOT_FOUND", message: "no such plugin" },
|
||||
};
|
||||
|
||||
describe("canonicalBytes", () => {
|
||||
it("is deterministic across calls", { timeout: 5000 }, () => {
|
||||
const a = canonicalBytes(RECORD);
|
||||
const b = canonicalBytes(RECORD);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("omits cause when absent", { timeout: 5000 }, () => {
|
||||
const json = new TextDecoder().decode(canonicalBytes(RECORD));
|
||||
expect(json.includes('"cause"')).toBe(false);
|
||||
});
|
||||
|
||||
it("includes cause when present", { timeout: 5000 }, () => {
|
||||
const json = new TextDecoder().decode(canonicalBytes(FAILURE));
|
||||
expect(json.includes('"cause"')).toBe(true);
|
||||
});
|
||||
|
||||
it("emits keys in design-doc §12.5 order", { timeout: 5000 }, () => {
|
||||
const json = new TextDecoder().decode(canonicalBytes(RECORD));
|
||||
// Top-level keys appear in the fixed order
|
||||
const order = [
|
||||
'"ts"',
|
||||
'"type"',
|
||||
'"principal"',
|
||||
'"tenant"',
|
||||
'"request_id"',
|
||||
'"trace_id"',
|
||||
'"subject"',
|
||||
'"outcome"',
|
||||
];
|
||||
let last = -1;
|
||||
for (const k of order) {
|
||||
const idx = json.indexOf(k);
|
||||
expect(idx).toBeGreaterThan(last);
|
||||
last = idx;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("signRecord / verifyRecord", () => {
|
||||
it("HMAC verifies under the same key", { timeout: 5000 }, () => {
|
||||
const tag = signRecord(RECORD, KEY);
|
||||
expect(verifyRecord(RECORD, KEY, tag)).toBe(true);
|
||||
});
|
||||
|
||||
it("HMAC is hex-encoded and length-64 for SHA-256", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tag = signRecord(RECORD, KEY);
|
||||
expect(tag.length).toBe(64);
|
||||
expect(/^[0-9a-f]+$/.test(tag)).toBe(true);
|
||||
});
|
||||
|
||||
it("tampering invalidates HMAC", { timeout: 5000 }, () => {
|
||||
const tag = signRecord(RECORD, KEY);
|
||||
const tampered: AuditRecord = {
|
||||
...RECORD,
|
||||
outcome: "failure",
|
||||
cause: { code: "X", message: "y" },
|
||||
};
|
||||
expect(verifyRecord(tampered, KEY, tag)).toBe(false);
|
||||
});
|
||||
|
||||
it("wrong key fails verification", { timeout: 5000 }, () => {
|
||||
const tag = signRecord(RECORD, KEY);
|
||||
const other = new TextEncoder().encode(
|
||||
"different-key-padding-padding!!!"
|
||||
);
|
||||
expect(verifyRecord(RECORD, other, tag)).toBe(false);
|
||||
});
|
||||
|
||||
it("malformed hex returns false", { timeout: 5000 }, () => {
|
||||
expect(verifyRecord(RECORD, KEY, "zz")).toBe(false);
|
||||
expect(verifyRecord(RECORD, KEY, "abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("length mismatch returns false", { timeout: 5000 }, () => {
|
||||
expect(verifyRecord(RECORD, KEY, "ab")).toBe(false);
|
||||
});
|
||||
});
|
||||
93
packages/api-stack/api/api-audit-stdout/src/hmac/hmac.ts
Normal file
93
packages/api-stack/api/api-audit-stdout/src/hmac/hmac.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { hmac as cryptoHmac } from "@suaveplan/crypto-hash";
|
||||
import type { AuditRecord } from "../schema/schema.js";
|
||||
|
||||
/**
|
||||
* HMAC field name appended to the JSON line. Lower-case + underscore
|
||||
* prefix so log indexers see it as metadata rather than a regular
|
||||
* record field.
|
||||
*/
|
||||
export const HMAC_FIELD = "_hmac" as const;
|
||||
|
||||
/**
|
||||
* Encode `record` as deterministic canonical bytes for HMAC signing.
|
||||
*
|
||||
* The canonical form is JSON with top-level keys sorted in fixed
|
||||
* order matching design doc §12.5: `ts`, `type`, `principal`,
|
||||
* `tenant`, `request_id`, `trace_id`, `subject`, `outcome`, `cause`.
|
||||
* `cause` is omitted entirely when absent (rather than serialised as
|
||||
* `null`) so signatures stay stable across optional-vs-undefined
|
||||
* variations of the same logical record. Nested objects use their
|
||||
* natural insertion order — they're already locked by the Zod schema
|
||||
* so the order is deterministic at validation time.
|
||||
*/
|
||||
export function canonicalBytes(record: AuditRecord): Uint8Array {
|
||||
const ordered: Record<string, unknown> = {
|
||||
ts: record.ts,
|
||||
type: record.type,
|
||||
principal: record.principal,
|
||||
tenant: record.tenant,
|
||||
request_id: record.request_id,
|
||||
trace_id: record.trace_id,
|
||||
subject: record.subject,
|
||||
outcome: record.outcome,
|
||||
};
|
||||
if (record.cause !== undefined) {
|
||||
ordered["cause"] = record.cause;
|
||||
}
|
||||
return new TextEncoder().encode(JSON.stringify(ordered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the lowercase hex HMAC-SHA-256 of the canonical bytes of
|
||||
* `record` under `key`. The hex form keeps the JSON line ASCII-safe
|
||||
* and trivially comparable in log pipelines.
|
||||
*/
|
||||
export function signRecord(record: AuditRecord, key: Uint8Array): string {
|
||||
const mac = cryptoHmac("SHA-256", key, canonicalBytes(record));
|
||||
return toHex(mac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a previously-emitted HMAC hex string against `record` under
|
||||
* `key`. Returns `true` when the signature matches, `false`
|
||||
* otherwise. Comparison is constant-time over the byte-decoded form.
|
||||
*/
|
||||
export function verifyRecord(
|
||||
record: AuditRecord,
|
||||
key: Uint8Array,
|
||||
signatureHex: string
|
||||
): boolean {
|
||||
const expected = cryptoHmac("SHA-256", key, canonicalBytes(record));
|
||||
const actual = fromHex(signatureHex);
|
||||
if (actual === null) return false;
|
||||
if (actual.length !== expected.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
// Bounds-checked by the loop guard; the structural fallback
|
||||
// mirrors `noUncheckedIndexedAccess` typing without a runtime
|
||||
// branch.
|
||||
const e = expected[i] as number;
|
||||
const a = actual[i] as number;
|
||||
diff |= e ^ a;
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (const byte of bytes) {
|
||||
out += byte.toString(16).padStart(2, "0");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Uint8Array | null {
|
||||
if (hex.length % 2 !== 0) return null;
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const byte = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||
if (Number.isNaN(byte)) return null;
|
||||
out[i] = byte;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
canonicalBytes,
|
||||
HMAC_FIELD,
|
||||
signRecord,
|
||||
verifyRecord,
|
||||
} from "./hmac.js";
|
||||
42
packages/api-stack/api/api-audit-stdout/src/index.ts
Normal file
42
packages/api-stack/api/api-audit-stdout/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
type AuditStdoutErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
canonicalBytes,
|
||||
HMAC_FIELD,
|
||||
signRecord,
|
||||
verifyRecord,
|
||||
} from "./hmac/hmac.js";
|
||||
export {
|
||||
createStdoutAuditInstruments,
|
||||
type StdoutAuditInstruments,
|
||||
type StdoutAuditInstrumentsConfig,
|
||||
} from "./instruments/instruments.js";
|
||||
export {
|
||||
DEFAULT_MAX_QUEUE_MS,
|
||||
DEFAULT_WARN_THROTTLE_MS,
|
||||
type ResolvedStdoutAuditScalarOptions,
|
||||
resolveStdoutAuditScalarOptions,
|
||||
type StdoutAuditScalarOptions,
|
||||
stdoutAuditScalarOptionsSchema,
|
||||
} from "./options/options.js";
|
||||
export {
|
||||
type AuditCause,
|
||||
type AuditOutcome,
|
||||
type AuditPrincipal,
|
||||
type AuditRecord,
|
||||
auditCauseSchema,
|
||||
auditOutcomeSchema,
|
||||
auditPrincipalSchema,
|
||||
auditRecordSchema,
|
||||
parseAuditRecord,
|
||||
} from "./schema/schema.js";
|
||||
export {
|
||||
adaptEnvelope,
|
||||
createStdoutAuditSink,
|
||||
type LineWriter,
|
||||
type StdoutAuditSink,
|
||||
type StdoutAuditSinkOptions,
|
||||
} from "./sink/sink.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createStdoutAuditInstruments,
|
||||
type StdoutAuditInstruments,
|
||||
type StdoutAuditInstrumentsConfig,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# `instruments` — OTel counters for the stdout audit sink
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the two counters the stdout sink emits at runtime,
|
||||
matching design doc §12.3's metric surface for the audit pipeline.
|
||||
Operators wire the `MeterProvider` once at process boot; the sink
|
||||
picks up the configured provider through this factory. When no SDK
|
||||
is installed, the OTel global resolves to a no-op meter so the sink
|
||||
runs cleanly in tests without any ceremony.
|
||||
|
||||
The factory returns a frozen bundle so the sink does zero meter
|
||||
lookups on the hot path — both counters are constructed once and the
|
||||
record's `type` / `outcome` labels are attached at observation time.
|
||||
|
||||
## Features
|
||||
|
||||
- `api.audit.records_total{sink, type, outcome}` — successful write
|
||||
counter.
|
||||
- `api.audit.dropped_total{sink, reason}` — drop counter for
|
||||
queue-full and writer-failed paths.
|
||||
- Default meter provider falls back to the OTel global.
|
||||
- Frozen bundle — references stable for the sink's lifetime.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
|
||||
|
||||
const instruments = createStdoutAuditInstruments();
|
||||
instruments.recordsTotal.add(1, {
|
||||
sink: "stdout",
|
||||
type: "request.success",
|
||||
outcome: "success",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
|
||||
|
||||
const meterProvider = new MeterProvider();
|
||||
const instruments = createStdoutAuditInstruments({ meterProvider });
|
||||
|
||||
// Hand them into createStdoutAuditSink so the sink emits through the
|
||||
// SDK-installed provider rather than the global no-op.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createStdoutAuditInstruments(config?)`
|
||||
- `meterProvider?: MeterProvider` — defaults to
|
||||
`metrics.getMeterProvider()`.
|
||||
- Returns `StdoutAuditInstruments` with `meter`, `recordsTotal`,
|
||||
`droppedTotal`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Counters are constructed eagerly so the first write does zero
|
||||
allocations beyond the attribute object. The bundle is frozen to
|
||||
prevent accidental shadowing. The `sink="stdout"` attribute is
|
||||
attached at observation time by the sink so the same provider can
|
||||
serve multiple sink implementations side by side (file, vault, etc.)
|
||||
without conflict on the metric name.
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createStdoutAuditInstruments } from "./instruments.js";
|
||||
|
||||
async function readCounter(
|
||||
provider: MeterProvider,
|
||||
reader: PeriodicExportingMetricReader,
|
||||
exporter: InMemoryMetricExporter,
|
||||
name: string
|
||||
): Promise<number> {
|
||||
exporter.reset();
|
||||
await provider.forceFlush();
|
||||
const records = exporter.getMetrics();
|
||||
let total = 0;
|
||||
for (const rm of records) {
|
||||
for (const scope of rm.scopeMetrics) {
|
||||
for (const m of scope.metrics) {
|
||||
if (m.descriptor.name !== name) continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
total += dp.value as number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
describe("createStdoutAuditInstruments", () => {
|
||||
it("falls back to the OTel global meter provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const instruments = createStdoutAuditInstruments();
|
||||
expect(instruments.recordsTotal).toBeDefined();
|
||||
expect(instruments.droppedTotal).toBeDefined();
|
||||
expect(instruments.meter).toBeDefined();
|
||||
});
|
||||
|
||||
it("returned bundle is frozen", { timeout: 5000 }, () => {
|
||||
const instruments = createStdoutAuditInstruments();
|
||||
expect(Object.isFrozen(instruments)).toBe(true);
|
||||
});
|
||||
|
||||
it("counters increment through a real MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
const instruments = createStdoutAuditInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
instruments.recordsTotal.add(1, {
|
||||
sink: "stdout",
|
||||
type: "x",
|
||||
outcome: "success",
|
||||
});
|
||||
instruments.recordsTotal.add(2, {
|
||||
sink: "stdout",
|
||||
type: "x",
|
||||
outcome: "success",
|
||||
});
|
||||
instruments.droppedTotal.add(1, { sink: "stdout" });
|
||||
|
||||
const records = await readCounter(
|
||||
provider,
|
||||
reader,
|
||||
exporter,
|
||||
"api.audit.records_total"
|
||||
);
|
||||
const dropped = await readCounter(
|
||||
provider,
|
||||
reader,
|
||||
exporter,
|
||||
"api.audit.dropped_total"
|
||||
);
|
||||
expect(records).toBe(3);
|
||||
expect(dropped).toBe(1);
|
||||
await provider.shutdown();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Config consumed by {@link createStdoutAuditInstruments}. The meter
|
||||
* provider defaults to the OTel global (which no-ops without an
|
||||
* installed SDK) so the sink works in test envs without ceremony.
|
||||
*/
|
||||
export interface StdoutAuditInstrumentsConfig {
|
||||
readonly meterProvider?: MeterProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen bundle of the two counters this sink emits.
|
||||
*
|
||||
* - `api.audit.records_total{sink="stdout", type, outcome}` — one
|
||||
* increment per successful write.
|
||||
* - `api.audit.dropped_total{sink="stdout"}` — one increment per
|
||||
* dropped record (queue-full or writer-failed). Drops are NEVER
|
||||
* silent — every drop bumps this counter before the sink returns.
|
||||
*/
|
||||
export interface StdoutAuditInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly recordsTotal: Counter;
|
||||
readonly droppedTotal: Counter;
|
||||
}
|
||||
|
||||
const METER_NAME = "@suaveplan/api-audit-stdout";
|
||||
|
||||
/**
|
||||
* Build the sink's instrument registry. Counters are created once at
|
||||
* construction so the hot-path writes do zero meter lookups.
|
||||
*/
|
||||
export function createStdoutAuditInstruments(
|
||||
config: StdoutAuditInstrumentsConfig = {}
|
||||
): StdoutAuditInstruments {
|
||||
const meterProvider = config.meterProvider ?? metrics.getMeterProvider();
|
||||
const meter = meterProvider.getMeter(METER_NAME);
|
||||
const recordsTotal = meter.createCounter("api.audit.records_total", {
|
||||
description:
|
||||
"Audit records successfully written, by sink + type + outcome",
|
||||
});
|
||||
const droppedTotal = meter.createCounter("api.audit.dropped_total", {
|
||||
description: "Audit records dropped by backpressure or writer failure",
|
||||
});
|
||||
return Object.freeze({ meter, recordsTotal, droppedTotal });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
DEFAULT_MAX_QUEUE_MS,
|
||||
DEFAULT_WARN_THROTTLE_MS,
|
||||
type ResolvedStdoutAuditScalarOptions,
|
||||
resolveStdoutAuditScalarOptions,
|
||||
type StdoutAuditScalarOptions,
|
||||
stdoutAuditScalarOptionsSchema,
|
||||
} from "./options.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `options` — constructor option schema and defaults
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the Zod schema and defaults for the scalar slice of
|
||||
`createStdoutAuditSink({...})`'s input. Validation happens at the
|
||||
boundary — a typo on `maxQueueMs` raises
|
||||
`AUDIT_STDOUT.INVALID_OPTIONS` rather than silently being ignored.
|
||||
The schema is `.strict()` so unknown keys are rejected (same rationale
|
||||
as the `AuditRecord` schema).
|
||||
|
||||
The non-scalar slots — writer, clock, timers, logger, instruments,
|
||||
hmacKey — bypass Zod and are checked structurally by the sink itself
|
||||
at construction. Mixing the two validation styles keeps the typed
|
||||
surface narrow while still rejecting obvious typos.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict Zod schema for scalar option keys.
|
||||
- Defaults: `maxQueueMs = 100`, `warnThrottleMs = 60_000`,
|
||||
`sinkLabel = "stdout"`.
|
||||
- Resolver wraps any `ZodError` as `AUDIT_STDOUT.INVALID_OPTIONS`.
|
||||
- Constants exported so callers can reference them when computing
|
||||
test throughput budgets.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveStdoutAuditScalarOptions } from "@suaveplan/api-audit-stdout/options";
|
||||
|
||||
const resolved = resolveStdoutAuditScalarOptions({ maxQueueMs: 250 });
|
||||
// resolved.warnThrottleMs === 60_000 (default)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_MAX_QUEUE_MS,
|
||||
resolveStdoutAuditScalarOptions,
|
||||
type StdoutAuditScalarOptions,
|
||||
} from "@suaveplan/api-audit-stdout/options";
|
||||
|
||||
function tightenedForTests(): StdoutAuditScalarOptions {
|
||||
return {
|
||||
maxQueueMs: Math.max(1, DEFAULT_MAX_QUEUE_MS / 10),
|
||||
warnThrottleMs: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = resolveStdoutAuditScalarOptions(tightenedForTests());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `stdoutAuditScalarOptionsSchema` — strict Zod object.
|
||||
- `StdoutAuditScalarOptions` — inferred type.
|
||||
- `ResolvedStdoutAuditScalarOptions` — defaults-applied shape.
|
||||
- `resolveStdoutAuditScalarOptions(raw)` — boundary parser.
|
||||
- `DEFAULT_MAX_QUEUE_MS`, `DEFAULT_WARN_THROTTLE_MS` — constants.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`maxQueueMs` is bounded to positive integers because a value of 0
|
||||
would mean "never queue" — that's a different semantics (synchronous
|
||||
backpressure) and would need its own option. `warnThrottleMs`
|
||||
governs the once-per-window log; tests use a `FakeClock` plus a
|
||||
short throttle to exercise the throttle without sleeping. The
|
||||
`sinkLabel` exists so a future spinoff (e.g. journald) can reuse
|
||||
this module while still attaching a distinct `sink="…"` label to its
|
||||
counters.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DEFAULT_MAX_QUEUE_MS,
|
||||
DEFAULT_WARN_THROTTLE_MS,
|
||||
resolveStdoutAuditScalarOptions,
|
||||
stdoutAuditScalarOptionsSchema,
|
||||
} from "./options.js";
|
||||
|
||||
describe("resolveStdoutAuditScalarOptions", () => {
|
||||
it("applies defaults when no input is provided", { timeout: 5000 }, () => {
|
||||
const r = resolveStdoutAuditScalarOptions(undefined);
|
||||
expect(r.maxQueueMs).toBe(DEFAULT_MAX_QUEUE_MS);
|
||||
expect(r.warnThrottleMs).toBe(DEFAULT_WARN_THROTTLE_MS);
|
||||
expect(r.sinkLabel).toBe("stdout");
|
||||
});
|
||||
|
||||
it("merges supplied scalars over defaults", { timeout: 5000 }, () => {
|
||||
const r = resolveStdoutAuditScalarOptions({
|
||||
maxQueueMs: 250,
|
||||
sinkLabel: "stdout-test",
|
||||
});
|
||||
expect(r.maxQueueMs).toBe(250);
|
||||
expect(r.warnThrottleMs).toBe(DEFAULT_WARN_THROTTLE_MS);
|
||||
expect(r.sinkLabel).toBe("stdout-test");
|
||||
});
|
||||
|
||||
it("rejects unknown keys with INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
resolveStdoutAuditScalarOptions({
|
||||
// @ts-expect-error — testing runtime rejection
|
||||
bogus: 1,
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditStdoutError);
|
||||
if (caught instanceof AuditStdoutError) {
|
||||
expect(caught.code).toBe(AUDIT_STDOUT_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative maxQueueMs", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
resolveStdoutAuditScalarOptions({ maxQueueMs: -1 })
|
||||
).toThrow(AuditStdoutError);
|
||||
});
|
||||
|
||||
it("rejects empty sinkLabel", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
resolveStdoutAuditScalarOptions({ sinkLabel: "" })
|
||||
).toThrow(AuditStdoutError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stdoutAuditScalarOptionsSchema", () => {
|
||||
it("is strict (extra keys rejected)", { timeout: 5000 }, () => {
|
||||
const r = stdoutAuditScalarOptionsSchema.safeParse({
|
||||
maxQueueMs: 1,
|
||||
bogus: 2,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Default upper bound for queue latency before a record is dropped. */
|
||||
export const DEFAULT_MAX_QUEUE_MS = 100;
|
||||
|
||||
/** Default throttle window for the drop-warning log (one warn per minute). */
|
||||
export const DEFAULT_WARN_THROTTLE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Strict Zod schema for {@link StdoutAuditSinkOptions}'s parsed
|
||||
* scalar fields. Object-typed slots (writer, instruments, clock,
|
||||
* timers, logger, hmacKey) are validated via separate `instanceof` /
|
||||
* structural checks because Zod's runtime model can't express
|
||||
* arbitrary external interfaces cleanly.
|
||||
*/
|
||||
export const stdoutAuditScalarOptionsSchema = z
|
||||
.object({
|
||||
maxQueueMs: z.number().int().positive().optional(),
|
||||
warnThrottleMs: z.number().int().positive().optional(),
|
||||
sinkLabel: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type StdoutAuditScalarOptions = z.infer<
|
||||
typeof stdoutAuditScalarOptionsSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Internal resolved-options shape. Every optional scalar has a
|
||||
* concrete value; the writer + clock + logger + timers + instruments
|
||||
* are resolved by the sink at construction time using the same
|
||||
* defaults documented on {@link createStdoutAuditSink}.
|
||||
*/
|
||||
export interface ResolvedStdoutAuditScalarOptions {
|
||||
readonly maxQueueMs: number;
|
||||
readonly warnThrottleMs: number;
|
||||
readonly sinkLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + apply defaults on the scalar slice of the constructor
|
||||
* input. Any Zod failure is repackaged as
|
||||
* `AUDIT_STDOUT.INVALID_OPTIONS` so callers see the package's own
|
||||
* error type rather than a raw `ZodError`.
|
||||
*/
|
||||
export function resolveStdoutAuditScalarOptions(
|
||||
raw: StdoutAuditScalarOptions | undefined
|
||||
): ResolvedStdoutAuditScalarOptions {
|
||||
const parsed = stdoutAuditScalarOptionsSchema.safeParse(raw ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AuditStdoutError(
|
||||
AUDIT_STDOUT_CODES.INVALID_OPTIONS,
|
||||
"error.audit_stdout.invalid_options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return {
|
||||
maxQueueMs: parsed.data.maxQueueMs ?? DEFAULT_MAX_QUEUE_MS,
|
||||
warnThrottleMs: parsed.data.warnThrottleMs ?? DEFAULT_WARN_THROTTLE_MS,
|
||||
sinkLabel: parsed.data.sinkLabel ?? "stdout",
|
||||
};
|
||||
}
|
||||
11
packages/api-stack/api/api-audit-stdout/src/schema/index.ts
Normal file
11
packages/api-stack/api/api-audit-stdout/src/schema/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
type AuditCause,
|
||||
type AuditOutcome,
|
||||
type AuditPrincipal,
|
||||
type AuditRecord,
|
||||
auditCauseSchema,
|
||||
auditOutcomeSchema,
|
||||
auditPrincipalSchema,
|
||||
auditRecordSchema,
|
||||
parseAuditRecord,
|
||||
} from "./schema.js";
|
||||
95
packages/api-stack/api/api-audit-stdout/src/schema/schema.md
Normal file
95
packages/api-stack/api/api-audit-stdout/src/schema/schema.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# `schema` — `AuditRecord` Zod schema (design doc §12.5)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module locks the wire shape of the JSON line the stdout sink
|
||||
writes. The shape is exactly what design doc §12.5 specifies for the
|
||||
admin / audit pipeline: `ts`, `type`, `principal`, `tenant`,
|
||||
`request_id`, `trace_id`, `subject`, `outcome`, and a conditional
|
||||
`cause`. Every write goes through `parseAuditRecord(...)` so a
|
||||
malformed record raises `AUDIT_STDOUT.INVALID_RECORD` *before* a line
|
||||
is written — log pipelines downstream never see a half-finished
|
||||
record.
|
||||
|
||||
The schema is `.strict()` (unknown top-level fields are rejected) and
|
||||
carries a `refine` enforcing the §12.5 invariant that `cause` is
|
||||
REQUIRED when `outcome === "failure"`. Optional fields surface via
|
||||
`exactOptionalPropertyTypes` parity so a missing `cause` on a
|
||||
`success` record is distinct from `cause: undefined` on a `failure`
|
||||
record. The principal slice is its own sub-schema so the same shape
|
||||
can be reused by other audit sinks that target the same wire format.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict shape — extra keys rejected.
|
||||
- `outcome === "failure"` requires `cause` (refine rule).
|
||||
- Sub-schemas for `cause`, `principal`, and `outcome` so consumers can
|
||||
type their own DTOs against the same atoms.
|
||||
- `parseAuditRecord(input)` returns the validated record or throws
|
||||
`AUDIT_STDOUT.INVALID_RECORD` wrapping the underlying `ZodError`.
|
||||
- All types are `z.infer<...>` — no hand-written interfaces drift.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseAuditRecord } from "@suaveplan/api-audit-stdout/schema";
|
||||
|
||||
const record = parseAuditRecord({
|
||||
ts: "2026-05-26T10:00:00.000Z",
|
||||
type: "admin.plugin.disabled",
|
||||
principal: { id: "user_1", scopes: ["admin"] },
|
||||
tenant: "tenant_a",
|
||||
request_id: "req_1",
|
||||
trace_id: null,
|
||||
subject: "plugin.exports",
|
||||
outcome: "success",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
auditRecordSchema,
|
||||
type AuditRecord,
|
||||
} from "@suaveplan/api-audit-stdout/schema";
|
||||
|
||||
function adapt(envelope: HostEnvelope): AuditRecord {
|
||||
return auditRecordSchema.parse({
|
||||
ts: envelope.at,
|
||||
type: `request.${envelope.outcome}`,
|
||||
principal: envelope.principal,
|
||||
tenant: envelope.tenantId,
|
||||
request_id: envelope.requestId,
|
||||
trace_id: envelope.traceId,
|
||||
subject: `${envelope.method} ${envelope.route}`,
|
||||
outcome: envelope.outcome,
|
||||
...(envelope.cause !== undefined ? { cause: envelope.cause } : {}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `auditRecordSchema` — strict Zod object with the §12.5 invariant
|
||||
refined.
|
||||
- `auditCauseSchema`, `auditPrincipalSchema`, `auditOutcomeSchema` —
|
||||
sub-schemas reused by consumers.
|
||||
- `AuditRecord`, `AuditCause`, `AuditPrincipal`, `AuditOutcome` —
|
||||
inferred types.
|
||||
- `parseAuditRecord(input: unknown): AuditRecord` — boundary parser
|
||||
that throws `AuditStdoutError(AUDIT_STDOUT.INVALID_RECORD, …)` on
|
||||
failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`.strict()` blocks unknown fields at the boundary — if a future
|
||||
design-doc revision adds fields, this schema must be updated in the
|
||||
same OpenSpec change as the spec. The `outcome === "failure"` /
|
||||
`cause` refine is enforced as a Zod `.refine` rather than a
|
||||
discriminated union so the failure path returns a `ZodError` with a
|
||||
single readable `path: ["cause"]` issue.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
auditOutcomeSchema,
|
||||
auditRecordSchema,
|
||||
parseAuditRecord,
|
||||
} from "./schema.js";
|
||||
|
||||
const baseValid = {
|
||||
ts: "2026-05-26T10:00:00.000Z",
|
||||
type: "admin.plugin.disabled",
|
||||
principal: { id: "user_1", scopes: ["admin"] as const },
|
||||
tenant: "tenant_a",
|
||||
request_id: "req_1",
|
||||
trace_id: null,
|
||||
subject: "plugin.exports",
|
||||
outcome: "success" as const,
|
||||
};
|
||||
|
||||
describe("auditRecordSchema", () => {
|
||||
it("accepts a complete success record", { timeout: 5000 }, () => {
|
||||
const ok = auditRecordSchema.safeParse(baseValid);
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a failure record carrying cause", { timeout: 5000 }, () => {
|
||||
const ok = auditRecordSchema.safeParse({
|
||||
...baseValid,
|
||||
outcome: "failure",
|
||||
cause: { code: "PLUGIN.NOT_FOUND", message: "no such plugin" },
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("missing request_id is rejected", { timeout: 5000 }, () => {
|
||||
const { request_id: _r, ...rest } = baseValid;
|
||||
const bad = auditRecordSchema.safeParse(rest);
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
|
||||
it("unknown field is rejected", { timeout: 5000 }, () => {
|
||||
const bad = auditRecordSchema.safeParse({
|
||||
...baseValid,
|
||||
extra: "nope",
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
|
||||
it("failure without cause is rejected", { timeout: 5000 }, () => {
|
||||
const bad = auditRecordSchema.safeParse({
|
||||
...baseValid,
|
||||
outcome: "failure",
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
if (!bad.success) {
|
||||
expect(bad.error.issues[0]?.path).toEqual(["cause"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("principal may be null", { timeout: 5000 }, () => {
|
||||
const ok = auditRecordSchema.safeParse({
|
||||
...baseValid,
|
||||
principal: null,
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("tenant may be null", { timeout: 5000 }, () => {
|
||||
const ok = auditRecordSchema.safeParse({
|
||||
...baseValid,
|
||||
tenant: null,
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auditOutcomeSchema", () => {
|
||||
it("rejects unknown outcome literals", { timeout: 5000 }, () => {
|
||||
expect(auditOutcomeSchema.safeParse("partial").success).toBe(false);
|
||||
expect(auditOutcomeSchema.safeParse("success").success).toBe(true);
|
||||
expect(auditOutcomeSchema.safeParse("failure").success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAuditRecord", () => {
|
||||
it("returns validated record on success", { timeout: 5000 }, () => {
|
||||
const result = parseAuditRecord(baseValid);
|
||||
expect(result.request_id).toBe("req_1");
|
||||
});
|
||||
|
||||
it("throws AuditStdoutError on invalid input", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
parseAuditRecord({ ...baseValid, request_id: "" });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditStdoutError);
|
||||
if (caught instanceof AuditStdoutError) {
|
||||
expect(caught.code).toBe(AUDIT_STDOUT_CODES.INVALID_RECORD);
|
||||
}
|
||||
});
|
||||
});
|
||||
97
packages/api-stack/api/api-audit-stdout/src/schema/schema.ts
Normal file
97
packages/api-stack/api/api-audit-stdout/src/schema/schema.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Audit-record outcome enum. Mirrors the request-pipeline's
|
||||
* `RequestAuditEnvelope.outcome` so the host can hand its envelope
|
||||
* directly to the sink after `RequestAuditEnvelope ↔ AuditRecord`
|
||||
* adaption.
|
||||
*/
|
||||
export const auditOutcomeSchema = z.enum(["success", "failure"]);
|
||||
export type AuditOutcome = z.infer<typeof auditOutcomeSchema>;
|
||||
|
||||
/**
|
||||
* Cause attached to non-2xx audit records. Locked to two short
|
||||
* strings so log pipelines can index on `cause.code` cheaply.
|
||||
*/
|
||||
export const auditCauseSchema = z
|
||||
.object({
|
||||
code: z.string().min(1),
|
||||
message: z.string(),
|
||||
})
|
||||
.strict();
|
||||
export type AuditCause = z.infer<typeof auditCauseSchema>;
|
||||
|
||||
/**
|
||||
* Principal slice carried by an audit record. Anonymous requests
|
||||
* resolve to `null` so the field is explicit at the wire — never
|
||||
* stripped.
|
||||
*/
|
||||
export const auditPrincipalSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
scopes: z.array(z.string()).readonly(),
|
||||
})
|
||||
.strict();
|
||||
export type AuditPrincipal = z.infer<typeof auditPrincipalSchema>;
|
||||
|
||||
/**
|
||||
* Canonical audit record shape (design doc §12.5).
|
||||
*
|
||||
* Field names match the wire-format the design doc locks: `ts`,
|
||||
* `type`, `principal`, `tenant`, `request_id`, `trace_id`, `subject`,
|
||||
* `outcome`, `cause`. The schema is `.strict()` — unknown top-level
|
||||
* fields are rejected at the boundary so typos in caller code surface
|
||||
* as `AUDIT_STDOUT.INVALID_RECORD` rather than silently riding along
|
||||
* to log storage.
|
||||
*/
|
||||
export const auditRecordSchema = z
|
||||
.object({
|
||||
/** RFC 3339 timestamp in UTC. */
|
||||
ts: z.string().min(1),
|
||||
/** Event type, e.g. `"request.error"`, `"admin.plugin.disabled"`. */
|
||||
type: z.string().min(1),
|
||||
/** Principal slice or `null` for anonymous. */
|
||||
principal: auditPrincipalSchema.nullable(),
|
||||
/** Tenant id or `null` for cross-tenant / unscoped actions. */
|
||||
tenant: z.string().nullable(),
|
||||
/** Per-request id from the host's request pipeline. */
|
||||
request_id: z.string().min(1),
|
||||
/** W3C trace id when present, `null` otherwise. */
|
||||
trace_id: z.string().nullable(),
|
||||
/** Action subject — route, plugin, resource id, etc. */
|
||||
subject: z.string().min(1),
|
||||
/** `success` or `failure`. */
|
||||
outcome: auditOutcomeSchema,
|
||||
/** Cause is REQUIRED when `outcome === "failure"`; OPTIONAL otherwise. */
|
||||
cause: auditCauseSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((r) => !(r.outcome === "failure" && r.cause === undefined), {
|
||||
message: "cause is required when outcome === 'failure'",
|
||||
path: ["cause"],
|
||||
});
|
||||
|
||||
export type AuditRecord = z.infer<typeof auditRecordSchema>;
|
||||
|
||||
/**
|
||||
* Parse + validate an audit record at the write boundary. On failure,
|
||||
* the underlying `z.ZodError` is wrapped as
|
||||
* `AUDIT_STDOUT.INVALID_RECORD`. The validated record is returned
|
||||
* with its strict shape preserved (so the sink can serialise it
|
||||
* directly).
|
||||
*/
|
||||
export function parseAuditRecord(input: unknown): AuditRecord {
|
||||
const parsed = auditRecordSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new AuditStdoutError(
|
||||
AUDIT_STDOUT_CODES.INVALID_RECORD,
|
||||
"error.audit_stdout.invalid_record",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
adaptEnvelope,
|
||||
createStdoutAuditSink,
|
||||
type LineWriter,
|
||||
type StdoutAuditSink,
|
||||
type StdoutAuditSinkOptions,
|
||||
} from "./sink.js";
|
||||
108
packages/api-stack/api/api-audit-stdout/src/sink/sink.md
Normal file
108
packages/api-stack/api/api-audit-stdout/src/sink/sink.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `sink` — `createStdoutAuditSink`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module wires the schema, HMAC signer, instruments, and options
|
||||
into a `RequestAuditSink` implementation that writes JSON-per-line
|
||||
audit records to a `LineWriter` (default `process.stdout`). It is the
|
||||
default audit sink shipped with `@suaveplan/api-host`: container
|
||||
runtimes already redirect stdout into a log pipeline, so operators
|
||||
inherit the records without touching host configuration.
|
||||
|
||||
The sink is single-flight: at most one write is on the wire at any
|
||||
time. Subsequent records wait for the slot, but only up to
|
||||
`maxQueueMs` (default 100ms) before being dropped. Drops are NEVER
|
||||
silent — every drop increments `api.audit.dropped_total{sink,reason}`
|
||||
and (subject to the once-per-`warnThrottleMs` throttle) emits a
|
||||
single warning log. Validation, HMAC signing, and serialisation
|
||||
happen on the calling side of the queue so a malformed record is
|
||||
rejected synchronously without consuming the slot.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `RequestAuditSink` (`emit(envelope)`) from
|
||||
`@suaveplan/api-host/request-pipeline` — host accepts the instance
|
||||
directly.
|
||||
- `write(record: AuditRecord)` overload for admin paths that already
|
||||
hold a canonical record.
|
||||
- Boundary Zod validation — `AUDIT_STDOUT.INVALID_RECORD` on a bad
|
||||
record.
|
||||
- Bounded queue — `AUDIT_STDOUT.QUEUE_FULL` on timeout, drop counter
|
||||
+ throttled warning.
|
||||
- HMAC-SHA-256 over canonical bytes appended as `_hmac` field.
|
||||
- `dispose()` drains the in-flight write and rejects subsequent
|
||||
writes as `closed` drops.
|
||||
- Pluggable `clock`, `timers`, `logger`, `writer`, `instruments`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStdoutAuditSink } from "@suaveplan/api-audit-stdout/sink";
|
||||
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey: new TextEncoder().encode(process.env["AUDIT_HMAC_KEY"]!),
|
||||
});
|
||||
await sink.write({
|
||||
ts: new Date().toISOString(),
|
||||
type: "admin.plugin.disabled",
|
||||
principal: { id: "user_1", scopes: ["admin"] },
|
||||
tenant: "tenant_a",
|
||||
request_id: "req_1",
|
||||
trace_id: null,
|
||||
subject: "plugin.exports",
|
||||
outcome: "success",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createStdoutAuditSink } from "@suaveplan/api-audit-stdout/sink";
|
||||
import { createStdoutAuditInstruments } from "@suaveplan/api-audit-stdout/instruments";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const meterProvider = new MeterProvider({ readers: [...] });
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey,
|
||||
writer: {
|
||||
async write(line) {
|
||||
await myStream.write(line);
|
||||
},
|
||||
},
|
||||
maxQueueMs: 250,
|
||||
warnThrottleMs: 30_000,
|
||||
instruments: createStdoutAuditInstruments({ meterProvider }),
|
||||
logger: myLogger,
|
||||
});
|
||||
// Hand into createHost({ audit: sink }) per host wiring.
|
||||
await sink.emit(envelope);
|
||||
await sink.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createStdoutAuditSink(options)` → `StdoutAuditSink`.
|
||||
- `StdoutAuditSinkOptions` — `{ hmacKey, writer?, clock?, timers?,
|
||||
logger?, instruments?, maxQueueMs?, warnThrottleMs?, sinkLabel? }`.
|
||||
- `StdoutAuditSink` — extends `RequestAuditSink`; adds `write(record)`
|
||||
and `dispose()`.
|
||||
- `LineWriter` — `{ write(line: string): void | Promise<void> }`.
|
||||
- `adaptEnvelope(envelope)` — exposed for tests and bespoke
|
||||
re-emission flows.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The slot reservation uses an in-flight `Promise<void>` rather than a
|
||||
queue + worker so cancellation flows trivially through GC — there's
|
||||
no worker loop to shut down. `acquireSlot` races the in-flight write
|
||||
against the `timers.setTimer` callback; whichever wins clears the
|
||||
loser. The `closed` flag is checked before validation so a disposed
|
||||
sink rejects fast without spending CPU on schema work.
|
||||
`adaptEnvelope` is the only place the host's camelCase envelope
|
||||
shape meets the canonical snake_case record — keeping that mapping
|
||||
isolated lets the design-doc wire shape evolve without rippling
|
||||
through unrelated callers.
|
||||
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createStdoutAuditInstruments } from "../instruments/instruments.js";
|
||||
import type { AuditRecord } from "../schema/schema.js";
|
||||
import { createStdoutAuditSink, type LineWriter } from "./sink.js";
|
||||
|
||||
const KEY = new TextEncoder().encode("a-32-byte-key-for-tests-padding!");
|
||||
|
||||
function makeRecord(over: Partial<AuditRecord> = {}): AuditRecord {
|
||||
return {
|
||||
ts: "2026-05-26T10:00:00.000Z",
|
||||
type: "admin.plugin.disabled",
|
||||
principal: { id: "user_1", scopes: ["admin"] },
|
||||
tenant: "tenant_a",
|
||||
request_id: "req_1",
|
||||
trace_id: null,
|
||||
subject: "plugin.exports",
|
||||
outcome: "success",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writer whose first write blocks until `release()` is called so the
|
||||
* sink slot is held while subsequent writes are dispatched. This is
|
||||
* the only way to deterministically trigger the queue-full path
|
||||
* without relying on real timers.
|
||||
*/
|
||||
class HangingWriter implements LineWriter {
|
||||
public readonly lines: string[] = [];
|
||||
private resolveFirst: (() => void) | null = null;
|
||||
private firstWritten = false;
|
||||
public firstPending: Promise<void>;
|
||||
public constructor() {
|
||||
this.firstPending = new Promise((res) => {
|
||||
this.resolveFirst = res;
|
||||
});
|
||||
}
|
||||
public release(): void {
|
||||
this.resolveFirst?.();
|
||||
}
|
||||
public async write(line: string): Promise<void> {
|
||||
this.lines.push(line);
|
||||
if (!this.firstWritten) {
|
||||
this.firstWritten = true;
|
||||
await this.firstPending;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Macrotask-deferred timer. Fires on `setImmediate` (which runs
|
||||
* AFTER all queued microtasks) so a free slot's `inflight.then`
|
||||
* microtask always wins the race, while a held slot lets the timer
|
||||
* trigger the queue-full path deterministically.
|
||||
*/
|
||||
function deferredTimers(): TimersLike {
|
||||
let id = 1;
|
||||
const handles = new Map<number, NodeJS.Immediate>();
|
||||
return {
|
||||
setTimer: (cb) => {
|
||||
const handle = id++;
|
||||
const imm = setImmediate(() => {
|
||||
handles.delete(handle);
|
||||
cb();
|
||||
});
|
||||
handles.set(handle, imm);
|
||||
return handle;
|
||||
},
|
||||
clearTimer: (h) => {
|
||||
const imm = handles.get(h);
|
||||
if (imm !== undefined) {
|
||||
clearImmediate(imm);
|
||||
handles.delete(h);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readCounterValue(
|
||||
provider: MeterProvider,
|
||||
reader: PeriodicExportingMetricReader,
|
||||
exporter: InMemoryMetricExporter,
|
||||
name: string,
|
||||
matchAttr?: (attrs: Record<string, unknown>) => boolean
|
||||
): Promise<number> {
|
||||
exporter.reset();
|
||||
await provider.forceFlush();
|
||||
const records = exporter.getMetrics();
|
||||
let total = 0;
|
||||
for (const rm of records) {
|
||||
for (const scope of rm.scopeMetrics) {
|
||||
for (const m of scope.metrics) {
|
||||
if (m.descriptor.name !== name) continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
if (
|
||||
matchAttr === undefined ||
|
||||
matchAttr(dp.attributes as Record<string, unknown>)
|
||||
) {
|
||||
total += dp.value as number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function makeProviderAndReader(): {
|
||||
provider: MeterProvider;
|
||||
reader: PeriodicExportingMetricReader;
|
||||
exporter: InMemoryMetricExporter;
|
||||
} {
|
||||
const exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
return { provider, reader, exporter };
|
||||
}
|
||||
|
||||
describe("stdout sink — conformance", () => {
|
||||
it("successful write increments records_total with type and outcome labels", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, reader, exporter } = makeProviderAndReader();
|
||||
const instruments = createStdoutAuditInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
const writer: LineWriter = { write: () => undefined };
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey: KEY,
|
||||
writer,
|
||||
instruments,
|
||||
});
|
||||
await sink.write(makeRecord({ type: "admin.x", outcome: "success" }));
|
||||
const total = await readCounterValue(
|
||||
provider,
|
||||
reader,
|
||||
exporter,
|
||||
"api.audit.records_total",
|
||||
(a) =>
|
||||
a["sink"] === "stdout" &&
|
||||
a["type"] === "admin.x" &&
|
||||
a["outcome"] === "success"
|
||||
);
|
||||
expect(total).toBe(1);
|
||||
await sink.dispose();
|
||||
await provider.shutdown();
|
||||
});
|
||||
|
||||
it("drop increments dropped_total", { timeout: 5000 }, async () => {
|
||||
const { provider, reader, exporter } = makeProviderAndReader();
|
||||
const instruments = createStdoutAuditInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
const writer = new HangingWriter();
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey: KEY,
|
||||
writer,
|
||||
instruments,
|
||||
timers: deferredTimers(),
|
||||
maxQueueMs: 50,
|
||||
});
|
||||
// First write holds the slot
|
||||
const first = sink.write(makeRecord({ request_id: "r1" }));
|
||||
// Yield to let first acquire the slot
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// Second write attempts to acquire and times out immediately
|
||||
let caught: unknown;
|
||||
try {
|
||||
await sink.write(makeRecord({ request_id: "r2" }));
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditStdoutError);
|
||||
if (caught instanceof AuditStdoutError) {
|
||||
expect(caught.code).toBe(AUDIT_STDOUT_CODES.QUEUE_FULL);
|
||||
}
|
||||
writer.release();
|
||||
await first;
|
||||
const dropped = await readCounterValue(
|
||||
provider,
|
||||
reader,
|
||||
exporter,
|
||||
"api.audit.dropped_total",
|
||||
(a) => a["sink"] === "stdout" && a["reason"] === "queue_full"
|
||||
);
|
||||
expect(dropped).toBe(1);
|
||||
await sink.dispose();
|
||||
await provider.shutdown();
|
||||
});
|
||||
|
||||
it("warning log throttled to once per minute", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const warnings: {
|
||||
msg: string;
|
||||
ctx?: Readonly<Record<string, unknown>>;
|
||||
}[] = [];
|
||||
const logger: LoggerLike = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg, ctx) => {
|
||||
warnings.push({ msg, ...(ctx !== undefined ? { ctx } : {}) });
|
||||
},
|
||||
error: () => undefined,
|
||||
};
|
||||
let now = 0;
|
||||
const clock: Clock = () => now;
|
||||
const writer = new HangingWriter();
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey: KEY,
|
||||
writer,
|
||||
timers: deferredTimers(),
|
||||
maxQueueMs: 50,
|
||||
warnThrottleMs: 60_000,
|
||||
clock,
|
||||
logger,
|
||||
});
|
||||
const first = sink.write(makeRecord({ request_id: "r1" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// Fire 5 drops at simulated now=0..30_000 (all inside one minute)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
now = i * 6_000;
|
||||
try {
|
||||
await sink.write(makeRecord({ request_id: `rx_${i}` }));
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
}
|
||||
expect(warnings.length).toBe(1);
|
||||
|
||||
// Advance the clock past the throttle window — the next drop
|
||||
// should produce a fresh warning.
|
||||
now = 120_000;
|
||||
try {
|
||||
await sink.write(makeRecord({ request_id: "ry" }));
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
expect(warnings.length).toBe(2);
|
||||
|
||||
writer.release();
|
||||
await first;
|
||||
await sink.dispose();
|
||||
});
|
||||
|
||||
it("dropped_total tracks writer_failed reason separately", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, reader, exporter } = makeProviderAndReader();
|
||||
const instruments = createStdoutAuditInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
const writer: LineWriter = {
|
||||
write: () => {
|
||||
throw new Error("disk_full");
|
||||
},
|
||||
};
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey: KEY,
|
||||
writer,
|
||||
instruments,
|
||||
});
|
||||
try {
|
||||
await sink.write(makeRecord());
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
const dropped = await readCounterValue(
|
||||
provider,
|
||||
reader,
|
||||
exporter,
|
||||
"api.audit.dropped_total",
|
||||
(a) => a["reason"] === "writer_failed"
|
||||
);
|
||||
expect(dropped).toBe(1);
|
||||
await sink.dispose();
|
||||
await provider.shutdown();
|
||||
});
|
||||
|
||||
it("closed sink increments dropped_total with reason=closed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { provider, reader, exporter } = makeProviderAndReader();
|
||||
const instruments = createStdoutAuditInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
const writer: LineWriter = { write: () => undefined };
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey: KEY,
|
||||
writer,
|
||||
instruments,
|
||||
});
|
||||
await sink.dispose();
|
||||
await sink.write(makeRecord());
|
||||
const dropped = await readCounterValue(
|
||||
provider,
|
||||
reader,
|
||||
exporter,
|
||||
"api.audit.dropped_total",
|
||||
(a) => a["reason"] === "closed"
|
||||
);
|
||||
expect(dropped).toBe(1);
|
||||
await provider.shutdown();
|
||||
});
|
||||
});
|
||||
326
packages/api-stack/api/api-audit-stdout/src/sink/sink.test.ts
Normal file
326
packages/api-stack/api/api-audit-stdout/src/sink/sink.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import type { RequestAuditEnvelope } from "@suaveplan/api-host/request-pipeline";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type { Clock, TimersLike } from "@suaveplan/types";
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { HMAC_FIELD, verifyRecord } from "../hmac/hmac.js";
|
||||
import type { AuditRecord } from "../schema/schema.js";
|
||||
import {
|
||||
adaptEnvelope,
|
||||
createStdoutAuditSink,
|
||||
type LineWriter,
|
||||
type StdoutAuditSink,
|
||||
} from "./sink.js";
|
||||
|
||||
const KEY = new TextEncoder().encode("a-32-byte-key-for-tests-padding!");
|
||||
|
||||
function makeRecord(over: Partial<AuditRecord> = {}): AuditRecord {
|
||||
return {
|
||||
ts: "2026-05-26T10:00:00.000Z",
|
||||
type: "admin.plugin.disabled",
|
||||
principal: { id: "user_1", scopes: ["admin"] },
|
||||
tenant: "tenant_a",
|
||||
request_id: "req_1",
|
||||
trace_id: null,
|
||||
subject: "plugin.exports",
|
||||
outcome: "success",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
class CollectingWriter implements LineWriter {
|
||||
public readonly lines: string[] = [];
|
||||
public throwOnNext = false;
|
||||
public async write(line: string): Promise<void> {
|
||||
if (this.throwOnNext) {
|
||||
this.throwOnNext = false;
|
||||
throw new Error("boom");
|
||||
}
|
||||
this.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
describe("createStdoutAuditSink", () => {
|
||||
it("writes JSON-per-line with HMAC signature", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const writer = new CollectingWriter();
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
|
||||
await sink.write(makeRecord());
|
||||
expect(writer.lines.length).toBe(1);
|
||||
const line = writer.lines[0] ?? "";
|
||||
expect(line.endsWith("\n")).toBe(true);
|
||||
const parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
expect(typeof parsed[HMAC_FIELD]).toBe("string");
|
||||
const { _hmac, ...rest } = parsed as { _hmac: string } & AuditRecord;
|
||||
expect(verifyRecord(rest as AuditRecord, KEY, _hmac)).toBe(true);
|
||||
await sink.dispose();
|
||||
});
|
||||
|
||||
it("rejects records that fail Zod validation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const writer = new CollectingWriter();
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
|
||||
let caught: unknown;
|
||||
try {
|
||||
// @ts-expect-error — testing runtime rejection
|
||||
await sink.write({ ...makeRecord(), request_id: undefined });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditStdoutError);
|
||||
if (caught instanceof AuditStdoutError) {
|
||||
expect(caught.code).toBe(AUDIT_STDOUT_CODES.INVALID_RECORD);
|
||||
}
|
||||
expect(writer.lines.length).toBe(0);
|
||||
await sink.dispose();
|
||||
});
|
||||
|
||||
it("emit() adapts a RequestAuditEnvelope to canonical record", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const writer = new CollectingWriter();
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
|
||||
const envelope: RequestAuditEnvelope = {
|
||||
at: "2026-05-26T10:00:00.000Z",
|
||||
requestId: "req_1",
|
||||
traceId: null,
|
||||
route: "/admin/plugin",
|
||||
plugin: "@core/admin",
|
||||
method: "POST",
|
||||
principal: { id: "u1", scopes: ["admin"] },
|
||||
tenantId: "t1",
|
||||
status: 200,
|
||||
outcome: "success",
|
||||
};
|
||||
await sink.emit(envelope);
|
||||
expect(writer.lines.length).toBe(1);
|
||||
const parsed = JSON.parse(writer.lines[0] ?? "") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(parsed["subject"]).toBe("POST /admin/plugin");
|
||||
expect(parsed["type"]).toBe("request.success");
|
||||
expect(parsed["request_id"]).toBe("req_1");
|
||||
await sink.dispose();
|
||||
});
|
||||
|
||||
it("emit() forwards cause on failure envelopes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const writer = new CollectingWriter();
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
|
||||
await sink.emit({
|
||||
at: "2026-05-26T10:00:00.000Z",
|
||||
requestId: "req_2",
|
||||
traceId: "trace_1",
|
||||
route: "/admin/plugin",
|
||||
plugin: "@core/admin",
|
||||
method: "POST",
|
||||
principal: null,
|
||||
tenantId: null,
|
||||
status: 500,
|
||||
outcome: "failure",
|
||||
cause: { code: "PLUGIN.BOOM", message: "kaboom" },
|
||||
});
|
||||
const parsed = JSON.parse(writer.lines[0] ?? "") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(parsed["outcome"]).toBe("failure");
|
||||
expect(parsed["cause"]).toEqual({
|
||||
code: "PLUGIN.BOOM",
|
||||
message: "kaboom",
|
||||
});
|
||||
await sink.dispose();
|
||||
});
|
||||
|
||||
it("writer rejection raises WRITER_FAILED", { timeout: 5000 }, async () => {
|
||||
const writer = new CollectingWriter();
|
||||
writer.throwOnNext = true;
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await sink.write(makeRecord());
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditStdoutError);
|
||||
if (caught instanceof AuditStdoutError) {
|
||||
expect(caught.code).toBe(AUDIT_STDOUT_CODES.WRITER_FAILED);
|
||||
expect(caught.cause).toBeInstanceOf(Error);
|
||||
}
|
||||
await sink.dispose();
|
||||
});
|
||||
|
||||
it("non-Error writer throw still raises WRITER_FAILED without cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const writer: LineWriter = {
|
||||
write: () => {
|
||||
throw "literal-string-throw";
|
||||
},
|
||||
};
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await sink.write(makeRecord());
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditStdoutError);
|
||||
if (caught instanceof AuditStdoutError) {
|
||||
expect(caught.code).toBe(AUDIT_STDOUT_CODES.WRITER_FAILED);
|
||||
expect(caught.cause).toBeUndefined();
|
||||
}
|
||||
await sink.dispose();
|
||||
});
|
||||
|
||||
it("dispose makes subsequent writes silent no-ops", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const writer = new CollectingWriter();
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
|
||||
await sink.dispose();
|
||||
await sink.write(makeRecord());
|
||||
expect(writer.lines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const writer = new CollectingWriter();
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer });
|
||||
await sink.dispose();
|
||||
await sink.dispose();
|
||||
expect(writer.lines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an empty hmacKey", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createStdoutAuditSink({ hmacKey: new Uint8Array(0) })
|
||||
).toThrow(AuditStdoutError);
|
||||
});
|
||||
|
||||
it("rejects a non-Uint8Array hmacKey", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createStdoutAuditSink({
|
||||
// @ts-expect-error — testing runtime rejection
|
||||
hmacKey: "not-bytes",
|
||||
})
|
||||
).toThrow(AuditStdoutError);
|
||||
});
|
||||
|
||||
it("rejects invalid scalar options", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createStdoutAuditSink({ hmacKey: KEY, maxQueueMs: -1 })
|
||||
).toThrow(AuditStdoutError);
|
||||
});
|
||||
|
||||
it("uses the default stdout writer when none is provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = process.stdout.write.bind(process.stdout);
|
||||
const captured: string[] = [];
|
||||
const stub: typeof process.stdout.write = ((
|
||||
chunk: string | Uint8Array
|
||||
): boolean => {
|
||||
captured.push(
|
||||
typeof chunk === "string"
|
||||
? chunk
|
||||
: new TextDecoder().decode(chunk)
|
||||
);
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
process.stdout.write = stub;
|
||||
try {
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY });
|
||||
await sink.write(makeRecord());
|
||||
await sink.dispose();
|
||||
} finally {
|
||||
process.stdout.write = original;
|
||||
}
|
||||
expect(captured.length).toBe(1);
|
||||
expect((captured[0] ?? "").endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("typed surface satisfies RequestAuditSink", { timeout: 5000 }, () => {
|
||||
const sink: StdoutAuditSink = createStdoutAuditSink({ hmacKey: KEY });
|
||||
expectTypeOf(sink.emit).toBeFunction();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptEnvelope", () => {
|
||||
it("strips cause when envelope omits it", { timeout: 5000 }, () => {
|
||||
const r = adaptEnvelope({
|
||||
at: "2026-05-26T10:00:00.000Z",
|
||||
requestId: "r",
|
||||
traceId: null,
|
||||
route: "/x",
|
||||
plugin: "p",
|
||||
method: "GET",
|
||||
principal: null,
|
||||
tenantId: null,
|
||||
status: 200,
|
||||
outcome: "success",
|
||||
});
|
||||
expect(r.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clock + timers wiring", () => {
|
||||
it("uses injected clock for warning throttle", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Reach for the clock by forcing a writer error so the warning
|
||||
// path runs. The throttle uses `clock()` for `lastWarnAt`.
|
||||
let now = 0;
|
||||
const clock: Clock = () => now;
|
||||
const writer = new CollectingWriter();
|
||||
writer.throwOnNext = true;
|
||||
const sink = createStdoutAuditSink({ hmacKey: KEY, writer, clock });
|
||||
try {
|
||||
await sink.write(makeRecord());
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
now = 1_000_000;
|
||||
// Should not throw on the second attempt with a fresh writer.
|
||||
await sink.write(makeRecord());
|
||||
expect(writer.lines.length).toBe(1);
|
||||
await sink.dispose();
|
||||
});
|
||||
|
||||
it("uses injected timers for queue-full detection", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let lastDelay = -1;
|
||||
let cleared = 0;
|
||||
let counter = 1;
|
||||
const timers: TimersLike = {
|
||||
setTimer: (cb, ms) => {
|
||||
lastDelay = ms;
|
||||
const id = counter++;
|
||||
// Defer to macrotask so a free slot's inflight.then
|
||||
// microtask resolves first.
|
||||
setImmediate(cb);
|
||||
return id;
|
||||
},
|
||||
clearTimer: () => {
|
||||
cleared += 1;
|
||||
},
|
||||
};
|
||||
const writer = new CollectingWriter();
|
||||
const sink = createStdoutAuditSink({
|
||||
hmacKey: KEY,
|
||||
writer,
|
||||
timers,
|
||||
maxQueueMs: 500,
|
||||
});
|
||||
await sink.write(makeRecord());
|
||||
expect(lastDelay).toBe(500);
|
||||
expect(cleared).toBeGreaterThanOrEqual(1);
|
||||
await sink.dispose();
|
||||
});
|
||||
});
|
||||
270
packages/api-stack/api/api-audit-stdout/src/sink/sink.ts
Normal file
270
packages/api-stack/api/api-audit-stdout/src/sink/sink.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type {
|
||||
RequestAuditEnvelope,
|
||||
RequestAuditSink,
|
||||
} from "@suaveplan/api-host/request-pipeline";
|
||||
import type { Clock, LoggerLike, TimersLike } from "@suaveplan/types";
|
||||
import {
|
||||
AUDIT_STDOUT_CODES,
|
||||
AuditStdoutError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { HMAC_FIELD, signRecord } from "../hmac/hmac.js";
|
||||
import {
|
||||
createStdoutAuditInstruments,
|
||||
type StdoutAuditInstruments,
|
||||
} from "../instruments/instruments.js";
|
||||
import {
|
||||
resolveStdoutAuditScalarOptions,
|
||||
type StdoutAuditScalarOptions,
|
||||
} from "../options/options.js";
|
||||
import { type AuditRecord, parseAuditRecord } from "../schema/schema.js";
|
||||
|
||||
/**
|
||||
* Minimal one-line writer the sink writes to. `write` MAY be sync or
|
||||
* async; sync writers (e.g. `process.stdout.write`) are awaited
|
||||
* trivially. The sink appends a terminating `\n` so callers MUST NOT
|
||||
* include one in the supplied string.
|
||||
*/
|
||||
export interface LineWriter {
|
||||
write(line: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options accepted by {@link createStdoutAuditSink}.
|
||||
*/
|
||||
export interface StdoutAuditSinkOptions extends StdoutAuditScalarOptions {
|
||||
/** HMAC-SHA-256 key. Required. */
|
||||
readonly hmacKey: Uint8Array;
|
||||
/** Writer. Defaults to a `process.stdout`-backed adapter. */
|
||||
readonly writer?: LineWriter;
|
||||
/** Wall-clock used for the warning throttle. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
/** Timer pair used to bound queue waits. Defaults to global `setTimeout`/`clearTimeout`. */
|
||||
readonly timers?: TimersLike;
|
||||
/** Logger for drop warnings. Defaults to a no-op. */
|
||||
readonly logger?: LoggerLike;
|
||||
/** OTel instruments. Defaults to {@link createStdoutAuditInstruments}'s global-provider bundle. */
|
||||
readonly instruments?: StdoutAuditInstruments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public surface of the stdout audit sink. Extends the
|
||||
* `RequestAuditSink` contract from `@suaveplan/api-host/request-pipeline`
|
||||
* so the host's request pipeline accepts the instance directly, and
|
||||
* adds a `write` overload taking a canonical `AuditRecord` for
|
||||
* admin-API code paths.
|
||||
*/
|
||||
export interface StdoutAuditSink extends RequestAuditSink {
|
||||
/** Write a canonical {@link AuditRecord}. */
|
||||
write(record: AuditRecord): Promise<void>;
|
||||
/**
|
||||
* Dispose the sink. Pending writes finish before resolution;
|
||||
* subsequent calls to `emit` / `write` are no-ops and increment
|
||||
* `dropped_total{reason="closed"}`.
|
||||
*/
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultStdoutWriter: LineWriter = {
|
||||
write(line) {
|
||||
process.stdout.write(line);
|
||||
},
|
||||
};
|
||||
|
||||
const defaultTimers: TimersLike = {
|
||||
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
|
||||
clearTimer: (h) =>
|
||||
clearTimeout(h as unknown as ReturnType<typeof setTimeout>),
|
||||
};
|
||||
|
||||
/**
|
||||
* Construct a stdout audit sink. The sink writes one JSON line per
|
||||
* record with an `_hmac` field, validates each record against the
|
||||
* §12.5 schema, and bounds backpressure by dropping records whose
|
||||
* queue wait exceeds `maxQueueMs`. Drops are NEVER silent — every
|
||||
* drop bumps `api.audit.dropped_total{sink}` and (subject to the
|
||||
* once-per-`warnThrottleMs` throttle) emits a single warning log.
|
||||
*
|
||||
* The factory throws `AUDIT_STDOUT.INVALID_OPTIONS` on a bad option
|
||||
* bag. Runtime `write` / `emit` failures throw
|
||||
* `AUDIT_STDOUT.INVALID_RECORD` (boundary validation),
|
||||
* `AUDIT_STDOUT.QUEUE_FULL` (backpressure), or
|
||||
* `AUDIT_STDOUT.WRITER_FAILED` (writer rejection).
|
||||
*/
|
||||
export function createStdoutAuditSink(
|
||||
options: StdoutAuditSinkOptions
|
||||
): StdoutAuditSink {
|
||||
if (
|
||||
!(options.hmacKey instanceof Uint8Array) ||
|
||||
options.hmacKey.length === 0
|
||||
) {
|
||||
throw new AuditStdoutError(
|
||||
AUDIT_STDOUT_CODES.INVALID_OPTIONS,
|
||||
"error.audit_stdout.invalid_options",
|
||||
{ context: { reason: "hmacKey must be a non-empty Uint8Array" } }
|
||||
);
|
||||
}
|
||||
const {
|
||||
hmacKey,
|
||||
writer = defaultStdoutWriter,
|
||||
clock = Date.now,
|
||||
timers = defaultTimers,
|
||||
logger,
|
||||
instruments = createStdoutAuditInstruments(),
|
||||
...rest
|
||||
} = options;
|
||||
const scalar = resolveStdoutAuditScalarOptions(rest);
|
||||
|
||||
let lastWarnAt = Number.NEGATIVE_INFINITY;
|
||||
let inflight: Promise<void> = Promise.resolve();
|
||||
let closed = false;
|
||||
|
||||
const tryWarn = (reason: string): void => {
|
||||
if (logger === undefined) return;
|
||||
const now = clock();
|
||||
if (now - lastWarnAt >= scalar.warnThrottleMs) {
|
||||
lastWarnAt = now;
|
||||
logger.warn("audit_stdout.dropped", {
|
||||
sink: scalar.sinkLabel,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const bumpDropped = (reason: string): void => {
|
||||
instruments.droppedTotal.add(1, {
|
||||
sink: scalar.sinkLabel,
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for the current in-flight write OR `maxQueueMs`, whichever
|
||||
* comes first. Returns `true` when the slot is free, `false`
|
||||
* when the wait timed out.
|
||||
*/
|
||||
const acquireSlot = (): Promise<boolean> =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const handle = timers.setTimer(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(false);
|
||||
}, scalar.maxQueueMs);
|
||||
// `inflight` is always a `Promise<void>` that resolves
|
||||
// (never rejects) — see the `slot` promise wired into
|
||||
// `inflight` below. We attach a single fulfilment handler.
|
||||
inflight.then(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
timers.clearTimer(handle);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
|
||||
const write = async (record: AuditRecord): Promise<void> => {
|
||||
if (closed) {
|
||||
bumpDropped("closed");
|
||||
tryWarn("closed");
|
||||
return;
|
||||
}
|
||||
const validated = parseAuditRecord(record);
|
||||
const acquired = await acquireSlot();
|
||||
if (!acquired) {
|
||||
bumpDropped("queue_full");
|
||||
tryWarn("queue_full");
|
||||
throw new AuditStdoutError(
|
||||
AUDIT_STDOUT_CODES.QUEUE_FULL,
|
||||
"error.audit_stdout.queue_full",
|
||||
{
|
||||
context: {
|
||||
sink: scalar.sinkLabel,
|
||||
maxQueueMs: scalar.maxQueueMs,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
// Reserve the slot for the duration of this write.
|
||||
let release!: () => void;
|
||||
const slot = new Promise<void>((res) => {
|
||||
release = res;
|
||||
});
|
||||
inflight = slot;
|
||||
|
||||
const tag = signRecord(validated, hmacKey);
|
||||
const line = serialiseLine(validated, tag);
|
||||
try {
|
||||
await Promise.resolve(writer.write(line));
|
||||
instruments.recordsTotal.add(1, {
|
||||
sink: scalar.sinkLabel,
|
||||
type: validated.type,
|
||||
outcome: validated.outcome,
|
||||
});
|
||||
} catch (cause) {
|
||||
bumpDropped("writer_failed");
|
||||
tryWarn("writer_failed");
|
||||
throw new AuditStdoutError(
|
||||
AUDIT_STDOUT_CODES.WRITER_FAILED,
|
||||
"error.audit_stdout.writer_failed",
|
||||
{ cause: cause instanceof Error ? cause : undefined }
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
const emit = (envelope: RequestAuditEnvelope): Promise<void> => {
|
||||
const record = adaptEnvelope(envelope);
|
||||
return write(record);
|
||||
};
|
||||
|
||||
const dispose = async (): Promise<void> => {
|
||||
closed = true;
|
||||
try {
|
||||
await inflight;
|
||||
} catch {
|
||||
// Errors from the prior write surface to their own caller;
|
||||
// dispose just waits for the slot to drain.
|
||||
}
|
||||
};
|
||||
|
||||
return { emit, write, dispose };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt a host `RequestAuditEnvelope` to the §12.5 canonical
|
||||
* `AuditRecord` shape. The host envelope uses camelCase while the
|
||||
* canonical wire-record uses snake_case — this is the single point
|
||||
* the two shapes are reconciled.
|
||||
*/
|
||||
export function adaptEnvelope(envelope: RequestAuditEnvelope): AuditRecord {
|
||||
const base: AuditRecord = {
|
||||
ts: envelope.at,
|
||||
type: `request.${envelope.outcome}`,
|
||||
principal: envelope.principal,
|
||||
tenant: envelope.tenantId,
|
||||
request_id: envelope.requestId,
|
||||
trace_id: envelope.traceId,
|
||||
subject: `${envelope.method} ${envelope.route}`,
|
||||
outcome: envelope.outcome,
|
||||
...(envelope.cause !== undefined ? { cause: envelope.cause } : {}),
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
function serialiseLine(record: AuditRecord, tag: string): string {
|
||||
const ordered: Record<string, unknown> = {
|
||||
ts: record.ts,
|
||||
type: record.type,
|
||||
principal: record.principal,
|
||||
tenant: record.tenant,
|
||||
request_id: record.request_id,
|
||||
trace_id: record.trace_id,
|
||||
subject: record.subject,
|
||||
outcome: record.outcome,
|
||||
};
|
||||
if (record.cause !== undefined) {
|
||||
ordered["cause"] = record.cause;
|
||||
}
|
||||
ordered[HMAC_FIELD] = tag;
|
||||
return `${JSON.stringify(ordered)}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user