Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,13 @@ Customize the plugin with the following configuration(for example):
auditorPlugin({
autoDeleteInterval: '1d',
collection: {
changes: { excludeKeys: ['content'] },
trackCollections: [
{
slug: 'media',
hooks: {
afterChange: {
update: {
enabled: true,
},
update: { enabled: true },
},
},
},
Expand All @@ -69,6 +68,8 @@ auditorPlugin({
}),
```



## ✨ **Things you can customize:**

- 🔒 **You can control the accessibility of logs.** Due to the increased security of the plugin, other operations are not available.
Expand Down
9 changes: 8 additions & 1 deletion src/collections/auditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { AuditHookOperationType } from '../types/pluginOptions.js'
import type { hookTypes } from './../pluginUtils/configHelpers.js'

import { defaultCollectionValues } from '../Constant/Constant.js'

export type AuditorLog = {
changes?: Record<string, unknown>
collection: string
documentId?: string
hook: (typeof hookTypes)[number]
Expand Down Expand Up @@ -71,6 +71,13 @@ export const auditor: CollectionConfig = {
>,
required: true,
},
{
name: 'changes',
type: 'json',
admin: {
description: 'Field-level changes or full document snapshot for this operation',
},
},
{
name: 'createdAt',
type: 'date',
Expand Down
81 changes: 81 additions & 0 deletions src/core/log-builders/collections/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ import type {
} from './../../../types/pluginOptions.js'
import type { SharedArgs } from './shared.js'

import {
excludeKeysFromRecord,
normalizeForDiff,
redactKeysInRecord,
redactShallowDiff,
shallowDiff,
} from '../../../utils/diff.js'
import { emitWrapper } from './helpers/emitWrapper.js'
import { handleDebugMode } from './helpers/handleDebugMode.js'

Expand All @@ -36,21 +43,95 @@ export const hookHandlers = {
args: Parameters<CollectionAfterChangeHook>[0],
sharedArgs: SharedArgs,
baseLog: Partial<AuditorLog>,
data?: {
pluginOpts: PluginOptions
userActivatedHooks?: Partial<HookTrackingOperationMap>
userHookConfig?: HookTrackingOperationMap['afterChange']
userHookOperationConfig?: HookOperationConfig<'afterChange'>
},
) => {
baseLog.type = 'audit'
baseLog.documentId = args.doc.id
baseLog.user = sharedArgs.req?.user?.id || 'anonymous'

try {
const globalChangeCfg = data?.pluginOpts?.collection?.changes
const opChangeCfg = data?.userHookOperationConfig?.changes
const changesEnabled = opChangeCfg?.enabled ?? globalChangeCfg?.enabled ?? true
if (!changesEnabled) {
return args.doc
}

const excludeMeta = ['updatedAt', 'createdAt', '__v']
const excludeKeys = [
...excludeMeta,
...(globalChangeCfg?.excludeKeys ?? []),
...(opChangeCfg?.excludeKeys ?? []),
]
const redactKeys = [
...(globalChangeCfg?.redactKeys ?? []),
...(opChangeCfg?.redactKeys ?? []),
]

if (args.operation === 'update' && args.previousDoc) {
const next = normalizeForDiff(args.doc as Record<string, unknown>)
const prev = normalizeForDiff(args.previousDoc as Record<string, unknown>)
let diff = shallowDiff(next, prev, { excludeKeys })
if (redactKeys.length) {
diff = redactShallowDiff(diff, redactKeys)
}
if (Object.keys(diff).length > 0) {
;(baseLog as AuditorLog).changes = diff
}
} else if (args.operation === 'create') {
let snap = args.doc as Record<string, unknown>
if (excludeKeys.length) {snap = excludeKeysFromRecord(snap, excludeKeys)}
if (redactKeys.length) {snap = redactKeysInRecord(snap, redactKeys)
;}(baseLog as AuditorLog).changes = snap
}
} catch {
// ignore diff errors to avoid blocking logs
}

return args.doc
},
afterDelete: (
args: Parameters<CollectionAfterDeleteHook>[0],
sharedArgs: SharedArgs,
baseLog: Partial<AuditorLog>,
data?: {
pluginOpts: PluginOptions
userActivatedHooks?: Partial<HookTrackingOperationMap>
userHookConfig?: HookTrackingOperationMap['afterDelete']
userHookOperationConfig?: HookOperationConfig<'afterDelete'>
},
) => {
baseLog.type = 'audit'
baseLog.documentId = args.doc.id
baseLog.user = sharedArgs.req?.user?.id || 'anonymous'
try {
const globalChangeCfg = data?.pluginOpts?.collection?.changes
const opChangeCfg = data?.userHookOperationConfig?.changes
const changesEnabled = opChangeCfg?.enabled ?? globalChangeCfg?.enabled ?? true
if (!changesEnabled) {return}
const excludeMeta = ['updatedAt', 'createdAt', '__v']
const excludeKeys = [
...excludeMeta,
...(globalChangeCfg?.excludeKeys ?? []),
...(opChangeCfg?.excludeKeys ?? []),
]
const redactKeys = [
...(globalChangeCfg?.redactKeys ?? []),
...(opChangeCfg?.redactKeys ?? []),
]

let snap = args.doc as Record<string, unknown>
if (excludeKeys.length) {snap = excludeKeysFromRecord(snap, excludeKeys)}
if (redactKeys.length) {snap = redactKeysInRecord(snap, redactKeys)
;}(baseLog as AuditorLog).changes = snap
} catch {
// ignore
}
},
afterError: (
args: Parameters<CollectionAfterErrorHook>[0],
Expand Down
26 changes: 25 additions & 1 deletion src/types/pluginOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ export type HookModesConfig = {
}

export type HookOperationConfig<TCustomLogger extends keyof AllCollectionHooks> = {
/**
* 📝 Changes capture controls
*
* - enabled: toggle capturing changes for this operation
* - excludeKeys: omit these top-level keys from diffs/snapshots
* - redactKeys: mask these top-level keys in diffs/snapshots
*/
changes?: {
enabled?: boolean
excludeKeys?: string[]
redactKeys?: string[]
}
/**
* 📝 Custom log creation at a operation level
*
Expand Down Expand Up @@ -213,6 +225,7 @@ export type HookOperationConfig<TCustomLogger extends keyof AllCollectionHooks>
args: Parameters<AllCollectionHooks[TCustomLogger]>[0],
fields: Omit<AuditorLog, 'hook'>,
) => Omit<AuditorLog, 'hook'> | Promise<Omit<AuditorLog, 'hook'>>

/**
* 📝 Specifies whether logging is enabled or disabled for this operation within the hook
*
Expand All @@ -236,7 +249,6 @@ export type HookOperationConfig<TCustomLogger extends keyof AllCollectionHooks>
*
*/
enabled?: boolean

/**
* 📝 Auxiliary side modes
*
Expand Down Expand Up @@ -1816,6 +1828,18 @@ export type PluginCollectionConfig = {
*
*/
buffer?: BufferConfig
/**
* 📝 Default changes capture controls (overridable per-operation)
*
* - enabled: toggle capturing changes
* - excludeKeys: omit these top-level keys from diffs/snapshots
* - redactKeys: mask these top-level keys in diffs/snapshots
*/
changes?: {
enabled?: boolean
excludeKeys?: string[]
redactKeys?: string[]
}
/**
* 📝 Collection main configuration
*
Expand Down
123 changes: 123 additions & 0 deletions src/utils/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
export type ShallowDiff = Record<string, { new: unknown; old: unknown }>

/**
* Compute a shallow diff between two plain objects (top-level keys only).
* Returns a map of keys whose values differ, each with `{ old, new }`.
*
* @param next The updated object.
* @param prev The previous object.
* @param options Optional controls such as `excludeKeys` to skip specific fields.
*/
export const shallowDiff = (
next: null | Record<string, unknown> | undefined,
prev: null | Record<string, unknown> | undefined,
options?: { excludeKeys?: string[] },
): ShallowDiff => {
const out: ShallowDiff = {}
if (!next && !prev) {return out}
const exclude = new Set(options?.excludeKeys ?? [])

const keys = new Set<string>([
...Object.keys((next || {})),
...Object.keys((prev || {})),
])

for (const key of keys) {
if (exclude.has(key)) {continue}
const a = (prev)?.[key]
const b = (next)?.[key]
if (JSON.stringify(a) !== JSON.stringify(b)) {
out[key] = { new: b, old: a }
}
}

return out
}

/**
* Normalize common Payload relationship-like shapes to compact identifiers for diffing.
* - Object with an `id` -> reduce to its `id`.
* - Array of objects with `id` -> array of ids (sorted as strings for stability).
* - Leaves other values as-is (e.g., rich text content trees).
*/
export const normalizeForDiff = (
obj: null | Record<string, unknown> | undefined,
): Record<string, unknown> => {
if (!obj) {return {}}
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
out[key] = reduceValue(value)
}
return out
}

const isRelObject = (v: unknown): v is { id: unknown } =>
!!v && typeof v === 'object' && 'id' in (v as Record<string, unknown>)

const reduceValue = (value: unknown): unknown => {
if (Array.isArray(value)) {
// If array items are relation-like objects, reduce to their ids
if (value.every((it) => isRelObject(it) || typeof it === 'string' || typeof it === 'number')) {
const ids = value.map((it) => (isRelObject(it) ? (it as any).id : it))
return ids
.map((x) => (typeof x === 'string' || typeof x === 'number' ? String(x) : ''))
.sort()
}
return value
}

if (isRelObject(value)) {
return (value as any).id
}

return value
}

/**
* Remove selected top-level keys from a record.
*/
export const excludeKeysFromRecord = (
obj: Record<string, unknown>,
keys: string[],
): Record<string, unknown> => {
const omit = new Set(keys)
return Object.fromEntries(
Object.entries(obj).filter(([k]) => !omit.has(k)),
)
}

/**
* Redact selected top-level keys in a record by replacing their values.
*/
export const redactKeysInRecord = (
obj: Record<string, unknown>,
keys: string[],
replacement = '[REDACTED]',
): Record<string, unknown> => {
if (!keys?.length) {return obj}
const redact = new Set(keys)
const out: Record<string, unknown> = { ...obj }
for (const key of redact) {
if (key in out) {out[key] = replacement}
}
return out
}

/**
* Redact selected keys in a shallow diff by replacing old/new values.
*/
export const redactShallowDiff = (
diff: ShallowDiff,
keys: string[],
replacement = '[REDACTED]',
): ShallowDiff => {
if (!keys?.length) {return diff}
const redact = new Set(keys)
const out: ShallowDiff = { ...diff }
for (const key of Object.keys(out)) {
if (redact.has(key)) {
out[key] = { new: replacement, old: replacement }
}
}
return out
}