@setu-ts/audit-plugin

Immutable audit-trail logging for Setu-TS. Registers an IAuditLogger under CAPABILITIES.AUDIT, backed by a pluggable storage backend. Each entry is stamped with an internally assigned id (runtime.uuid()) and wall-clock timestamp (runtime.now()), deep-frozen for immutability, then appended to the selected storage.

Storage backends:

Backendstorage idPersistenceDependency
MemoryAuditStoragememoryin-process array (non-durable)none (every runtime, incl. Cloudflare Workers)
LogAuditStoragelogrouted to the resolved ILoggerthe logger capability (LoggerPlugin)
DatabaseAuditStoragedatabaserows via an injected IAuditDbClientan injected client facade (inject-only, no driver)
FileAuditStoragefileJSONL via runtime.fs (read-modify-write)writable runtime.fs (Node/Deno/Bun only)

The default backend is memory — zero-dependency and portable, but non-durable: it is lost on restart. Select log, database, or file for production. No database driver is ever a hard dependency; the database backend takes an injected client facade and never touches the database capability token.

Installation

import { AuditPlugin } from '@setu-ts/audit-plugin';

The memory, log, and file backends need nothing beyond the framework. The database backend needs an injected client that adapts your driver to the IAuditDbClient shape (insert(table, row) / select(table, criteria?)).

Usage

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { AuditPlugin } from '@setu-ts/audit-plugin';
import { CAPABILITIES } from '@setu-ts/common';
import type { IAuditLogger } from '@setu-ts/common';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    // In-memory (default — non-durable)
    AuditPlugin(),
  ],
});
await app.start();

const audit = app.services.get<IAuditLogger>(CAPABILITIES.AUDIT);
await audit.log({
  action: 'user.delete',
  resource: 'user',
  resourceId: '123',
  userId: currentUser.id,
  result: 'success',
  before: { active: true },
  after: { active: false },
});

IAuditLogger is write-only (like ILogger). AuditEntry is the write shape and carries no id/timestamp; those are assigned internally on the stored record, which is immutable once written.

Backend configuration

// Route audit records through the resolved logger.
AuditPlugin({ storage: 'log', options: { level: 'info' } });

// Persist to a database via an injected client (inject-only).
AuditPlugin({ storage: 'database', options: { client: myDbClient, table: 'audit_logs' } });

// Append JSONL to a file (Node/Deno/Bun only — requires runtime.fs).
AuditPlugin({ storage: 'file', options: { path: './audit.log' } });
OptionBackendDefaultNotes
storage—'memory''memory' | 'log' | 'database' | 'file'. Unknown ids throw.
options.levellog'info''info' | 'warn' | 'error'.
options.loggerlogctx.loggerInjected ILogger; throws at registration when neither is present.
options.clientdatabase—Injected IAuditDbClient; required (throws when absent).
options.tabledatabase'audit_logs'Table for insert/select.
options.pathfile'./audit.log'Throws at registration when runtime.fs is absent (Workers/edge).

Database row shape

The default table is audit_logs. Supply an IAuditDbClient that preserves this row shape; before, after, and metadata are JSON strings, not database JSON objects. A jsonb column decoded to an object is read as undefined. timestamp is epoch milliseconds:

ColumnPostgreSQL typeValue
idtext PRIMARY KEYAssigned UUID
timestampbigint NOT NULLEpoch milliseconds
action, resourcetext NOT NULLEntry action and resource
resource_id, user_idtextOptional identifiers, otherwise null
resulttext NOT NULL'success' or 'failure'
before, after, metadatatextJSON-encoded objects, otherwise null
CREATE TABLE audit_logs (
  id text PRIMARY KEY,
  timestamp bigint NOT NULL,
  action text NOT NULL,
  resource text NOT NULL,
  resource_id text,
  user_id text,
  result text NOT NULL CHECK (result IN ('success', 'failure')),
  before text,
  after text,
  metadata text
);

Reading the trail

IAuditLogger has no read method. An owner constructs the exported storage class over the same client and table used by the plugin, then calls query(criteria):

import { AuditPlugin, DatabaseAuditStorage, type IAuditDbClient } from '@setu-ts/audit-plugin';

app.register(
  AuditPlugin({ storage: 'database', options: { client: myDbClient, table: 'audit_logs' } }),
);

export function readTrail(client: IAuditDbClient) {
  const reader = new DatabaseAuditStorage({ client, table: 'audit_logs' });
  return reader.query({ resource: 'order', result: 'success', limit: 100 });
}

Only equality fields (action, resource, result, userId, resourceId) reach select. from/to are inclusive filters applied in memory over every matched row; there is no offset or paging. limit keeps the newest N records, returned in ascending timestamp order. For a large trail, query the table directly so the database can bound the scan.

Health

Registers an audit health indicator reporting { storage, reachable }. It carries BOTH signals: isReady() is lifecycle (constructed and accepting writes), and a cached, bounded probe is reachability (the sink answers right now).

BackendProbeReports
memorynone needed — an in-process arrayreachable: true
lognone needed — the sink is the resolved in-process ILoggerreachable: true
databaseselect on the primary key against a sentinel that matches no rowtrue / false
filethe last append’s outcome, then stat of the target directorytrue / false

A ready backend whose sink does not answer is down. A backend that cannot probe reports reachable: 'unknown' — never a falsely affirmative true. Outcomes are cached for 5 s and each probe is bounded at 2 s on the runtime’s own clock and timers, so scraping /health never turns into sink load.

The database probe READS and never writes: an insert would put a fabricated record into the trail this plugin exists to keep trustworthy. The file probe does not write either, for the same reason — which leaves one gap it is honest about: a directory that is readable but not writable reports true until the first append proves otherwise, and that is why the append outcome is tracked.

Runtime portability

  • memory and log run on every target, including Cloudflare Workers.
  • file requires a writable runtime.fs; it throws at registration on runtimes without one (Workers/edge). The committed IFileSystem has no native append, so writes are read-modify-write and concurrent appends are serialized; on shutdown the plugin’s onClose drains any in-flight write. The target file’s parent directory is created recursively on first write, so a configured path in a not-yet-existing directory (e.g. ./var/log/audit.log) does not fail with ENOENT.
  • database is inject-only — there is no canonical SQL driver to lazy-load. Equality filters (action/resource/result/userId/resourceId) are delegated to the client’s select WHERE; time-range (from/to), ordering, and limit are applied in-process.

Immutability

Every stored record is deep-frozen — including nested before/after/metadata — so it cannot be mutated after it is written, and records reconstructed on read (database/file) are frozen too.

Redaction

Pass redaction as a RedactionPolicy or IRedactionService to filter before, after, and metadata before an entry is frozen and stored. Omitting the option preserves the existing audit record shape and values.

License

MIT

Exports

ExportKind
AuditPluginfunction
AuditServiceclass
DatabaseAuditStorageclass
FileAuditStorageclass
LogAuditStorageclass
MemoryAuditStorageclass
AuditEntryinterface
AuditPluginOptionsinterface
AuditQueryinterface
AuditStorageOptionsinterface
IAuditDbClientinterface
IAuditLoggerinterface
StoredAuditEntryinterface
AuditStorageTypetype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

AuditPlugin() (@setu-ts/audit-plugin)

Provides an immutable audit trail: registers an IAuditLogger under CAPABILITIES.AUDIT, backed by a pluggable storage backend. log() stamps each entry with an internally assigned id (runtime.uuid()) and wall-clock timestamp (runtime.now()), deep-freezes the record (immutability), then appends it to storage. The default backend is 'memory' (zero-dependency, runs on every runtime including Cloudflare Workers) — non-durable, so production should select 'log', 'database', or 'file'. No database driver is a hard dependency: the 'database' backend takes an injected client facade (IAuditDbClient), never the database capability token.

Registration

import { AuditPlugin } from '@setu-ts/audit-plugin';

// In-memory (default — non-durable)
app.register(AuditPlugin());

// Route records through the resolved logger
app.register(AuditPlugin({ storage: 'log', options: { level: 'info' } }));

// Persist to a database via an injected client (inject-only)
app.register(
  AuditPlugin({ storage: 'database', options: { client: myDbClient, table: 'audit_logs' } }),
);

// Append JSONL to a file (Node/Deno/Bun only — requires runtime.fs)
app.register(AuditPlugin({ storage: 'file', options: { path: './audit.log' } }));

Usage

import { CAPABILITIES } from '@setu-ts/common';
import type { IAuditLogger } from '@setu-ts/common';

const audit = ctx.services.get<IAuditLogger>(CAPABILITIES.AUDIT);
await audit.log({
  action: 'user.delete',
  resource: 'user',
  resourceId: '123',
  userId: ctx.request.user?.id,
  result: 'success',
  before: { active: true },
  after: { active: false },
});

IAuditLogger is write-only (like ILogger); AuditEntry is the write shape and carries no id/timestamp. Stored records add both internally and are immutable once written. Retrieval is not part of the public capability this milestone.

Options

OptionBackendDescription
storage—'memory' (default), 'log', 'database', 'file'. Unknown values throw at registration.
options.levellogLogger method to emit at: 'info' (default), 'warn', or 'error'.
options.loggerlogInjected ILogger overriding ctx.logger (throws at registration when neither is present).
options.clientdatabaseInjected IAuditDbClient; required for 'database' (throws when absent).
options.tabledatabaseTable name for insert/select; default 'audit_logs'.
options.pathfileJSONL file path; default './audit.log' (throws at registration when runtime.fs is absent).

Exports

  • AuditPlugin(options?) — plugin factory.
  • AuditService — the IAuditLogger implementation (stamps id/timestamp, deep-freezes, delegates to storage).
  • MemoryAuditStorage, LogAuditStorage, DatabaseAuditStorage, FileAuditStorage — storage backend classes (exported for direct construction/injection).
  • AuditPluginOptions, AuditStorageType, AuditStorageOptions — option types.
  • IAuditDbClient — structural database client facade for the 'database' backend (insert(table, row) / select(table, criteria?)).
  • IAuditLogger, AuditEntry — re-exported from @setu-ts/common.
  • StoredAuditEntry, AuditQuery — the return and parameter types of the exported storage classes’ query members. Exported so those signatures are nameable by a consumer (the M52c NormalizedQuery lesson); the read path belongs to the storages, not to IAuditLogger.

Notes

  • The 'memory' backend is non-durable (in-process array, lost on restart). It is the zero-dependency default so audit works on every runtime out of the box; select a durable backend for production.
  • The 'log' backend’s query() returns [] — the log sink is the durable trail; read audit records through the logging backend, not this object.
  • The 'file' backend uses read-modify-write over runtime.fs (the committed IFileSystem has no native append) and serializes concurrent appends; on shutdown the plugin’s onClose drains any in-flight write. The target file’s parent directory is created recursively on first write, so a path in a not-yet-existing directory does not fail with ENOENT.
  • The 'database' backend delegates equality filtering (action/resource/result/userId/ resourceId) to the injected client’s select WHERE; time-range (from/to), ordering, and limit are applied in-process.

Agent card · TypeDoc API reference