Instatic Audit Log Append-Only Architecture: Immutable Activity Tracking for CMS Operations
Instatic implements a tamper-resistant audit system by writing every administrative action to an immutable audit_events table that strictly accepts inserts while prohibiting updates or deletes, creating a complete and verifiable historical trail.
The CoreBunch/Instatic codebase provides a robust append-only audit log architecture designed to record every meaningful administrative action across the platform. This architectural pattern guarantees that once an event is persisted, it cannot be altered or removed, establishing a trustworthy record of authentication events, content mutations, role changes, and plugin lifecycle operations.
Storage Layer and Schema Design
At the foundation of the audit system lies a single database table named audit_events. According to the migration definitions and schema documentation, this table operates under strict immutability constraints—rows are never updated or deleted, and every new action results in a fresh insertion.
The table stores event metadata in a flat JSON structure called metadata_json. To maintain query efficiency and UI rendering simplicity, the AuditMetadata type constrains this data to Record<string, string | number | boolean | null | string[]>, explicitly disallowing nested objects.
All writes to this table are funneled through a centralized repository layer defined in server/repositories/audit.ts. This module exports the closed-union AuditActionSchema, which type-checks all permissible action strings, and provides two primary helpers: createAuditEvent for writes and listAuditEvents for reads.
Enforcing the Append-Only Guarantee
The codebase enforces immutability through multiple layers to prevent accidental or malicious tampering.
First, the schema documentation explicitly states that "events are never updated or deleted." Second, the createAuditEvent function serves as the sole writer to the audit_events table; a comprehensive review of the repository confirms no UPDATE or DELETE statements target this table anywhere else in the application. Third, the test suite in src/__tests__/server/auditLogEdges.test.ts verifies that only insertion operations occur, providing automated regression protection against mutating queries.
HTTP API and Access Control
Read access to the audit trail is exposed through a dedicated, read-only endpoint implemented in server/handlers/cms/audit.ts. The handler responds to GET /admin/api/cms/audit and guards access via the audit.read capability.
export async function handleAuditRoutes(req: Request, db: DbClient) {
const url = new URL(req.url)
if (url.pathname !== `${CMS_API_PREFIX}/audit`) return null
const actor = await requireCapability(req, db, 'audit.read')
if (actor instanceof Response) return actor
if (req.method !== 'GET') return methodNotAllowed()
return jsonResponse({ events: await listAuditEvents(db) })
}
This implementation ensures that consumption of audit data remains separate from the write pipeline, reinforcing the append-only nature by exposing no modification interface.
UI Integration and Event Rendering
The audit log surfaces in the admin dashboard through two primary components. The ActivityWidget (src/admin/pages/dashboard/widgets/ActivityWidget.tsx) displays recent events on the main dashboard, while the dedicated Users → Audit tab utilizes formatting utilities from src/admin/pages/users/utils/audit.ts.
The UI layer transforms raw database records into human-readable descriptions using pattern matching on the action type:
export function formatAuditTitle(event: AuditEvent): string {
switch (event.action) {
case 'login.success': return `Login succeeded for ${event.actorLabel}`
case 'data.row.publish': return `Published ${event.metadata.slug}`
default: return `Event ${event.action}`
}
}
By consuming the flat metadata structure defined in the repository layer, the UI avoids complex parsing logic while maintaining clear audit trail visibility.
Writing Audit Events in Practice
When an administrative operation mutates persisted state—such as publishing a row or installing a plugin—the surrounding handler invokes createAuditEvent immediately after the state change succeeds. This proximity to the source of truth ensures audit failures surface naturally rather than being treated as best-effort side effects.
The following TypeScript snippet demonstrates recording a publish event:
import { createAuditEvent } from '../../server/repositories/audit'
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'),
})
Retrieving the latest 100 events requires a simple repository call:
import { listAuditEvents } from '../../server/repositories/audit'
const recent = await listAuditEvents(db) // default limit = 100
// recent is an array of AuditEvent objects ready for UI rendering
Summary
- Immutable Storage: The
audit_eventstable in CoreBunch/Instatic accepts onlyINSERToperations, guaranteeing a tamper-resistant historical record. - Centralized Repository: All writes flow through
server/repositories/audit.ts, which enforces type safety viaAuditActionSchemaand prevents mutations through a singlecreateAuditEventinterface. - Read-Only API: The
GET /admin/api/cms/auditendpoint inserver/handlers/cms/audit.tsprovides secure, capability-gated access without exposing modification vectors. - Flat Metadata: Events carry structured metadata as simple key-value pairs, optimizing query performance and UI rendering in components like
ActivityWidget.tsx. - Synchronous Recording: Audit events are written immediately after state mutations, ensuring reliability and consistency with the source of truth.
Frequently Asked Questions
What defines the append-only nature of Instatic's audit log?
The append-only architecture relies on a single source of truth—the createAuditEvent function in server/repositories/audit.ts—which exclusively executes INSERT statements against the audit_events table. The codebase contains no UPDATE or DELETE operations for this table, and the schema documentation explicitly prohibits modifications, ensuring every recorded event remains immutable throughout its lifecycle.
How does the system prevent nested metadata from complicating queries?
The AuditMetadata type restricts metadata to flat scalar values and arrays of strings, explicitly disallowing nested objects. This design decision, implemented in the repository layer, keeps database queries efficient and eliminates the need for complex JSON traversal when rendering audit trails in the admin interface.
Which capabilities control access to audit log data?
Access requires the audit.read capability, checked by the handleAuditRoutes function in server/handlers/cms/audit.ts. This permission gate ensures that only authorized administrators can retrieve the immutable event history through the GET /admin/api/cms/audit endpoint.
When are audit events triggered during state changes?
Audit events are created immediately after successful state mutations, such as publishing a row or updating a user role. This synchronous approach, implemented throughout the CMS handlers, ensures that audit trails fail alongside their associated operations rather than being treated as asynchronous, best-effort side effects.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →