Add Audit

pithy add audit

No prerequisites, no secrets, no account. It is one of the few capabilities in the kit that composes on its own.

What lands in your repo

apps/<worker>/pithy.config.ts gains the registration:

audit({
  database: "DB",
  basePath: "/audit",
}),

apps/<worker>/wrangler.jsonc gains one binding, in every environment stanza: the DB it writes to. If your project already composes anything else, that binding is already there and nothing changes.

What runs

One migration, creating one table: pithy_audit_events.

It is one Zod object — the whole table, with every field described. The app shape and the SQLite row shape are derived from the same definition, and every conversion between them goes through a codec.

The two config options

OptionDefaultWhat it decides
databaseDBThe D1 binding the table and its migrations target. KV is not an option — an audit log is a query workload and KV is get-by-key only
basePath/auditWhere the control-plane routes that read the trail mount. Reads only: the trail is append-only and this surface never writes

Point database at a separate binding if you want the trail in its own D1. Two Workers that both declare that binding still share one database, which is what lets a multi-Worker project keep one trail.

The run that records nothing

The first pithy add audit emits no capability/added event, and cannot: nothing can audit-log its own installation. Every pithy add after it does — at info severity, on success and on failure alike, when Cloudflare credentials resolve.

Nothing else has to change

This is the part worth stating, because it is unusual. No capability needs rewiring, and no route needs editing.

The event contract lives in @pithy-sh/core, so every capability already calls c.var.emit(...). Before you composed audit, that call resolved to a no-op recorder. After, it resolves to this package. The capabilities that were already emitting start writing rows immediately:

CapabilityRecords
authSign-in, token refresh, device revocation
secretsEvery set, rotation and removal — naming the secret and the environments reached, never the value
paymentsEntitlement grants and revocations
The CLICapability installs and removals, deploys, schema resets, token mints, feature teardowns

The consequence: composing audit late gives you a trail that starts today. There is no back-fill, because there is nothing to back-fill from.

Emitting your own events

One call, from any route:

await c.var.emit({
  action: "app/report_exported",
  outcome: "success",
  actorType: "user",
  actorId: c.var.auth.userId,
  tenant: organizationId,
});

action is namespaced by convention — your own capability’s name as the domain, so your events and the kit’s cannot collide. outcome is recorded truthfully on failure too: an attempt that was refused is often the row you most want.

The project, environment and Worker are stamped for you by the recorder, from that Worker’s own vars. You cannot set them, which is what makes them trustworthy.

Reading the trail

Two ways, and they are for different people.

SQL. The table is in your database, so it joins against your own tables with no export step. A staff member’s id resolves to a name because your users table is in the same query.

The control-plane surface. Two scopes — read the events, and read one event’s detail — both default-denied until a management client is connected and granted them. Reading an audit trail is itself a privileged act, and it is audited: a management client walking your event log leaves rows saying so.

Check it worked

pithy doctor reports audit under the Worker’s health. Sign in locally and the first row appears — pithy dev, then a query against pithy_audit_events, is the whole verification.

ESC