@krizaka/audit
Who did what: an append-only trail in its own schema, inside the transaction or beside it, filtered, exported and redacted (Loi 25).
Install
npm install @krizaka/auditAlso published as subpath exports: @krizaka/audit/testkit.
Who did what, for every Krizaka product. The trail is append-only and lives in the product's database, in the Postgres schema audit.
- Two ways to write: inside the caller's transaction (it commits or rolls back with the work), or quietly beside it (a failure is logged, never thrown).
- For the admin: filtered and paginated.
- For Loi 25: exported and redacted.
- Retention: purged after a retention the admin sets.
There is no database client and no wall clock: you pass a Query and a Clock.
pnpm add @krizaka/audit @krizaka/dbAdd its schema to your migrations
The schema is embedded through @krizaka/db, in your own sequence:
import { emitMigration } from "@krizaka/db";
import { auditSchema } from "@krizaka/audit";
// commit the output as one of your migrations (drizzle-kit `generate --custom`, or your list)
emitMigration(auditSchema, 1, { tables: { owner: "accounts" } });The table is audit.entry. It holds the time, the actor (references your accounts, ON DELETE SET NULL), a readable label, the dotted action (billing.checkout_started), an optional target, the detail (jsonb) and the IP.
Use
import { createAudit } from "@krizaka/audit";
import { transaction } from "@krizaka/db";
const audit = createAudit({ clock: { now: () => new Date() }, onError: (e) => logger.warn(e) });
// Inside a unit of work: the entry exists only if the work commits.
await transaction(connect, async (tx) => {
await suspendUser(tx, userId);
await audit.record(tx, { actorId: admin.id, actorLabel: admin.name, action: "admin.user_suspended", target: { type: "account", id: userId } });
});
// Beside it: never fails the request.
await audit.recordQuietly(query, { actorId: null, actorLabel: "stripe webhook", action: "billing.event_ignored", detail: { type } });
// The admin's view: newest first, filtered, paged by cursor.
const page = await audit.list(query, { actionPrefix: "admin.", limit: 50 });
const next = await audit.list(query, { actionPrefix: "admin.", limit: 50, before: page.at(-1)?.id });-
Loi 25:
exportFor(query, accountId)gives the person's entries;redactActor(tx, accountId)keeps the facts and removes the person: the id and IP become null, and the label becomes « erased ».
Put as little personal data in
detailas the action needs. -
Retention: register
auditSettingsin your@krizaka/settingsregistry (audit.retentionDays: default 1095, 30–3650 days). Your job then callsaudit.purge(tx, { retentionDays }). -
Input from a request is checked the same way for every store: a page size must be a positive integer (capped at 200), a cursor must be an entry id, an IP must be an address, and a retention must be within the setting's bounds (30–3650 days), so a bad value never purges the whole trail. Each refusal is an
AuditErrorwith a stable code (AUDIT_BAD_FILTER,AUDIT_BAD_IP,AUDIT_BAD_RETENTION…). -
A fixed list of actions:
createAudit({ allowedActions: [...] })refuses anything else (AUDIT_UNKNOWN_ACTION). -
Another store runs the contract kit:
auditStoreContractfrom@krizaka/audit/testkit.memoryAuditStore()is the reference.
Exports
The 23 names the package exports, read from its types.
| Name | Kind | What it is |
|---|---|---|
Audit | type | The trail as a product uses it. |
AUDIT_CODE | constant | |
AuditCode | type | |
AuditDeps | type | |
AuditEntry | type | An entry as read back: its id (a cursor too) and everything written. |
AuditEntryInput | type | What a product records. |
AuditError | class | |
AuditFilters | type | How the admin filters the trail. |
AuditInsert | type | What a store writes: the input, flattened, with its time. |
auditSchema | constant | The tables of « audit », in their own Postgres schema (ADR-001 §3). |
auditSettings | constant | The settings of « audit », a fragment the product adds to its registry: the admin sets how long the trail is kept, and the product's job calls purge with it. |
AuditStore | type | Where the trail lives. |
checkFilters | function | Checks filters that may come from a request: a page size that is a positive integer, a cursor that is an entry id. |
checkRetention | function | Checks a retention against the setting's bounds, so a wrong value can never purge the whole trail. |
Clock | type | |
createAudit | function | The audit trail of a product. |
dbAuditStore | function | The PostgreSQL store, on <schema>.entry (auditSchema), over the product's query: a pool, or an open transaction. |
matches | function | Whether an entry passes the admin's filters (the same semantics as the database store). |
memoryAuditStore | function | An in-memory trail, for unit tests and demos. |
PAGE | constant | The size of a page of the trail, for the admin. |
pageSize | function | The page size a store uses: the caller's, capped at PAGE.max, or PAGE.default. |
Query | type | |
toInsert | function | Checks an entry against the trail's rules and the product's list of actions, and flattens it for a store. |
@krizaka/audit/testkit
| Name | Kind | What it is |
|---|---|---|
auditStoreContract | function | What every AuditStore adapter must do: the same behaviour behind the same port. |
Package
| Version on npm | Not on npm yet |
| Licence | Apache-2.0 |
| Node.js | >=22 |
| Dependencies | @krizaka/db workspace:^, @krizaka/settings workspace:^, date-fns ^4.4.0 |
| Peer dependencies | vitest >=4, zod >=3.23 |
| Source | audit |