audit
Who changed what, per organization. One audit_log row per change, written
in the same db.batch() as the change, so the data and its log commit
together. Optional, each its own subpath: draft/publish revisions for
content (@recranet/flare/audit/revisions) and a log of sent mail
(@recranet/flare/audit/mail). examples/crm uses all but revisions.
Modelled on Recranet Studio (immutable revisions, version_changes with an
actor kind) and Recranet Console (sent_mail, the “User roles changed” log
line). Unlike Studio, every write is logged, not only live-state changes.
Uses void/db and void/schema-d1 (void is a peer dependency), and
nothing from other Flare plugins. It reads the member that
@recranet/flare/access’s requireMember returns when you pass it.
Set up
Section titled “Set up”Add the table to db/schema.ts, then pnpm db:generate:
export { auditLog } from "@recranet/flare/audit/schema"| Column | What |
|---|---|
organization_id |
The tenant. From the checked member, never from input |
actor_id |
The user acted as; null for the system |
actor_kind |
user, api_key, mcp or system |
client_id |
The API key or OAuth client the actor came through |
entity_type, entity_id |
"deal", its id |
label |
The record’s name when it changed, so the log still reads after a delete |
action |
create, update, delete, or your own (publish, role.update) |
changes |
JSON, { field: [before, after] } |
request_id |
c.var.requestId, x-request-id or cf-ray, to tie a request’s rows |
created_at |
Milliseconds |
Indexes cover the organization’s log, one record’s history and one person’s changes, each newest first.
Writing
Section titled “Writing”audit(source, change) returns a Drizzle insert. Put it in the change’s
batch, or await it alone:
import { audit } from "@recranet/flare/audit"
const m = await access.requireMember(c, { deal: ["update"] })const before = await Deals.get(m, id)const [[row]] = await db.batch([ db.update(deals).set(changes).where(and(eq(deals.id, id), eq(deals.organizationId, m.organizationId))).returning(), audit(c, { entity: "deal", id, action: "update", before, after: changes, member: m }),])| Field | What |
|---|---|
entity |
The entity type |
id |
Default after.id ?? before.id |
action |
Free text; the CRM uses create, update, delete, role.update, member.add, member.remove |
before, after |
The record before and after; leave out before on create and after on delete |
changes |
An explicit diff, when before and after don’t say it (a role change). Still redacted |
label |
Default the first of label, name, title, subject |
member |
The member requireMember returned. Its organization wins over the session’s |
What changes holds:
- On create, the fields that were set:
{ title: [null, "Rollout"] }. - On update, the fields that changed. A field
afterdoesn’t have is unchanged, so pass the update’s values, not a merged record. - On delete, the fields that were set:
{ title: ["Rollout", null] }. id,organizationId,createdAtandupdatedAtare skipped (optionignore). Dates become ISO strings. Objects compare by value.
Who acts
Section titled “Who acts”source is the request context, or an AuditScope made once and reused.
From a context:
| Request | actor_kind |
actor_id |
client_id |
|---|---|---|---|
setActor(c, actor) was called |
actor.kind |
actor.id |
actor.clientId |
An x-api-key header with a signed-in user |
api_key |
the key’s user | the session id (Better Auth’s API key plugin makes it the key’s id) |
| A signed-in user | user |
the user | null |
| Nobody signed in | system |
null | null |
The organization is the member’s when given, else the actor’s own
(setActor with organizationId), else the session’s active one. Without
one, audit() throws: an audited write needs a tenant. Call it after
requireMember.
An MCP tool acts as the user who connected the client, in the organization on the token, so it names itself before writing:
setActor(ctx.c, { kind: "mcp", id: ctx.user.id, clientId: ctx.clientId, organizationId: ctx.organizationId })A cron or inbound mail handler has no user. Make a scope:
import { audit, systemScope } from "@recranet/flare/audit"
await audit(systemScope(organizationId), { entity: "contact", action: "create", after: contact })auditScope(c, member) resolves a request’s scope once. The CRM puts it on
the member its requireMember returns, so lib/records.ts can log every
write without the context.
Secrets
Section titled “Secrets”A field whose name contains password, secret, token, apikey,
privatekey, authorization, cookie, credential, totp or
backupcode (case, _ and - ignored) is logged as "[redacted]", at any
depth. A changed secret shows as ["[redacted]", "[redacted]"]: the log
says it changed, never to what. Add your own with
defineAudit({ redact: ["iban", /^ssn$/i] }), or replace the list with
redactDefaults: false.
Options
Section titled “Options”audit is defineAudit() on void/db’s database. For others:
export const audit = defineAudit({ db, redact: ["iban"], ignore: ["id", "updatedAt"], apiKeyHeader: "x-api-key" })Reading
Section titled “Reading”const { items, nextCursor } = await audit.list(m, { entityType: "deal", entityId: id, actorId, actorKind, action, since, until, cursor, limit })Always one organization’s rows (the first argument’s organizationId),
newest first, keyset paged on (created_at, id): pass nextCursor back as
cursor. limit is 1 to 200, default 50.
A record’s history should 404 for a record of another organization before
its log is read: check the record first, as the CRM’s history handler
does.
Revisions
Section titled “Revisions”Draft and publish for content, after Studio’s pages and entries. Every save
is a new, immutable revision. The content row keeps its live fields in its
own columns, plus two pointers: draftRevisionId (the unpublished head, null
without a draft) and liveRevisionId (what the live columns came from).
import { revisionPointers } from "@recranet/flare/audit/revisions"export { revisions } from "@recranet/flare/audit/revisions"
export const pages = sqliteTable("pages", { id: text("id").primaryKey(), organizationId: text("organization_id").notNull(), title: text("title").notNull(), body: text("body").notNull(), ...revisionPointers(),})
// lib/pages.tsexport const Pages = defineRevisions(pages, { entity: "page", fields: ["title", "body"] })Each call takes an AuditScope (auditScope(c, m) or systemScope(org))
and the record’s id, and works only on the scope’s organization:
| Call | What |
|---|---|
head(scope, id) |
What an editor opens: the draft’s data, else the live fields, with its hash and isDraft |
save(scope, id, patch, { baseHash }) |
Merges the patch onto the head and writes a save revision if anything changed (revision: null if not). With baseHash, refuses with CONFLICT when the head moved since |
publish(scope, id) |
A publish revision from the draft (or the live fields), copied onto the row; liveRevisionId points at it and the draft is cleared |
restore(scope, id, revisionId) |
A restore revision copied from an older one, made live. A draft stays |
discard(scope, id) |
Clears the draft pointer; its saves stay in the history |
list(scope, id, { cursor, limit }) |
The revisions, newest first, keyset paged, with isDraft and isLive |
get(scope, id, revisionId) |
One revision of this record |
Errors are RevisionError with code: NOT_FOUND (no such record in the
organization), NO_SUCH_REVISION (not one of its revisions) or CONFLICT.
Publish, restore and discard write an audit_log row in the same batch;
saves don’t, as in Studio, since they aren’t live. Pass audit: false to
skip that, or your own defineAudit().
D1 has no row locks. A save or publish moves the draft pointer only from the
value it read; if it moved in between, the revision (and audit row) is
deleted again and the call throws CONFLICT.
Differences from Studio: no suggestion revisions, no collapsing of runs of saves in the history, no live hash column (the hash of the live fields is computed when needed), and fields hold JSON values (text, numbers, booleans, JSON columns), not Date columns. Revisions are never pruned.
Mail log
Section titled “Mail log”A row per mail sent through void/email: recipients, cc, bcc, subject,
status and Cloudflare’s message ids, never the body. After Console’s
sent_mail.
export { sentMail } from "@recranet/flare/audit/mail"
// lib/mail.tsimport { withMailLog } from "@recranet/flare/audit/mail"import { sendEmail } from "void/email"export const send = withMailLog(sendEmail)
await send({ to, subject, text }, { organizationId, category: "invitation" })The wrapped send answers what sendEmail answers, and rethrows what it
throws (recorded as failed). A row that fails to write is a warning in the
Worker log, never a failed send.
status |
When |
|---|---|
sent |
Every recipient accepted |
partial |
Some recipients refused (error lists them) |
failed |
Refused, or sendEmail threw |
unknown |
OUTCOME_UNKNOWN: it may have gone out |
listSentMail(m, { status, category, cursor, limit }) reads one
organization’s, newest first.
In examples/crm
Section titled “In examples/crm”lib/access.ts:requireMemberreturns the member with its auditscope. A newauditresource withlistonly for owners.lib/records.ts: create, update and delete each go in one batch with their audit row. Inbound mail logs as the system.lib/audit.ts: each row as a sentence with names, never ids (“Stage: Lead → Won”, “Company: Globex”); the activity-log tab; Better AuthorganizationHookslogging joins, removals, role changes and invitations with the signed-in user (getSession()inside the hook). Better Auth writes those itself, so their rows follow in their own write.lib/mail.ts: invitations and task reminders go throughwithMailLog.lib/mcp.ts: the MCP tools’ writes callsetActorfirst, so they log asmcpwith the OAuth client.- An “Activity log” tab on every detail page, for anyone who may read the
record;
GET /api/<records>/:id/history. /audit, for owners: filters (record type, change, person, how) and Older / Newest paging, and a Sent mail view.GET /api/auditandGET /api/audit/mail.
The reminder cron’s claim (reminderSentAt) isn’t logged: it’s bookkeeping,
and the mail log has the send.