Skip to content

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.

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.

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 after doesn’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, createdAt and updatedAt are skipped (option ignore). Dates become ISO strings. Objects compare by value.

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.

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.

audit is defineAudit() on void/db’s database. For others:

export const audit = defineAudit({ db, redact: ["iban"], ignore: ["id", "updatedAt"], apiKeyHeader: "x-api-key" })
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.

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).

db/schema.ts
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.ts
export 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.

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.

db/schema.ts
export { sentMail } from "@recranet/flare/audit/mail"
// lib/mail.ts
import { 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.

  • lib/access.ts: requireMember returns the member with its audit scope. A new audit resource with list only 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 Auth organizationHooks logging 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 through withMailLog.
  • lib/mcp.ts: the MCP tools’ writes call setActor first, so they log as mcp with 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/audit and GET /api/audit/mail.

The reminder cron’s claim (reminderSentAt) isn’t logged: it’s bookkeeping, and the mail log has the send.