How Instatic Implements an Append-Only Audit Log for Admin Actions

Instatic guarantees audit immutability by restricting the audit_events table to INSERT operations only, using the createAuditEvent function in server/repositories/audit.ts to record every administrative action without the possibility of modification or deletion.

Instatic, an open-source CMS maintained by CoreBunch, maintains a complete history of administrative operations through a strictly append-only audit log. At the core of this system lies the audit repository in server/repositories/audit.ts, which enforces immutability by design—ensuring that once an action is recorded, it becomes a permanent, tamper-proof part of the system history.

The Append-Only Architecture

The audit log's integrity stems from a deliberate architectural constraint: the audit_events table never undergoes UPDATE or DELETE operations. According to the source code in server/repositories/audit.ts, the repository exclusively appends new rows to capture administrative activity, creating an immutable ledger that serves as the single source of truth for all system changes.

Database Schema Design

Each row in the audit_events table stores the raw identifiers (actor_user_id, target_id), the specific action name (such as user.create or site.publish), optional metadata objects, plus contextual request data including the IP address and user-agent header. Every entry receives a unique nanoid() primary key upon insertion, and records are permanently ordered by the created_at timestamp. This schema guarantees that historical data remains static and verifiable.

Recording Actions with createAuditEvent

When an administrator performs a sensitive operation—such as creating a user in server/handlers/cms/users.ts or updating a role—the corresponding handler initiates the audit trail by invoking createAuditEvent. This function constructs a pure INSERT statement without any conditional logic that could modify existing rows.

The flow follows these steps:

  1. Action Handler Detection — The handler identifies an audit-worthy operation (e.g., after successfully creating a user record).
  2. Event Construction — The handler calls createAuditEvent(db, { ... }) with details including the current user's ID, target resource ID, action type, and request metadata.
  3. Immutable Insertion — The repository generates a new nanoid() ID and executes an INSERT statement into audit_events, committing the event permanently.
// Example from a user creation handler
await createAuditEvent(db, {
  actorUserId: currentUser.id,
  action: 'user.create',
  targetType: 'user',
  targetId: newUser.id,
  metadata: { email: newUser.email },
  ipAddress: request.ip,
  userAgent: request.headers.get('user-agent'),
});

Querying the Audit Trail

Retrieving the audit history relies on listAuditEvents, which respects the append-only constraint by using strictly read-only operations. This function returns events ordered by created_at descending, ensuring the most recent administrative actions appear first while never altering the stored history.

The handleAuditRoutes endpoint in server/handlers/cms/audit.ts exposes this data via the GET /admin/api/cms/audit endpoint, defaulting to the most recent 100 entries to ensure efficient query performance.

// Inside the GET /admin/api/cms/audit handler
const events = await listAuditEvents(db);   // defaults to the most recent 100
return jsonResponse({ events });

Enriching Events for the Admin UI

Raw database rows contain only IDs and technical action names, so the frontend utilities in src/admin/pages/users/utils/audit.ts transform these records into human-readable formats. The auditTitle function generates descriptive text based on the action type and current entity names, while auditActor resolves the actor_user_id to a display name using the current user cache.

This separation of concerns preserves the append-only integrity of the storage layer while allowing the presentation layer to reflect current system state (such as updated user names) without modifying historical records.

import { auditTitle, auditActor } from '@/admin/pages/users/utils/audit';

const title = auditTitle(event, usersById, rolesById);
const actor = auditActor(event, usersById);
console.log(`${title} ${actor}`);

Summary

  • The audit system in server/repositories/audit.ts strictly appends to the audit_events table, explicitly avoiding UPDATE and DELETE operations to ensure immutability.
  • ** createAuditEvent** captures comprehensive context—including actor ID, target ID, action name, metadata, IP address, and user agent—using a generated nanoid() primary key.
  • ** listAuditEvents** retrieves history in reverse chronological order without modifying stored records, defaulting to the latest 100 events for performance.
  • UI utilities in src/admin/pages/users/utils/audit.ts enrich raw database rows with current entity names while preserving the underlying append-only audit trail.

Frequently Asked Questions

Can audit events be deleted or modified after creation?

No. The repository design in server/repositories/audit.ts explicitly omits UPDATE and DELETE operations for the audit_events table. This architectural constraint ensures that every administrative action remains in the log permanently, creating a tamper-proof audit trail as implemented in the CoreBunch/Instatic repository.

What specific data is captured for each administrative action?

Each audit event records the actor_user_id (who performed the action), target_id and targetType (what resource was affected), the specific action name (e.g., user.create), optional metadata (such as email addresses or configuration changes), plus contextual request data including the IP address and user-agent header.

How does the system handle high volumes of audit entries?

The listAuditEvents function defaults to returning only the most recent 100 events, ordered by created_at descending. This pagination approach ensures consistent query performance regardless of total log volume, while maintaining the append-only integrity of the underlying storage by never requiring table-wide modifications.

Where is the audit log functionality documented?

Feature documentation resides in docs/features/audit-log.md, while implementation details can be found in the core repository at server/repositories/audit.ts, the HTTP handlers in server/handlers/cms/audit.ts, and the display utilities in src/admin/pages/users/utils/audit.ts.

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 →