AbsoluteJS

Audit

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.

#Quick Start

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.

TS
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 evidence flow

Agent and handoff helpers preserve operational attribution while excluding sensitive prompt and payload content by default.

  1. Observe
    Receive a typed agent-runtime or handoff observer event.
  2. Normalize
    Keep signed discovery identity, delegation, lifecycle, effects, budget, operation, and outcome.
  3. Minimize
    Exclude goals, prompts, outputs, checkpoints, user ids, evidence messages, references, and raw provider payloads.
  4. Record
    Append a stable agent.run.*, agent.step.*, or handoff.* event through every configured sink.

#Event Shape

Open by design — no enforced enum on kind. audit.append({ kind, actor?, target?, metadata? }) fills in at and fans out to every sink concurrently.

FieldTypeDescription
atnumberEmission timestamp (Date.now()), filled in automatically by append().
kindstringEvent name. Convention is "<source>.<event>" (e.g. 'auth.login', 'queue.job.failed') so a kind filter matches a whole source.
actor?stringWho caused the event — a user id, or 'system'.
target?stringWhat was acted on — typically a resource id.
metadata?Record<string, unknown>Arbitrary structured context for the event.
Keep secrets out of metadata
Don't stuff secrets into metadata — pipe values through @absolutejs/secrets redact() first.

#Sinks

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.

SinkPackageWhat it does
memorySink({ max })@absolutejs/auditIn-process FIFO (bounded by max). Implements list() — useful for tests and an in-process tail.
consoleSink({ pretty })@absolutejs/auditForwards events to stdout / stderr as JSON.
createPostgresAuditSink({ sql })@absolutejs/audit-postgresPostgres 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-s3Buffered JSONL writes to AWS S3, Cloudflare R2, Backblaze B2, or MinIO. Time-sortable object keys; WORM-bucket-friendly for compliance retention.
auditElysia(...)@absolutejs/audit-elysiaElysia 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.

#Hash-chain Integrity

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).

TS
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.

#Live-Wire Helpers

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.

TS
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.

#Metrics & Close

audit.metrics() returns cumulative counters with a per-sink error breakdown:

CounterTypeDescription
appendednumberTotal successful append() calls.
appendErrorsnumberCalls where at least one sink threw.
sinkErrorsRecord<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.

#Testing

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.

TS
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);

Continue toward an outcome

These playbooks show where this package fits, how to verify the combined system, and what changes before production.

Current package surface

What ships today

@absolutejs/auditv0.2.3 · betaObservabilitynpmSource
3entry points32symbols

Import surface · click to copy

30 symbols
AuditEventtypePermalinkSource

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.

TS
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>;
};
Exported from @absolutejs/audit

Outcomes

What you can build

Overview

Cross-surface audit-event substrate for the AbsoluteJS ecosystem.

Design

Open-ended event shape

Hardening checklist

Production guidance

Make every external boundary explicitPin the deployed @absolutejs/audit version, replace example or memory-backed dependencies with durable implementations, bound external calls, protect credentials, and emit enough evidence to retry or recover safely.

Follow in order

Troubleshooting path

1
Trace from the first failed boundary
Reproduce the smallest canonical @absolutejs/audit example, confirm the supported entry point and version in the API explorer, then inspect the first boundary that did not produce its documented result.