Audit log
Append-only record of significant actions with an admin viewer.
The audit log is an append-only record of significant actions — who did what, to what, when. Other features record events through a single write seam; there is no update or delete path. An admin-only viewer under settings reads the log back with filtering and gapless cursor pagination.
Optional feature
Audit log is optional (core: false, requires: ["orgs"]). Rows are
org-scoped; the recording seam is a soft integration other features call behind
@feature:audit-log markers.
What it does
- Single write seam —
audit(ctx, action, target, metadata?)(server/audit.ts) is the one write point: a singledb.auditLog.create. It is fire-and-forget (D-017) — a failed write is logged and swallowed, never thrown into the caller. - Context capture —
AuditContextcarriesorganizationId,actorId?,actorType(user|system|api-key), an@feature:api-keys-gatedapiKeyId?, andip?.userAuditContext({ … })builds a user-actor context and derives the IP fromX-Forwarded-For/X-Real-IP. - Read service —
listAuditLogEntries(db, params)is the single read source, newest-first ([createdAt desc, id desc], a total order), fetchinglimit + 1to computenextCursorwithout a count. Filters:action,actorId,from/to,cursor.DEFAULT_AUDIT_PAGE_SIZE = 50,MAX = 100. - Admin-gated router —
auditLogRouterexposes onelistquery onorgProcedure.role("admin"), scoped to the caller's active org (no cross-org leakage). - Viewer —
AuditLogViewerat/settings/audit-log: a filter bar (action / actor / from / to, applied on submit), a newest-first table, per-row expandable metadata + IP, and a "Load more" button overuseInfiniteQuery.
Models it adds
| Model | Purpose |
|---|---|
AuditLog | One append-only entry: actorId/actorType, action (dot-namespaced, e.g. member.role_changed), targetType/targetId, metadata (Json), and ip. actorId is a plain nullable column, not a User FK, so deleting the actor never cascades away history. |
Adds the marker-wrapped Organization.auditLogs relation. org.delete is
deliberately un-audited (D-060): the AuditLog rows cascade with the org, so an
org.deleted row would be purged by the very delete it records.
Env it needs
None. metadata uses native Prisma Json, so no new dependency, script, or
compose service. The append-only design has no update or delete surface.
Composition
requires: ["orgs"]— every row is org-scoped (AuditLog.organizationIdcascades fromOrganization) and every viewer query runs onorgProcedure.role("admin").- Recording callers (soft, via
@feature:audit-logmarkers — notfeatures.jsonrequires): core org/member/invite mutations callaudit()directly; api-keys, flags, and files receive it as an injectedaudit?seam; billing's Stripe webhook recordssystem-actor events; and admin both writes override/impersonation events and reads the tail throughlistAuditLogEntries. The central adapter isrecordAuditinsrc/server/router.ts. - Dependents — none; no feature requires audit-log, so every caller stands alone when it is pruned.
What pruning removes
Leaving audit-log out drops features/audit-log, the /settings/audit-log route,
the retrofit integration test, the AuditLog model, and the
Organization.auditLogs relation — plus the recordAudit seam and the marked
audit(...) blocks across the callers. The core mutations still run (their audit
calls are additive and fire-and-forget); the injected-seam features fall back to
createXRouter({}) with the optional audit?.(...) becoming a no-op; and admin
loses its audit tail while otherwise standing.