Overview
Cross-surface audit-event substrate for the AbsoluteJS ecosystem.
An append-only, hash-chain tamper-evident audit log for your app. Many sinks behind one fan-out, optional per-writer integrity (SHA-256 or HMAC-SHA256), live-wire helpers for runtime / queue / secrets / sync events, and an open kind: string shape so any source can emit anything without an enforced enum.
createAudit({ sinks: [...] }) returns a handle with append() / metrics() / flush() / close(). Every append fans out to every sink concurrently; one sink throwing doesn't cancel the others.
import { createAudit, memorySink } from '@absolutejs/audit';
const audit = createAudit({
sinks: [memorySink()],
});
await audit.append({
kind: 'auth.login',
actor: 'user_123',
target: 'session_abc',
metadata: { ip: req.ip },
});
// Many sinks, one fan-out:
const production = createAudit({
sinks: [
memorySink(), // in-process for tests
consoleSink(), // stdout for dev
createPostgresAuditSink({ sql }), // @absolutejs/audit-postgres
],
});Agent and handoff helpers preserve operational attribution while excluding sensitive prompt and payload content by default.
Open by design — no enforced enum on kind. audit.append({ kind, actor?, target?, metadata? }) fills in at and fans out to every sink concurrently.
| Field | Type | Description |
|---|---|---|
at | number | Emission timestamp (Date.now()), filled in automatically by append(). |
kind | string | Event name. Convention is "<source>.<event>" (e.g. 'auth.login', 'queue.job.failed') so a kind filter matches a whole source. |
actor? | string | Who caused the event — a user id, or 'system'. |
target? | string | What was acted on — typically a resource id. |
metadata? | Record<string, unknown> | Arbitrary structured context for the event. |
metadata — pipe values through @absolutejs/secrets redact() first.An AuditSink is anything that implements append(); list / prune / flush / close are optional for stores that support them. Two sinks ship in core, with sibling packages for Postgres, S3-compatible object storage, and Elysia request tracing.
| Sink | Package | What it does |
|---|---|---|
memorySink({ max }) | @absolutejs/audit | In-process FIFO (bounded by max). Implements list() — useful for tests and an in-process tail. |
consoleSink({ pretty }) | @absolutejs/audit | Forwards events to stdout / stderr as JSON. |
createPostgresAuditSink({ sql }) | @absolutejs/audit-postgres | Postgres sink with list + prune + flush. Accepts any postgres-js-compatible tag template (porsager/postgres or Neon serverless). Lazy schema, jsonb metadata, indexed on at / kind / actor. |
createS3AuditSink(...) | @absolutejs/audit-s3 | Buffered JSONL writes to AWS S3, Cloudflare R2, Backblaze B2, or MinIO. Time-sortable object keys; WORM-bucket-friendly for compliance retention. |
auditElysia(...) | @absolutejs/audit-elysia | Elysia plugin emitting one structured audit event per request — success AND error paths — with optional correlation to the active OTel trace id. |
Custom sinks just implement AuditSink — append a row to your warehouse, forward to Splunk, post to a webhook. A sink that only forwards doesn't need list / prune; only stores do.
withIntegrity(sink) decorates any sink with a per-writer hash chain. Each appended event carries a metadata.__integrity link ({ hash, previousHash, writerId }) so verifyChain(events) detects modification, removal, or reordering. SHA-256 by default; pass secret for HMAC-SHA256 (only secret holders can forge a valid chain).
import {
createAudit,
memorySink,
verifyChain,
withIntegrity,
} from '@absolutejs/audit';
const sink = memorySink();
const audit = createAudit({
sinks: [withIntegrity(sink, {
secret: process.env.AUDIT_HMAC, // optional HMAC; sha256 without
writerId: 'shard-A', // stable id to resume across restarts
})],
});
// Later, an auditor checks the chain:
const result = await verifyChain(await sink.list());
if (!result.ok) {
console.error('Broken at index', result.brokenAt, ':', result.reason);
}Multi-writer safe — each withIntegrity() call defaults to a random writerId so concurrent writers and redeploys each own a self-contained sub-chain. Pass a stable writerId to resume across restarts; the chain seeds from the most recent event matching the writer (overridable via loadWriterHead). Concurrent appends within a writer are serialized so two callers can't both link to the same previousHash — that's the chain's correctness contract, not an optimization.
Bundled helpers return plain callbacks you wire into the source package's existing listener API. Audit doesn't reach into any package's lifecycle — your app stays in control. The inputs are narrow duck types, so audit takes no peer deps on the packages it can observe.
import {
recordQueueError,
recordRuntimeTransition,
recordSecretRotation,
recordSyncActivity,
} from '@absolutejs/audit';
// @absolutejs/runtime
createRuntime({
onTransition: recordRuntimeTransition(audit),
});
// → 'runtime.spawn', 'runtime.exit', 'runtime.crash', …
// @absolutejs/queue
createQueueWorker({
onError: recordQueueError(audit),
});
// → 'queue.error' with jobKind + attempts in metadata
// @absolutejs/secrets
broker.onRotate(name, recordSecretRotation(audit));
// → 'secrets.rotated' (fingerprint only — value NEVER recorded)
// @absolutejs/sync
engine.onActivity(recordSyncActivity(audit));
// → 'sync.change.insert', 'sync.mutation.ok', 'sync.batch.error',
// 'sync.retry', …Each helper is a one-liner convenience for the common shape. If you need custom event mapping, write your own listener and call audit.append() directly.
audit.metrics() returns cumulative counters with a per-sink error breakdown:
| Counter | Type | Description |
|---|---|---|
appended | number | Total successful append() calls. |
appendErrors | number | Calls where at least one sink threw. |
sinkErrors | Record<string, number> | Per-sink error counts, keyed by sink.name or 'sink-<index>'. |
Sink errors are NOT fatal — every sink still receives the event; errors fire onError and bump the per-sink counter. The default onError is console.warn; route it to a side-channel pipeline if you want sink failures visible separately (the audit log itself would loop). flush() flushes every sink that implements it — useful before shutdown so batched writers commit. close() flushes then closes every sink; after close(), append() throws AuditClosedError.
No mocks — memorySink() implements list() so tests assert exactly what was appended, without coupling to a particular store.verifyChain over a tampered event surfaces brokenAt for tamper-evidence tests.
import {
createAudit, memorySink, verifyChain, withIntegrity
} from '@absolutejs/audit';
const sink = memorySink();
const audit = createAudit({ sinks: [withIntegrity(sink)] });
await audit.append({ kind: 'auth.login', actor: 'user_1' });
await audit.append({ kind: 'auth.login', actor: 'user_2' });
const events = await sink.list();
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ kind: 'auth.login', actor: 'user_1' });
// Integrity check — a tampered event triggers brokenAt:
const chain = await verifyChain(events);
expect(chain.ok).toBe(true);
events[0].actor = 'attacker'; // tamper
const tampered = await verifyChain(events);
expect(tampered.ok).toBe(false);
expect(tampered.brokenAt).toBe(0);These playbooks show where this package fits, how to verify the combined system, and what changes before production.
Current package surface
Import surface · click to copy
A single audit event. kind is a namespaced string ("sync.insert", "auth.login", etc.) — open-ended on purpose so any package can emit any event type without coordinating with @absolutejs/audit. The structured fields cover the cross-surface essentials; everything else goes in metadata.
type AuditEvent = {
/** Wall-clock at emission (`Date.now()`). */
at: number;
/**
* Namespaced event identifier. Convention: `"<source>.<event>"`,
* e.g. `"runtime.spawn"`, `"sync.delete"`, `"queue.job.failed"`.
* Free-form — no enforced enum.
*/
kind: string;
/**
* Who caused the event — `userId`, system component, or omitted
* for events with no semantic actor (`"runtime.observation"`).
*/
actor?: string;
/**
* What the event was done TO — `resourceId`, `tenantId`, table
* name, etc.
*/
target?: string;
/** Free-form extra context. Avoid stuffing secrets here — pair with
* `@absolutejs/secrets` `redact()` first. */
metadata?: Record<string, unknown>;
};@absolutejs/auditOutcomes
Cross-surface audit-event substrate for the AbsoluteJS ecosystem.
Open-ended event shape
Hardening checklist
Follow in order