Audit Log Patterns
Every significant admin and member action in VeloCMS writes to the audit_logs collection via the auditLog() helper in src/lib/audit/log.ts. The write is non-blocking — the originating action succeeds regardless of whether the audit write succeeds. This design choice means audit infrastructure never degrades user-visible operations, at the cost of occasional missed entries under extreme failure conditions.
The auditLog() helper
import { auditLog } from "@/lib/audit/log";
// Basic usage — fire-and-forget (do NOT await in hot paths)
void auditLog({
actor: { id: session.user.id, type: "user" },
tenantId: session.tenantId,
action: "post.published",
target: { type: "post", id: post.id, name: post.title },
});
// With metadata (e.g. changed fields diff)
void auditLog({
actor: { id: session.user.id, type: "user" },
tenantId: session.tenantId,
action: "settings.updated",
target: { type: "site_settings", id: settings.id },
metadata: { changed_fields: ["blog_name", "description"] },
});
// System-initiated (cron job, webhook handler)
void auditLog({
actor: { id: "cron", type: "system" },
tenantId: null, // platform-level event
action: "subscription.upgraded",
target: { type: "tenant", id: tenantId, name: tenantSlug },
});Actor types
Every audit entry has an actor — who or what triggered the event. The actor_type field is a three-value enum.
| actor_type | Who | Example |
|---|---|---|
| user | Blog owner or team member (users collection) | Admin publishing a post, team member deleting media |
| member | Reader/subscriber (blog_members collection) | Member updating their account, member canceling subscription |
| system | Automated action (cron, webhook, API key) | Stripe webhook upgrading a plan, cron archiving old jobs |
Action verb registry
The AUDIT_ACTIONS constant in src/lib/pocketbase/schemas/audit-log.ts is the canonical registry of action verbs. All verbs follow the format {resource}.{verb}. Using the registry makes log queries predictable and enables structured filtering in the audit log UI.
export const AUDIT_ACTIONS = [
// Posts
"post.created", "post.updated", "post.published", "post.deleted", "post.archived",
// Comments
"comment.submitted", "comment.approved", "comment.rejected", "comment.spam", "comment.deleted",
// Settings
"settings.updated",
// Team
"team.member_invited", "team.member_removed", "team.role_changed",
// Media
"media.uploaded", "media.deleted", "media.metadata_updated",
// API Keys
"apikey.created", "apikey.revoked",
// Membership / Billing
"subscription.upgraded", "subscription.downgraded", "subscription.canceled",
// GDPR
"gdpr.data_export_requested", "gdpr.data_deleted",
// ... (see full list in the source file)
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];IP hashing for privacy
Raw IP addresses are never stored. The hashIp() function in src/lib/audit/log.ts computes HMAC-SHA256 of (AUDIT_LOG_SALT + rawIp) and stores the first 16 hex characters. The salt (from the AUDIT_LOG_SALT environment variable, defaults to velocms-audit-default-salt) prevents rainbow-table attacks against the short hash prefix. The extractIpHash() function reads the x-forwarded-for header in Next.js server context and returns the hash.
export function hashIp(rawIp: string): string {
const salt = process.env.AUDIT_LOG_SALT || "velocms-audit-default-salt";
return createHash("sha256")
.update(salt + rawIp)
.digest("hex")
.slice(0, 16); // 16-char prefix — enough for dedup, not enough to reverse
}Tenant scoping
The tenant_id field is nullable. Non-null entries are tenant-scoped events visible in the tenant's audit log UI. Null entries are platform-level events (GDPR deletions, deploy hooks, Stripe billing events). The audit_logs collection sits in the GLOBAL_ALLOWLIST in multi-instance.ts — it is intentionally not in TENANT_SCOPED_COLLECTIONS, because the platform admin needs to read all entries. Tenant-level access is gated by the PocketBase API rules using tenant_id.owner_id checks.
Non-blocking write contract
auditLog() catches all errors internally and logs them via the VeloCMS logger (log.error). It never re-throws. The function signature returns Promise<void> but callers use void auditLog() rather than awaiting. This contract means: audit entries are best-effort. In testing environments where POCKETBASE_ADMIN_EMAIL is unset, the function logs a warning and returns immediately rather than crashing.
export async function auditLog(input: AuditLogInput): Promise<void> {
try {
// ... write to audit_logs collection
} catch (err) {
// Non-blocking — audit failures MUST NOT break the originating action
log.error("audit log: write failed (non-fatal)", {
error: err instanceof Error ? err.message : "unknown",
});
}
}
// Batch variant — all entries fire in parallel, errors isolated
export async function auditLogBatch(inputs: AuditLogInput[]): Promise<void> {
await Promise.allSettled(inputs.map(auditLog));
}Retention
Audit logs are retained indefinitely in the current implementation. A retention cron that deletes entries older than a configurable number of days is planned for a future sprint. Until then, PocketBase's SQLite file grows with every audit entry. For high-volume deployments (many tenants, frequent writes), monitor the pb_data volume and plan accordingly. A reasonable retention policy is 90 days for tenant events and 365 days for platform-level events.
Data model
| Field | Type | Description |
|---|---|---|
| actor_id | string | null | ID of the user or member, null for system |
| actor_type | user | member | system | Auth collection the actor belongs to |
| tenant_id | string | null | Tenant scope, null = platform-level |
| action | string | Verb from AUDIT_ACTIONS or custom string |
| target_type | string | null | Resource type (post, settings, media, etc.) |
| target_id | string | null | PocketBase record ID of the resource |
| target_name | string | null | Denormalized human-readable name |
| ip_hash | string | null | 16-char HMAC-SHA256 prefix of source IP |
| user_agent | string | null | Truncated User-Agent (max 256 chars) |
| metadata | JSON | null | Arbitrary context (changed fields diff, etc.) |
Reference
| File | Responsibility |
|---|---|
| src/lib/audit/log.ts | auditLog(), auditLogBatch(), hashIp(), extractIpHash() |
| src/lib/pocketbase/schemas/audit-log.ts | AUDIT_ACTIONS registry, AuditAction type, Zod schemas |
| pb/pb_migrations/ (audit_logs migration) | PocketBase collection schema + API rules |