Tamper-evident, append-only audit logging for Node: a SHA-256 hash chain (optionally keyed with HMAC), RBAC-aware events, and PII redaction built in. Built to survive an FCA / OCC / OSFI review.
TypeScript-first, with pluggable storage and one-call verification.
import { createAuditLog, MemorySink } from '@pametan/audit-log';
const log = createAuditLog({ sink: new MemorySink() });
await log.append({ action: 'loan.decision', actor: { id: 'u1', role: 'underwriter' }, outcome: 'success' });
const result = await log.verify();
// { valid: true, count: 1, errors: [] }Regulated firms must show who did what, when, and what the system decided — and
prove the record wasn't altered. audit-log writes an append-only trail where
each entry binds the hash of the previous one, so any edit, deletion or
reordering breaks the chain and is caught by verify(). Events are structured
for a reviewer, and PII is stripped before it's ever written.
npm install @pametan/audit-logRequires Node 24+. Ships ESM with bundled type declarations. Depends on
@pametan/pii-redact for redaction.
await log.append({
action: 'payment.captured',
actor: { id: 'u1', role: 'agent' },
resource: { type: 'payment', id: 'pay_123' },
outcome: 'success',
data: { cardNumber: '4111111111111111' }, // redacted before storage -> '[PAN]'
});append() redacts the event, computes its chain hash, and persists it,
resolving to the stored AuditRecord. Appends are serialised internally so the
chain stays linear under concurrency.
const result = await log.verify();
if (!result.valid) console.error('Audit log integrity failure', result.brokenAt, result.errors);verify() streams the records (constant memory) and detects edits, gaps and
reordering, reporting the seq of the first broken record.
// Keyed HMAC: an attacker who can rewrite the store still can't forge hashes
// without the key. verify() needs the same key.
const log = createAuditLog({ sink, hmacKey: process.env.AUDIT_KEY });A hash chain detects modification, reordering and middle deletion — but not end-truncation: removing the most recent records leaves a still-consistent prefix. To catch that, periodically anchor the head somewhere outside the log, and check it:
const head = await log.head(); // { seq, hash } — store this externally
// ...later...
const result = await log.verify(head); // fails if the log no longer reaches that headimport { MemorySink, FileSink } from '@pametan/audit-log';
new MemorySink(); // ephemeral / tests
new FileSink('/var/log/audit.jsonl'); // append-only JSON Lines, streamed on readPostgres (separate entry point so the core has no DB dependency — pass your own
pg pool):
import { Pool } from 'pg';
import { PostgresSink } from '@pametan/audit-log/postgres';
const sink = new PostgresSink(new Pool());
await sink.ensureSchema();
const log = createAuditLog({ sink });Implement the Sink interface for anything else (S3, DynamoDB, Kafka). The
Postgres sink's primary key on seq also coordinates appends across processes.
PII redaction (via @pametan/pii-redact) runs on event.data before storage.
actor, action, resource and outcome are left intact so the trail stays
queryable.
createAuditLog({ sink }); // redact data with defaults
createAuditLog({ sink, redact: { strategy: 'last4' } });
createAuditLog({ sink, redact: false }); // opt out entirely| Export | Description |
|---|---|
createAuditLog(options) |
{ append, verify, head }. |
append(event) |
Redact → chain → persist; resolves to the AuditRecord. |
verify(expectedHead?) |
Validate the chain; pass an anchored head to catch truncation. |
head() |
Current { seq, hash } for external anchoring. |
MemorySink, FileSink |
Built-in sinks. |
PostgresSink (/postgres) |
Postgres-backed sink; bring your own pg. |
computeHash, canonicalStringify |
The hashing primitives. |
Types AuditEvent, AuditRecord, Sink, Head, VerifyResult are exported.
- The chain detects modification, reordering and middle-deletion. It does not
by itself detect end-truncation — anchor the head externally and use
verify(expectedHead)(above). - Appends are serialised in-process; multi-writer setups must coordinate at the
sink (the Postgres
seqprimary key does this). - A safety net, not a guarantee of compliance — pair with sound retention and access controls.
npm install
npm run typecheck
npm test # chain, tamper/gap detection, HMAC, file & postgres sinks, redaction
npm run build # emit dist/Note: this package depends on
@pametan/pii-redact. Until that is published to npm, link it locally for development (npm link @pametan/pii-redactor afile:reference).
Provided as an engineering aid, not legal or compliance advice. MIT licensed —
see LICENSE.
We're Pametan — a specialist fintech/regtech engineering agency working across UK, US and Canadian rails (FCA · CFPB · FCAC). We build the regulated, audited systems this sits inside: end-to-end audit trails, evidence stores, and the controls a reviewer expects.