Instatic Audit Log Implementation and Append-Only Design: A Complete Guide

TLDR: Instatic stores every administrative action in an immutable, append-only audit_events table where rows are never updated or deleted, ensuring a tamper-evident trail of who did what, when, and how.

The CoreBunch/Instatic CMS implements a strict append-only audit log that records state-changing operations across authentication, publishing, user management, and plugin activity. This design enforces a write-only, never-update-or-delete rule on the audit_events table to guarantee an immutable history. According to the CoreBunch/Instatic source code, every entry is backed by a TypeBox schema, protected behind capability checks, and stored with flat metadata to keep queries fast and UI rendering predictable.

Core Components of the Instatic Audit Log

According to the CoreBunch/Instatic source code, the audit system is organized into four tightly integrated layers that span from the database to the admin dashboard.

Type-Safe Action Schema with TypeBox

All possible audit actions are defined in server/repositories/audit.ts as a closed AuditActionSchema literal union using TypeBox. This closed union provides compile-time safety by ensuring that only recognized action strings can be persisted, preventing typo-ridden or arbitrary events from polluting the log.

Repository Layer: createAuditEvent and listAuditEvents

The server/repositories/audit.ts file exposes two primary functions: createAuditEvent for inserting rows and listAuditEvents for fetching them. Every insert receives a fresh nanoid value for the id column, avoiding auto-increment reuse and strengthening the immutability contract. The repository also defines the typed AuditEvent interface that maps directly to the audit_events table columns.

HTTP Handler and Capability Gating

Public access to the log is exposed through a single endpoint implemented in server/handlers/cms/audit.ts. The handler responds to GET /admin/api/cms/audit and requires the caller to hold the audit.read capability via requireCapability. If authorization passes, it returns the event list as JSON; otherwise, access is denied before the database is queried.

Dashboard Widgets and UI Formatters

On the frontend, src/admin/pages/dashboard/widgets/ActivityWidget.tsx consumes the endpoint to display the last ten operational events. For human-readable output, src/admin/pages/users/utils/audit.ts exports formatAuditTitle, which maps raw action strings to curated titles based on the event payload. This separation keeps the API flat while allowing the UI to present rich, contextual descriptions.

Append-Only Design Guarantees

The Instatic audit log is built around hard rules that guarantee tamper resistance.

Rows in audit_events are never updated or deleted once inserted. Each event receives an immutable nanoid identifier at creation time, eliminating sequence gaps or reuse. The metadata_json column stores a flat Record<string, string|number|boolean|null|string[]>, which avoids expensive nested parsing and simplifies UI rendering.

Schema changes are handled exclusively through additive migrations in server/db/migrations-pg.ts and server/db/migrations-sqlite.ts, and the _json suffix follows the project's database-dialect convention. Read access is further gated by the audit.read permission, preventing unauthorized enumeration of administrative history.

How to Write Audit Events in Instatic

Audit events are emitted only after a state-changing operation succeeds, such as publishing a row or installing a plugin. Production code should await the repository call, although best-effort contexts like AI chat streaming may deliberately swallow failures.

The following pattern from docs/features/audit-log.md records a publish event:

import { createAuditEvent } from '@/server/repositories/audit';
import { clientIp } from '@/server/utils/ip';

await createAuditEvent(db, {
  action:      'data.row.publish',
  actorUserId: user.id,
  targetId:    row.id,
  targetType:  'row',
  metadata: {
    tableId:   row.tableId,
    tableSlug: 'posts',
    slug:      row.slug,
    fromStatus: 'draft',
    toStatus:   'published',
  },
  ipAddress: clientIp(req),
  userAgent: req.headers.get('user-agent'),
});

Extending the Audit Action Schema

To track a new domain event, add a literal to the AuditActionSchema union in server/repositories/audit.ts:

// In server/repositories/audit.ts
export const AuditActionSchema = Type.Union([
  // …existing literals
  Type.Literal('my.custom.action'), // ← new action
]);

// Then use it:
await createAuditEvent(db, {
  action: 'my.custom.action',
  // …other required fields
});

How to Read and Render Audit Events

Fetching the log is handled by listAuditEvents(db, limit?), which returns the most recent events and defaults to a limit of 100. The CMS HTTP handler in server/handlers/cms/audit.ts delegates directly to this function:

// server/handlers/cms/audit.ts
export async function handleAuditRoutes(req: Request, db: DbClient) {
  if (new URL(req.url).pathname !== `${CMS_API_PREFIX}/audit`) return null;
  await requireCapability(req, db, 'audit.read');
  if (req.method !== 'GET') return methodNotAllowed();
  return jsonResponse({ events: await listAuditEvents(db) });
}

Formatting Events for the UI

The admin UI translates raw rows into readable text using the formatter in src/admin/pages/users/utils/audit.ts:

// src/admin/pages/users/utils/audit.ts
export function formatAuditTitle(event: AuditEvent) {
  switch (event.action) {
    case 'login.success': return `User ${event.actorLabel} logged in`;
    case 'data.row.publish': return `Published ${event.metadata.slug}`;
    // …default fallback
  }
}

Summary

  • Instatic persists every admin action to an append-only audit_events table that forbids updates and deletions.
  • The server/repositories/audit.ts file defines a TypeBox AuditActionSchema and the createAuditEvent and listAuditEvents functions.
  • A nanoid primary key and flat metadata_json column keep the log immutable and query-friendly.
  • The GET /admin/api/cms/audit endpoint in server/handlers/cms/audit.ts requires the audit.read capability.
  • Dashboard widgets and user-page formatters consume the API to render human-readable activity feeds.

Frequently Asked Questions

What makes Instatic's audit log append-only?

Instatic enforces a strict write-only rule on the audit_events table: rows are never updated or deleted after insertion. Each record receives a unique nanoid at creation, and the schema only allows additive migrations. These constraints produce an immutable, tamper-evident trail.

How does Instatic enforce type safety for audit events?

Type safety is guaranteed by the AuditActionSchema in server/repositories/audit.ts, which is a closed TypeBox union of string literals. Only actions defined in this schema can be passed to createAuditEvent, preventing invalid or misspelled event types from reaching the database.

Who can access the audit log data?

Access is restricted to users who possess the audit.read capability. The server/handlers/cms/audit.ts handler explicitly calls requireCapability(req, db, 'audit.read') before executing listAuditEvents, ensuring unauthorized requests are rejected at the edge.

How do I add a custom action to the Instatic audit log?

Extend the AuditActionSchema union in server/repositories/audit.ts with a new Type.Literal(...), then invoke createAuditEvent with that action string. The new event will immediately be compatible with the existing read pipeline and UI formatters.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →