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

> Discover how Instatic ensures audit immutability with an append-only log. Learn how the `createAuditEvent` function records every admin action, preventing modification or deletion. See the code in audit.ts.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-30

---

**Instatic guarantees audit immutability by restricting the `audit_events` table to INSERT operations only, using the `createAuditEvent` function in [`server/repositories/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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.

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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.

```typescript
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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/docs/features/audit-log.md), while implementation details can be found in the core repository at [`server/repositories/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/audit.ts), the HTTP handlers in [`server/handlers/cms/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/audit.ts), and the display utilities in [`src/admin/pages/users/utils/audit.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/users/utils/audit.ts).