@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:
| Backend | storage id | Persistence | Dependency |
|---|---|---|---|
MemoryAuditStorage | memory | in-process array (non-durable) | none (every runtime, incl. Cloudflare Workers) |
LogAuditStorage | log | routed to the resolved ILogger | the logger capability (LoggerPlugin) |
DatabaseAuditStorage | database | rows via an injected IAuditDbClient | an injected client facade (inject-only, no driver) |
FileAuditStorage | file | JSONL 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' } });
| Option | Backend | Default | Notes |
|---|---|---|---|
storage | — | 'memory' | 'memory' | 'log' | 'database' | 'file'. Unknown ids throw. |
options.level | log | 'info' | 'info' | 'warn' | 'error'. |
options.logger | log | ctx.logger | Injected ILogger; throws at registration when neither is present. |
options.client | database | — | Injected IAuditDbClient; required (throws when absent). |
options.table | database | 'audit_logs' | Table for insert/select. |
options.path | file | './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:
| Column | PostgreSQL type | Value |
|---|---|---|
id | text PRIMARY KEY | Assigned UUID |
timestamp | bigint NOT NULL | Epoch milliseconds |
action, resource | text NOT NULL | Entry action and resource |
resource_id, user_id | text | Optional identifiers, otherwise null |
result | text NOT NULL | 'success' or 'failure' |
before, after, metadata | text | JSON-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).
| Backend | Probe | Reports |
|---|---|---|
memory | none needed — an in-process array | reachable: true |
log | none needed — the sink is the resolved in-process ILogger | reachable: true |
database | select on the primary key against a sentinel that matches no row | true / false |
file | the last append’s outcome, then stat of the target directory | true / 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
memoryandlogrun on every target, including Cloudflare Workers.filerequires a writableruntime.fs; it throws at registration on runtimes without one (Workers/edge). The committedIFileSystemhas no native append, so writes are read-modify-write and concurrent appends are serialized; on shutdown the plugin’sonClosedrains any in-flight write. The target file’s parent directory is created recursively on first write, so a configuredpathin a not-yet-existing directory (e.g../var/log/audit.log) does not fail withENOENT.databaseis inject-only — there is no canonical SQL driver to lazy-load. Equality filters (action/resource/result/userId/resourceId) are delegated to the client’sselectWHERE; time-range (from/to), ordering, andlimitare 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
| Export | Kind |
|---|---|
AuditPlugin | function |
AuditService | class |
DatabaseAuditStorage | class |
FileAuditStorage | class |
LogAuditStorage | class |
MemoryAuditStorage | class |
AuditEntry | interface |
AuditPluginOptions | interface |
AuditQuery | interface |
AuditStorageOptions | interface |
IAuditDbClient | interface |
IAuditLogger | interface |
StoredAuditEntry | interface |
AuditStorageType | type |
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
| Option | Backend | Description |
|---|---|---|
storage | — | 'memory' (default), 'log', 'database', 'file'. Unknown values throw at registration. |
options.level | log | Logger method to emit at: 'info' (default), 'warn', or 'error'. |
options.logger | log | Injected ILogger overriding ctx.logger (throws at registration when neither is present). |
options.client | database | Injected IAuditDbClient; required for 'database' (throws when absent). |
options.table | database | Table name for insert/select; default 'audit_logs'. |
options.path | file | JSONL file path; default './audit.log' (throws at registration when runtime.fs is absent). |
Exports
AuditPlugin(options?)— plugin factory.AuditService— theIAuditLoggerimplementation (stampsid/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’querymembers. Exported so those signatures are nameable by a consumer (the M52cNormalizedQuerylesson); the read path belongs to the storages, not toIAuditLogger.
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’squery()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 overruntime.fs(the committedIFileSystemhas no native append) and serializes concurrent appends; on shutdown the plugin’sonClosedrains any in-flight write. The target file’s parent directory is created recursively on first write, so apathin a not-yet-existing directory does not fail withENOENT. - The
'database'backend delegates equality filtering (action/resource/result/userId/resourceId) to the injected client’sselectWHERE; time-range (from/to), ordering, andlimitare applied in-process.