Skip to the content
Krizaka TS
Krizaka TS

@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

bashTerminal
npm install @krizaka/audit

Also 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/db

Add 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 detail as the action needs.

  • Retention: register auditSettings in your @krizaka/settings registry (audit.retentionDays: default 1095, 30–3650 days). Your job then calls audit.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 AuditError with 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: auditStoreContract from @krizaka/audit/testkit. memoryAuditStore() is the reference.

Exports

The 23 names the package exports, read from its types.

NameKindWhat it is
AudittypeThe trail as a product uses it.
AUDIT_CODEconstant
AuditCodetype
AuditDepstype
AuditEntrytypeAn entry as read back: its id (a cursor too) and everything written.
AuditEntryInputtypeWhat a product records.
AuditErrorclass
AuditFilterstypeHow the admin filters the trail.
AuditInserttypeWhat a store writes: the input, flattened, with its time.
auditSchemaconstantThe tables of « audit », in their own Postgres schema (ADR-001 §3).
auditSettingsconstantThe 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.
AuditStoretypeWhere the trail lives.
checkFiltersfunctionChecks filters that may come from a request: a page size that is a positive integer, a cursor that is an entry id.
checkRetentionfunctionChecks a retention against the setting's bounds, so a wrong value can never purge the whole trail.
Clocktype
createAuditfunctionThe audit trail of a product.
dbAuditStorefunctionThe PostgreSQL store, on <schema>.entry (auditSchema), over the product's query: a pool, or an open transaction.
matchesfunctionWhether an entry passes the admin's filters (the same semantics as the database store).
memoryAuditStorefunctionAn in-memory trail, for unit tests and demos.
PAGEconstantThe size of a page of the trail, for the admin.
pageSizefunctionThe page size a store uses: the caller's, capped at PAGE.max, or PAGE.default.
Querytype
toInsertfunctionChecks an entry against the trail's rules and the product's list of actions, and flattens it for a store.

@krizaka/audit/testkit

NameKindWhat it is
auditStoreContractfunctionWhat every AuditStore adapter must do: the same behaviour behind the same port.

Package

Version on npmNot on npm yet
LicenceApache-2.0
Node.js>=22
Dependencies@krizaka/db workspace:^, @krizaka/settings workspace:^, date-fns ^4.4.0
Peer dependenciesvitest >=4, zod >=3.23
Sourceaudit

On this page